blasttrace 1.2.0__py2.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,815 @@
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.dart_flutter_parser import parse_dart_content
27
+ from blasttrace.parsers.swift_ios_parser import parse_swift_content
28
+ from blasttrace.parsers.kotlin_android_parser import parse_kotlin_content
29
+ from blasttrace.parsers.vue_nuxt_parser import parse_vue_content
30
+ from blasttrace.parsers.go_parser import parse_go_content
31
+ from blasttrace.parsers.test_mapper import (
32
+ is_test_file,
33
+ parse_python_test_content,
34
+ parse_ts_test_content,
35
+ )
36
+
37
+
38
+ _IGNORED_DIRS = {
39
+ ".git", "__pycache__", "node_modules", ".venv", "venv", "env",
40
+ "build", "dist", ".pytest_cache", ".mypy_cache", ".next", ".turbo",
41
+ ".dart_tool", ".flutter-plugins", "Pods", ".gradle", ".build", ".swiftpm", "DerivedData", "website", "website_v3", "egg-info",
42
+ }
43
+
44
+
45
+ def build_fullstack_topology(repo_path: str) -> Dict[str, Any]:
46
+ """
47
+ Scans a repository and builds the complete cross-stack contract topology
48
+ spanning Database, Backend, API, Frontend, and Tests.
49
+ """
50
+ if not os.path.exists(repo_path):
51
+ return {"error": f"Repository path not found: {repo_path}"}
52
+
53
+ root = os.path.abspath(repo_path)
54
+ if os.path.isfile(root):
55
+ root = os.path.dirname(root)
56
+
57
+ nodes_by_id: Dict[str, Dict[str, Any]] = {}
58
+ edges: List[Dict[str, str]] = []
59
+ edge_set = set()
60
+
61
+ # Collected layer registries
62
+ db_tables: Dict[str, Dict[str, Any]] = {}
63
+ orm_models: Dict[str, Dict[str, Any]] = {}
64
+ py_modules: Dict[str, Dict[str, Any]] = {}
65
+ api_routes: List[Dict[str, Any]] = []
66
+ fe_modules: Dict[str, Dict[str, Any]] = {}
67
+ flutter_modules: Dict[str, Dict[str, Any]] = {}
68
+ swift_modules: Dict[str, Dict[str, Any]] = {}
69
+ kotlin_modules: Dict[str, Dict[str, Any]] = {}
70
+ vue_modules: Dict[str, Any] = {}
71
+ go_modules: Dict[str, Any] = {}
72
+ test_suites: Dict[str, Dict[str, Any]] = {}
73
+ module_import_edges: List[Dict[str, str]] = []
74
+
75
+ def _add_node(n: Dict[str, Any]) -> None:
76
+ nid = n["id"]
77
+ if nid not in nodes_by_id:
78
+ nodes_by_id[nid] = n
79
+ else:
80
+ nodes_by_id[nid].update(n)
81
+
82
+ def _add_edge(src: str, dst: str, relation: str = "impacts") -> None:
83
+ if not src or not dst or src == dst:
84
+ return
85
+ key = (src, dst)
86
+ if key not in edge_set:
87
+ edge_set.add(key)
88
+ edges.append({"source": src, "target": dst, "relation": relation})
89
+
90
+ # Walk repository files
91
+ all_files: List[Tuple[str, str]] = []
92
+ for dirpath, dirnames, filenames in os.walk(root):
93
+ dirnames[:] = [
94
+ d for d in dirnames
95
+ if d not in _IGNORED_DIRS and not d.endswith(".egg-info")
96
+ ]
97
+ for fn in sorted(filenames):
98
+ abs_p = os.path.join(dirpath, fn)
99
+ rel_p = os.path.relpath(abs_p, root).replace("\\", "/")
100
+ all_files.append((abs_p, rel_p))
101
+
102
+ for abs_p, rel_p in all_files:
103
+ lower = rel_p.lower()
104
+ try:
105
+ with open(abs_p, "r", encoding="utf-8", errors="ignore") as f:
106
+ content = f.read()
107
+ except Exception:
108
+ continue
109
+
110
+ # 1. SQL / Prisma schemas
111
+ if lower.endswith(".sql"):
112
+ sql_res = parse_sql_content(content, rel_p)
113
+ for tname, tinfo in sql_res["tables"].items():
114
+ if tname in db_tables:
115
+ db_tables[tname]["columns"].update(tinfo.get("columns", {}))
116
+ else:
117
+ db_tables[tname] = tinfo
118
+ for n in sql_res["nodes"]:
119
+ _add_node(n)
120
+ for e in sql_res["edges"]:
121
+ _add_edge(e["source"], e["target"], e.get("relation", "schema"))
122
+
123
+ elif lower.endswith(".prisma"):
124
+ pr_res = parse_prisma_content(content, rel_p)
125
+ for tname, tinfo in pr_res["tables"].items():
126
+ if tname in db_tables:
127
+ db_tables[tname]["columns"].update(tinfo.get("columns", {}))
128
+ else:
129
+ db_tables[tname] = tinfo
130
+ for n in pr_res["nodes"]:
131
+ _add_node(n)
132
+ for e in pr_res["edges"]:
133
+ _add_edge(e["source"], e["target"], e.get("relation", "schema"))
134
+
135
+ # 2. Test files (Python or TS/JS)
136
+ elif is_test_file(rel_p):
137
+ if lower.endswith(".py"):
138
+ t_res = parse_python_test_content(content, rel_p)
139
+ else:
140
+ t_res = parse_ts_test_content(content, rel_p)
141
+ test_suites[rel_p] = t_res
142
+ for n in t_res["nodes"]:
143
+ _add_node(n)
144
+ for e in t_res["edges"]:
145
+ _add_edge(e["source"], e["target"], e.get("relation", "test"))
146
+
147
+ # 3. Python Backend / ORM / API files
148
+ elif lower.endswith(".py"):
149
+ py_res = parse_python_content(content, rel_p)
150
+ py_modules[rel_p] = py_res
151
+ for n in py_res["nodes"]:
152
+ _add_node(n)
153
+ for e in py_res["edges"]:
154
+ _add_edge(e["source"], e["target"], e.get("relation", "python"))
155
+
156
+ orm_res = parse_orm_python_content(content, rel_p)
157
+ for cls_name, minfo in orm_res["orm_models"].items():
158
+ orm_models[cls_name] = minfo
159
+ tbl = minfo["table"]
160
+ db_tables.setdefault(tbl, {"name": tbl, "file": rel_p, "columns": {}})
161
+ for fname, finfo in minfo["fields"].items():
162
+ db_tables[tbl]["columns"].setdefault(fname.lower(), {
163
+ "name": fname.lower(),
164
+ "table": tbl,
165
+ "type": finfo.get("args", "ORM"),
166
+ "nullable": "nullable=False" not in finfo.get("args", ""),
167
+ "has_default": "default=" in finfo.get("args", ""),
168
+ "file": rel_p,
169
+ })
170
+ for n in orm_res["nodes"]:
171
+ _add_node(n)
172
+ for e in orm_res["edges"]:
173
+ _add_edge(e["source"], e["target"], e.get("relation", "orm"))
174
+
175
+ api_res = parse_python_api_routes(content, rel_p)
176
+ api_routes.extend(api_res["routes"])
177
+ for n in api_res["nodes"]:
178
+ _add_node(n)
179
+ for e in api_res["edges"]:
180
+ _add_edge(e["source"], e["target"], e.get("relation", "api"))
181
+
182
+ # 4. TypeScript / React / JS files
183
+ elif lower.endswith((".ts", ".tsx", ".js", ".jsx")):
184
+ fe_res = parse_ts_react_content(content, rel_p)
185
+ fe_modules[rel_p] = fe_res
186
+ for n in fe_res["nodes"]:
187
+ _add_node(n)
188
+ for e in fe_res["edges"]:
189
+ _add_edge(e["source"], e["target"], e.get("relation", "frontend"))
190
+
191
+ ts_api = parse_ts_js_api_routes(content, rel_p)
192
+ api_routes.extend(ts_api["routes"])
193
+ for n in ts_api["nodes"]:
194
+ _add_node(n)
195
+ for e in ts_api["edges"]:
196
+ _add_edge(e["source"], e["target"], e.get("relation", "api"))
197
+
198
+ # 4B. Dart / Flutter Mobile & POS files
199
+ elif lower.endswith(".dart"):
200
+ dart_res = parse_dart_content(content, rel_p)
201
+ flutter_modules[rel_p] = dart_res
202
+ dart_mod_id = f"dart:{rel_p}"
203
+ _add_node({
204
+ "id": dart_mod_id,
205
+ "type": "flutter_module",
206
+ "name": os.path.basename(rel_p),
207
+ "file": rel_p,
208
+ "layer": "flutter_client",
209
+ })
210
+ for cls_info in dart_res.get("classes", []):
211
+ cid = f"dart:{rel_p}:{cls_info['name']}"
212
+ _add_node({
213
+ "id": cid,
214
+ "type": "flutter_model",
215
+ "name": cls_info["name"],
216
+ "file": rel_p,
217
+ "layer": "flutter_client",
218
+ })
219
+ _add_edge(dart_mod_id, cid, "contains_model")
220
+
221
+ # 4C. Swift / iOS Client files
222
+ elif lower.endswith(".swift"):
223
+ sw_res = parse_swift_content(content, rel_p)
224
+ swift_modules[rel_p] = sw_res
225
+ sw_mod_id = f"swift:{rel_p}"
226
+ _add_node({
227
+ "id": sw_mod_id,
228
+ "type": "swift_module",
229
+ "name": os.path.basename(rel_p),
230
+ "file": rel_p,
231
+ "layer": "ios_client",
232
+ })
233
+ for cls_info in sw_res.get("classes", []):
234
+ cid = f"swift:{rel_p}:{cls_info['name']}"
235
+ _add_node({
236
+ "id": cid,
237
+ "type": "swift_model",
238
+ "name": cls_info["name"],
239
+ "file": rel_p,
240
+ "layer": "ios_client",
241
+ })
242
+ _add_edge(sw_mod_id, cid, "contains_model")
243
+
244
+ # 4D. Kotlin / Android Client files
245
+ elif lower.endswith((".kt", ".kts")):
246
+ kt_res = parse_kotlin_content(content, rel_p)
247
+ kotlin_modules[rel_p] = kt_res
248
+ kt_mod_id = f"kotlin:{rel_p}"
249
+ _add_node({
250
+ "id": kt_mod_id,
251
+ "type": "kotlin_module",
252
+ "name": os.path.basename(rel_p),
253
+ "file": rel_p,
254
+ "layer": "android_client",
255
+ })
256
+ for cls_info in kt_res.get("classes", []):
257
+ cid = f"kotlin:{rel_p}:{cls_info['name']}"
258
+ _add_node({
259
+ "id": cid,
260
+ "type": "kotlin_model",
261
+ "name": cls_info["name"],
262
+ "file": rel_p,
263
+ "layer": "android_client",
264
+ })
265
+ _add_edge(kt_mod_id, cid, "contains_model")
266
+
267
+ # 4E. Vue.js / Nuxt 3 Frontend files
268
+ elif lower.endswith(".vue"):
269
+ vue_res = parse_vue_content(content, rel_p)
270
+ vue_modules[rel_p] = vue_res
271
+ vue_mod_id = f"vue:{rel_p}"
272
+ _add_node({
273
+ "id": vue_mod_id,
274
+ "type": "vue_module",
275
+ "name": os.path.basename(rel_p),
276
+ "file": rel_p,
277
+ "layer": "web_frontend",
278
+ })
279
+ for comp in vue_res.components:
280
+ cid = f"vue:{rel_p}:{comp.name}"
281
+ _add_node({
282
+ "id": cid,
283
+ "type": "vue_component",
284
+ "name": comp.name,
285
+ "file": rel_p,
286
+ "layer": "web_frontend",
287
+ })
288
+ _add_edge(vue_mod_id, cid, "contains_component")
289
+
290
+ # 4F. Go (Golang) Microservice & Backend files
291
+ elif lower.endswith(".go") and not lower.endswith("_test.go"):
292
+ go_res = parse_go_content(content, rel_p)
293
+ go_modules[rel_p] = go_res
294
+ go_mod_id = f"go:{rel_p}"
295
+ _add_node({
296
+ "id": go_mod_id,
297
+ "type": "go_module",
298
+ "name": os.path.basename(rel_p),
299
+ "file": rel_p,
300
+ "layer": "backend",
301
+ })
302
+ for st in go_res.structs:
303
+ sid = f"go:{rel_p}:{st.name}"
304
+ _add_node({
305
+ "id": sid,
306
+ "type": "go_struct",
307
+ "name": st.name,
308
+ "file": rel_p,
309
+ "layer": "backend",
310
+ })
311
+ _add_edge(go_mod_id, sid, "contains_struct")
312
+ for f in st.fields:
313
+ fid = f"go:{rel_p}:{st.name}.{f.name}"
314
+ _add_node({
315
+ "id": fid,
316
+ "type": "go_field",
317
+ "name": f.name,
318
+ "file": rel_p,
319
+ "layer": "backend",
320
+ })
321
+ _add_edge(sid, fid, "has_field")
322
+ for r in go_res.routes:
323
+ r_id = f"api:{r.method} {r.path}"
324
+ _add_node({
325
+ "id": r_id,
326
+ "type": "api_route",
327
+ "name": f"{r.method} {r.path}",
328
+ "file": rel_p,
329
+ "layer": "api",
330
+ })
331
+ _add_edge(go_mod_id, r_id, "exposes_route")
332
+ api_routes.append({
333
+ "id": r_id,
334
+ "method": r.method,
335
+ "path": r.path,
336
+ "normalized_path": _normalize_route_path(r.path),
337
+ "file": rel_p,
338
+ "function_name": r.handler_name,
339
+ "handler": r.handler_name,
340
+ })
341
+
342
+ # 5. OpenAPI specs
343
+ elif os.path.basename(lower) in ("openapi.json", "openapi.yaml", "openapi.yml", "swagger.json", "swagger.yaml"):
344
+ oa_res = parse_openapi_spec(content, rel_p)
345
+ api_routes.extend(oa_res["routes"])
346
+ for n in oa_res["nodes"]:
347
+ _add_node(n)
348
+ for e in oa_res["edges"]:
349
+ _add_edge(e["source"], e["target"], e.get("relation", "api"))
350
+
351
+ # =========================================================================
352
+ # CROSS-STACK LINKING PASS 1: DB Table/Column <-> ORM Model <-> Python Class/Function
353
+ # =========================================================================
354
+ # Map class names & table names
355
+ class_to_py_id: Dict[str, str] = {}
356
+ func_to_py_ids: Dict[str, List[str]] = {}
357
+ module_stem_to_rel: Dict[str, str] = {}
358
+
359
+ for rel_p, py_info in py_modules.items():
360
+ stem = os.path.splitext(rel_p)[0].replace("/", ".")
361
+ short_stem = os.path.splitext(os.path.basename(rel_p))[0]
362
+ module_stem_to_rel[stem] = rel_p
363
+ module_stem_to_rel[short_stem] = rel_p
364
+
365
+ for cname, cinfo in py_info["classes"].items():
366
+ cls_id = f"py:{rel_p}:{cname}"
367
+ class_to_py_id[cname] = cls_id
368
+ tbl_guess = (cinfo.get("tablename") or _pluralize(cname)).lower()
369
+ if tbl_guess in db_tables:
370
+ _add_edge(f"db:{tbl_guess}", cls_id, "maps_table_to_class")
371
+ if cname in orm_models:
372
+ _add_edge(f"orm:{cname}", cls_id, "orm_to_py_class")
373
+ for fname in cinfo["fields"]:
374
+ col_id = f"db:{tbl_guess}.{fname.lower()}"
375
+ py_field_id = f"py:{rel_p}:{cname}.{fname}"
376
+ if fname.lower() in db_tables[tbl_guess]["columns"]:
377
+ _add_edge(col_id, py_field_id, "maps_column_to_field")
378
+ if cname in orm_models and fname in orm_models[cname]["fields"]:
379
+ _add_edge(f"orm:{cname}.{fname}", py_field_id, "orm_field_to_py_field")
380
+
381
+ for fname in py_info["functions"]:
382
+ func_to_py_ids.setdefault(fname, []).append(f"py:{rel_p}:{fname}")
383
+
384
+ # Link Python imports & cross-module function calls + SQL/ORM references inside functions
385
+ for rel_p, py_info in py_modules.items():
386
+ mod_id = f"py:{rel_p}"
387
+ for imp in py_info["imports"]:
388
+ target_mod = imp.get("module", "")
389
+ target_sym = imp.get("symbol", "")
390
+ resolved_rel = (
391
+ module_stem_to_rel.get(target_mod)
392
+ or module_stem_to_rel.get(target_mod.split(".")[-1])
393
+ or module_stem_to_rel.get(target_sym)
394
+ )
395
+ if resolved_rel and resolved_rel != rel_p:
396
+ # Module import edge for circular dependency detection & module blast radius
397
+ _add_edge(f"py:{resolved_rel}", mod_id, "imported_by")
398
+ module_import_edges.append({"source": mod_id, "target": f"py:{resolved_rel}"})
399
+
400
+ for fn_name, fn_meta in py_info["functions"].items():
401
+ fn_id = f"py:{rel_p}:{fn_name}"
402
+
403
+ # Cross-module function calls
404
+ for called in fn_meta.get("calls", []):
405
+ short_called = called.split(".")[-1]
406
+ for target_fn_id in func_to_py_ids.get(short_called, []):
407
+ if target_fn_id != fn_id:
408
+ _add_edge(target_fn_id, fn_id, "called_by")
409
+ if called in class_to_py_id:
410
+ _add_edge(class_to_py_id[called], fn_id, "instantiates_class")
411
+
412
+ # Attribute accesses (e.g. user.balance, User.balance) -> link from ORM/DB column to function
413
+ for attr in fn_meta.get("attributes", []):
414
+ attr_short = attr.split(".")[-1].lower()
415
+ for tbl_name, tbl_info in db_tables.items():
416
+ if attr_short in tbl_info["columns"]:
417
+ col_id = f"db:{tbl_name}.{attr_short}"
418
+ _add_edge(col_id, fn_id, "column_read_by_function")
419
+ for cls_name, minfo in orm_models.items():
420
+ for orm_f in minfo["fields"]:
421
+ if orm_f.lower() == attr_short:
422
+ _add_edge(f"orm:{cls_name}.{orm_f}", fn_id, "orm_field_read_by_function")
423
+ if cls_name in class_to_py_id:
424
+ py_cls_file = py_modules_file_for_class(py_modules, cls_name)
425
+ if py_cls_file:
426
+ _add_edge(f"py:{py_cls_file}:{cls_name}.{orm_f}", fn_id, "field_read_by_function")
427
+
428
+ # Raw SQL string literals inside function bodies (e.g. "SELECT id, balance FROM users")
429
+ for s_lit in fn_meta.get("string_literals", []):
430
+ s_low = s_lit.lower()
431
+ for tbl_name, tbl_info in db_tables.items():
432
+ if re.search(rf"\b{re.escape(tbl_name)}\b", s_low):
433
+ _add_edge(f"db:{tbl_name}", fn_id, "queries_table")
434
+ for col_name in tbl_info["columns"]:
435
+ if re.search(rf"\b{re.escape(col_name)}\b", s_low):
436
+ _add_edge(f"db:{tbl_name}.{col_name}", fn_id, "queries_column")
437
+
438
+ # =========================================================================
439
+ # CROSS-STACK LINKING PASS 2: Backend/ORM <-> API Route & Payload Fields
440
+ # =========================================================================
441
+ routes_by_norm_path: Dict[str, List[Dict[str, Any]]] = {}
442
+ for route in api_routes:
443
+ norm_p = route["normalized_path"]
444
+ routes_by_norm_path.setdefault(norm_p, []).append(route)
445
+ route_id = route["id"]
446
+ handler_id = route.get("handler_id")
447
+
448
+ # Connect DB/ORM fields referenced or returned by handler directly to API payload fields
449
+ for rf in route.get("response_fields", []):
450
+ rf_low = rf.lower()
451
+ field_node = f"api_field:{norm_p}.{rf}"
452
+ if handler_id:
453
+ _add_edge(handler_id, field_node, "populates_api_field")
454
+ for tbl_name, tbl_info in db_tables.items():
455
+ if rf_low in tbl_info["columns"]:
456
+ _add_edge(f"db:{tbl_name}.{rf_low}", route_id, "exposes_db_column")
457
+ _add_edge(f"db:{tbl_name}.{rf_low}", field_node, "maps_to_api_field")
458
+ for cls_name, minfo in orm_models.items():
459
+ for orm_f in minfo["fields"]:
460
+ if orm_f.lower() == rf_low:
461
+ _add_edge(f"orm:{cls_name}.{orm_f}", route_id, "exposes_orm_field")
462
+ _add_edge(f"orm:{cls_name}.{orm_f}", field_node, "maps_orm_to_api_field")
463
+
464
+ # =========================================================================
465
+ # CROSS-STACK LINKING PASS 3: API Route/Payload <-> Frontend TS/React Consumers
466
+ # =========================================================================
467
+ fe_symbol_to_id: Dict[str, str] = {}
468
+ for rel_p, fe_info in fe_modules.items():
469
+ for sym_name in fe_info["symbols"]:
470
+ fe_symbol_to_id[sym_name] = f"fe:{rel_p}:{sym_name}"
471
+
472
+ for rel_p, fe_info in fe_modules.items():
473
+ fe_mod_id = f"fe:{rel_p}"
474
+ fe_syms = [f"fe:{rel_p}:{s}" for s in fe_info["symbols"]]
475
+ target_fe_nodes = fe_syms if fe_syms else [fe_mod_id]
476
+
477
+ # Link frontend cross-file imports
478
+ for imp in fe_info["imports"]:
479
+ sym = imp.get("symbol", "")
480
+ if sym in fe_symbol_to_id and fe_symbol_to_id[sym] not in target_fe_nodes:
481
+ for t_node in target_fe_nodes:
482
+ _add_edge(fe_symbol_to_id[sym], t_node, "imported_by_component")
483
+
484
+ # Link API calls (fetch, axios, useSWR, useQuery) to API routes & payload fields
485
+ for call in fe_info["api_calls"]:
486
+ c_path = call["normalized_path"]
487
+ c_method = call["method"]
488
+ matched_routes = routes_by_norm_path.get(c_path, [])
489
+ if not matched_routes:
490
+ # Try prefix / parameter matching (e.g. /api/v1/users/123 vs /api/v1/users/:param)
491
+ for cand_path, r_list in routes_by_norm_path.items():
492
+ if _paths_match(cand_path, c_path):
493
+ matched_routes.extend(r_list)
494
+
495
+ for route in matched_routes:
496
+ route_id = route["id"]
497
+ _add_edge(route_id, fe_mod_id, "consumed_by_frontend_module")
498
+ for t_node in target_fe_nodes:
499
+ _add_edge(route_id, t_node, "consumed_by_component")
500
+
501
+ # Connect accessed properties (e.g. data.balance) to api_field nodes
502
+ for prop in call.get("accessed_fields", []):
503
+ field_node = f"api_field:{route['normalized_path']}.{prop}"
504
+ if field_node in nodes_by_id:
505
+ _add_edge(field_node, fe_mod_id, "field_read_by_frontend")
506
+ for t_node in target_fe_nodes:
507
+ _add_edge(field_node, t_node, "field_read_by_component")
508
+ prop_node_id = f"{t_node}.{prop}"
509
+ _add_node({
510
+ "id": prop_node_id,
511
+ "type": "frontend_property_access",
512
+ "name": f"{os.path.basename(rel_p)} ({prop})",
513
+ "property": prop,
514
+ "file": rel_p,
515
+ "layer": "frontend",
516
+ })
517
+ _add_edge(field_node, prop_node_id, "renders_field")
518
+ _add_edge(t_node, prop_node_id, "accesses_property")
519
+
520
+ # =========================================================================
521
+ # CROSS-STACK LINKING PASS 3B: API Route/Payload <-> Flutter Dart Mobile/POS Consumers
522
+ # =========================================================================
523
+ for rel_p, d_info in flutter_modules.items():
524
+ dart_mod_id = f"dart:{rel_p}"
525
+ dart_classes = [f"dart:{rel_p}:{c['name']}" for c in d_info.get("classes", [])]
526
+ target_dart_nodes = dart_classes if dart_classes else [dart_mod_id]
527
+
528
+ for call in d_info.get("api_calls", []):
529
+ c_path = call["path"]
530
+ matched_routes = routes_by_norm_path.get(c_path, [])
531
+ if not matched_routes:
532
+ for cand_path, r_list in routes_by_norm_path.items():
533
+ if _paths_match(cand_path, c_path) or c_path.rstrip("/") in cand_path or cand_path.rstrip("/") in c_path:
534
+ matched_routes.extend(r_list)
535
+
536
+ for route in matched_routes:
537
+ route_id = route["id"]
538
+ _add_edge(route_id, dart_mod_id, "consumed_by_flutter_app")
539
+ for t_node in target_dart_nodes:
540
+ _add_edge(route_id, t_node, "consumed_by_flutter_model")
541
+
542
+ for prop in d_info.get("accessed_properties", []):
543
+ field_node = f"api_field:{route['normalized_path']}.{prop}"
544
+ if field_node in nodes_by_id:
545
+ _add_edge(field_node, dart_mod_id, "field_read_by_flutter")
546
+ for t_node in target_dart_nodes:
547
+ _add_edge(field_node, t_node, "field_read_by_flutter_model")
548
+
549
+ for prop in d_info.get("accessed_properties", []):
550
+ for cls_name, minfo in orm_models.items():
551
+ if prop in minfo.get("fields", {}):
552
+ for t_node in target_dart_nodes:
553
+ _add_edge(f"orm:{cls_name}.{prop}", t_node, "orm_field_to_flutter")
554
+ _add_edge(f"orm:{cls_name}", t_node, "orm_model_to_flutter")
555
+
556
+ # =========================================================================
557
+ # CROSS-STACK LINKING PASS 3C: API Route/Payload <-> Swift iOS Client Consumers
558
+ # =========================================================================
559
+ for rel_p, s_info in swift_modules.items():
560
+ sw_mod_id = f"swift:{rel_p}"
561
+ sw_classes = [f"swift:{rel_p}:{c['name']}" for c in s_info.get("classes", [])]
562
+ target_sw_nodes = sw_classes if sw_classes else [sw_mod_id]
563
+
564
+ for call in s_info.get("api_calls", []):
565
+ c_path = call["path"]
566
+ matched_routes = routes_by_norm_path.get(c_path, [])
567
+ if not matched_routes:
568
+ for cand_path, r_list in routes_by_norm_path.items():
569
+ if _paths_match(cand_path, c_path) or c_path.rstrip("/") in cand_path or cand_path.rstrip("/") in c_path:
570
+ matched_routes.extend(r_list)
571
+
572
+ for route in matched_routes:
573
+ route_id = route["id"]
574
+ _add_edge(route_id, sw_mod_id, "consumed_by_ios_app")
575
+ for t_node in target_sw_nodes:
576
+ _add_edge(route_id, t_node, "consumed_by_ios_model")
577
+
578
+ for prop in s_info.get("accessed_properties", []):
579
+ field_node = f"api_field:{route['normalized_path']}.{prop}"
580
+ if field_node in nodes_by_id:
581
+ _add_edge(field_node, sw_mod_id, "field_read_by_ios")
582
+ for t_node in target_sw_nodes:
583
+ _add_edge(field_node, t_node, "field_read_by_ios_model")
584
+
585
+ for prop in s_info.get("accessed_properties", []):
586
+ for cls_name, minfo in orm_models.items():
587
+ if prop in minfo.get("fields", {}):
588
+ for t_node in target_sw_nodes:
589
+ _add_edge(f"orm:{cls_name}.{prop}", t_node, "orm_field_to_ios")
590
+ _add_edge(f"orm:{cls_name}", t_node, "orm_model_to_ios")
591
+
592
+ # =========================================================================
593
+ # CROSS-STACK LINKING PASS 3D: API Route/Payload <-> Kotlin Android Client Consumers
594
+ # =========================================================================
595
+ for rel_p, k_info in kotlin_modules.items():
596
+ kt_mod_id = f"kotlin:{rel_p}"
597
+ kt_classes = [f"kotlin:{rel_p}:{c['name']}" for c in k_info.get("classes", [])]
598
+ target_kt_nodes = kt_classes if kt_classes else [kt_mod_id]
599
+
600
+ for call in k_info.get("api_calls", []):
601
+ c_path = call["path"]
602
+ matched_routes = routes_by_norm_path.get(c_path, [])
603
+ if not matched_routes:
604
+ for cand_path, r_list in routes_by_norm_path.items():
605
+ if _paths_match(cand_path, c_path) or c_path.rstrip("/") in cand_path or cand_path.rstrip("/") in c_path:
606
+ matched_routes.extend(r_list)
607
+
608
+ for route in matched_routes:
609
+ route_id = route["id"]
610
+ _add_edge(route_id, kt_mod_id, "consumed_by_android_app")
611
+ for t_node in target_kt_nodes:
612
+ _add_edge(route_id, t_node, "consumed_by_android_model")
613
+
614
+ for prop in k_info.get("accessed_properties", []):
615
+ field_node = f"api_field:{route['normalized_path']}.{prop}"
616
+ if field_node in nodes_by_id:
617
+ _add_edge(field_node, kt_mod_id, "field_read_by_android")
618
+ for t_node in target_kt_nodes:
619
+ _add_edge(field_node, t_node, "field_read_by_android_model")
620
+
621
+ for prop in k_info.get("accessed_properties", []):
622
+ for cls_name, minfo in orm_models.items():
623
+ if prop in minfo.get("fields", {}):
624
+ for t_node in target_kt_nodes:
625
+ _add_edge(f"orm:{cls_name}.{prop}", t_node, "orm_field_to_android")
626
+ _add_edge(f"orm:{cls_name}", t_node, "orm_model_to_android")
627
+
628
+ # =========================================================================
629
+ # CROSS-STACK LINKING PASS 3E: API Route/Payload <-> Vue.js / Nuxt 3 Consumers
630
+ # =========================================================================
631
+ for rel_p, v_parse_res in vue_modules.items():
632
+ vue_mod_id = f"vue:{rel_p}"
633
+ comp_nodes = [f"vue:{rel_p}:{c.name}" for c in v_parse_res.components]
634
+ target_vue_nodes = comp_nodes if comp_nodes else [vue_mod_id]
635
+
636
+ for call in v_parse_res.api_calls:
637
+ c_path = _normalize_route_path(call.endpoint)
638
+ matched_routes = routes_by_norm_path.get(c_path, [])
639
+ if not matched_routes:
640
+ for cand_path, r_list in routes_by_norm_path.items():
641
+ if _paths_match(cand_path, c_path) or c_path.rstrip("/") in cand_path or cand_path.rstrip("/") in c_path:
642
+ matched_routes.extend(r_list)
643
+
644
+ for route in matched_routes:
645
+ route_id = route["id"]
646
+ _add_edge(route_id, vue_mod_id, "consumed_by_vue_storefront")
647
+ for t_node in target_vue_nodes:
648
+ _add_edge(route_id, t_node, "consumed_by_vue_component")
649
+
650
+ for prop in v_parse_res.all_accessed_properties:
651
+ field_node = f"api_field:{route['normalized_path']}.{prop}"
652
+ if field_node in nodes_by_id:
653
+ _add_edge(field_node, vue_mod_id, "field_read_by_vue")
654
+ for t_node in target_vue_nodes:
655
+ _add_edge(field_node, t_node, "field_read_by_vue_component")
656
+
657
+ for prop in v_parse_res.all_accessed_properties:
658
+ for cls_name, minfo in orm_models.items():
659
+ if prop in minfo.get("fields", {}):
660
+ for t_node in target_vue_nodes:
661
+ _add_edge(f"orm:{cls_name}.{prop}", t_node, "orm_field_to_vue")
662
+ _add_edge(f"orm:{cls_name}", t_node, "orm_model_to_vue")
663
+
664
+ # =========================================================================
665
+ # CROSS-STACK LINKING PASS 3F: Go Microservice Structs & Outbound Calls
666
+ # =========================================================================
667
+ for rel_p, g_parse_res in go_modules.items():
668
+ go_mod_id = f"go:{rel_p}"
669
+ for st in g_parse_res.structs:
670
+ sid = f"go:{rel_p}:{st.name}"
671
+ # Match Go struct to DB table
672
+ norm_st = st.name.lower()
673
+ matching_tbls = [t for t in db_tables if t.lower() in (norm_st, _pluralize(norm_st), _singularize(norm_st))]
674
+ for tbl in matching_tbls:
675
+ _add_edge(f"db:{tbl}", sid, "table_backed_by_go_struct")
676
+ for gf in st.fields:
677
+ fname = gf.json_name or gf.name.lower()
678
+ if fname in db_tables[tbl]["columns"]:
679
+ _add_edge(f"db:{tbl}.{fname}", f"go:{rel_p}:{st.name}.{gf.name}", "column_to_go_field")
680
+
681
+ for call in g_parse_res.api_calls:
682
+ c_path = _normalize_route_path(call.endpoint)
683
+ matched_routes = routes_by_norm_path.get(c_path, [])
684
+ if not matched_routes:
685
+ for cand_path, r_list in routes_by_norm_path.items():
686
+ if _paths_match(cand_path, c_path) or c_path.rstrip("/") in cand_path or cand_path.rstrip("/") in c_path:
687
+ matched_routes.extend(r_list)
688
+ for route in matched_routes:
689
+ _add_edge(go_mod_id, route["id"], "go_calls_api_route")
690
+
691
+ # =========================================================================
692
+ # CROSS-STACK LINKING PASS 4: Backend/API/Frontend <-> Covering Test Suites
693
+ # =========================================================================
694
+ for test_rel, t_info in test_suites.items():
695
+ suite_id = f"test:{test_rel}"
696
+ for sym in t_info.get("imported_symbols", []):
697
+ for fn_id in func_to_py_ids.get(sym, []):
698
+ _add_edge(fn_id, suite_id, "covered_by_test_suite")
699
+ if sym in class_to_py_id:
700
+ _add_edge(class_to_py_id[sym], suite_id, "covered_by_test_suite")
701
+ if sym in fe_symbol_to_id:
702
+ _add_edge(fe_symbol_to_id[sym], suite_id, "covered_by_test_suite")
703
+
704
+ for tc in t_info.get("test_cases", []):
705
+ tc_id = tc["id"]
706
+ for called in tc.get("called_symbols", []):
707
+ for fn_id in func_to_py_ids.get(called, []):
708
+ _add_edge(fn_id, tc_id, "verified_by_test")
709
+ if called in class_to_py_id:
710
+ _add_edge(class_to_py_id[called], tc_id, "verified_by_test")
711
+ if called in fe_symbol_to_id:
712
+ _add_edge(fe_symbol_to_id[called], tc_id, "verified_by_test")
713
+
714
+ for ep in tc.get("tested_endpoints", []):
715
+ parts = ep.split(" ", 1)
716
+ if len(parts) == 2:
717
+ m, raw_p = parts[0], parts[1]
718
+ norm_p = _normalize_route_path(raw_p)
719
+ for r in routes_by_norm_path.get(norm_p, []):
720
+ _add_edge(r["id"], tc_id, "endpoint_tested_by")
721
+ _add_edge(r["id"], suite_id, "endpoint_covered_by_suite")
722
+
723
+ return {
724
+ "repo_root": root,
725
+ "nodes": list(nodes_by_id.values()),
726
+ "nodes_by_id": nodes_by_id,
727
+ "edges": edges,
728
+ "db_tables": db_tables,
729
+ "orm_models": orm_models,
730
+ "py_modules": py_modules,
731
+ "api_routes": api_routes,
732
+ "fe_modules": fe_modules,
733
+ "test_suites": test_suites,
734
+ "module_import_edges": module_import_edges,
735
+ }
736
+
737
+
738
+ def py_modules_file_for_class(py_modules: Dict[str, Dict[str, Any]], cls_name: str) -> Optional[str]:
739
+ for rel_p, info in py_modules.items():
740
+ if cls_name in info.get("classes", {}):
741
+ return rel_p
742
+ return None
743
+
744
+
745
+ def _paths_match(route_norm: str, call_norm: str) -> bool:
746
+ if route_norm == call_norm:
747
+ return True
748
+ r_parts = [p for p in route_norm.split("/") if p]
749
+ c_parts = [p for p in call_norm.split("/") if p]
750
+ if len(r_parts) != len(c_parts):
751
+ return False
752
+ for rp, cp in zip(r_parts, c_parts):
753
+ if rp == ":param" or cp == ":param" or rp.startswith(":") or cp.startswith(":"):
754
+ continue
755
+ if rp != cp:
756
+ return False
757
+ return True
758
+
759
+
760
+ def resolve_entity_id(topology: Dict[str, Any], query: str) -> List[str]:
761
+ """
762
+ Resolves a user or AI agent symbol/table/route/component query string
763
+ into matching canonical node IDs inside the Full-Stack Contract Topology Engine.
764
+ """
765
+ if not query:
766
+ return []
767
+ q = query.strip()
768
+ nodes_by_id: Dict[str, Dict[str, Any]] = topology.get("nodes_by_id", {})
769
+ if q in nodes_by_id:
770
+ return [q]
771
+
772
+ q_low = q.lower()
773
+ exact_matches: List[str] = []
774
+ suffix_matches: List[str] = []
775
+ substring_matches: List[str] = []
776
+
777
+ # Direct prefix candidates
778
+ for prefix in ("db:", "orm:", "py:", "api:", "api_field:", "fe:", "test:"):
779
+ cand = f"{prefix}{q}"
780
+ if cand in nodes_by_id:
781
+ exact_matches.append(cand)
782
+ cand_low = f"{prefix}{q_low}"
783
+ if cand_low in nodes_by_id and cand_low not in exact_matches:
784
+ exact_matches.append(cand_low)
785
+
786
+ # Check API route path normalization if query looks like a URL path
787
+ if q.startswith("/") or q_low.startswith(("get ", "post ", "put ", "delete ", "patch ")):
788
+ if " " in q:
789
+ m_part, p_part = q.split(" ", 1)
790
+ norm_cand = f"api:{m_part.upper()} {_normalize_route_path(p_part)}"
791
+ if norm_cand in nodes_by_id:
792
+ exact_matches.append(norm_cand)
793
+ else:
794
+ norm_p = _normalize_route_path(q)
795
+ for m in ("GET", "POST", "PUT", "DELETE", "PATCH"):
796
+ cand = f"api:{m} {norm_p}"
797
+ if cand in nodes_by_id:
798
+ exact_matches.append(cand)
799
+
800
+ if exact_matches:
801
+ return exact_matches
802
+
803
+ for nid, nmeta in nodes_by_id.items():
804
+ nid_low = nid.lower()
805
+ name_low = str(nmeta.get("name", "")).lower()
806
+ file_low = str(nmeta.get("file", "")).lower()
807
+ if name_low == q_low or file_low == q_low or os.path.basename(file_low) == q_low:
808
+ suffix_matches.append(nid)
809
+ elif nid_low.endswith(f":{q_low}") or nid_low.endswith(f".{q_low}") or nid_low.endswith(f"/{q_low}"):
810
+ suffix_matches.append(nid)
811
+ elif q_low in nid_low or q_low in name_low:
812
+ substring_matches.append(nid)
813
+
814
+ return suffix_matches if suffix_matches else substring_matches
815
+