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.
- blasttrace/__init__.py +7 -0
- blasttrace/classifier.py +415 -0
- blasttrace/license.py +293 -0
- blasttrace/parsers/__init__.py +4 -0
- blasttrace/parsers/api_route_parser.py +318 -0
- blasttrace/parsers/contract_linker.py +490 -0
- blasttrace/parsers/python_ast_parser.py +340 -0
- blasttrace/parsers/sql_schema_parser.py +376 -0
- blasttrace/parsers/test_mapper.py +182 -0
- blasttrace/parsers/ts_react_parser.py +299 -0
- blasttrace/remediation.py +220 -0
- blasttrace/server.py +824 -0
- blasttrace/visualizer.py +165 -0
- blasttrace-1.0.2.dist-info/METADATA +125 -0
- blasttrace-1.0.2.dist-info/RECORD +18 -0
- blasttrace-1.0.2.dist-info/WHEEL +5 -0
- blasttrace-1.0.2.dist-info/entry_points.txt +3 -0
- blasttrace-1.0.2.dist-info/top_level.txt +1 -0
|
@@ -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
|
+
|