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.
- blasttrace/__init__.py +7 -0
- blasttrace/classifier.py +415 -0
- blasttrace/install_agent.py +220 -0
- blasttrace/license.py +331 -0
- blasttrace/parsers/__init__.py +4 -0
- blasttrace/parsers/api_route_parser.py +318 -0
- blasttrace/parsers/contract_linker.py +815 -0
- blasttrace/parsers/dart_flutter_parser.py +110 -0
- blasttrace/parsers/go_parser.py +196 -0
- blasttrace/parsers/kotlin_android_parser.py +122 -0
- blasttrace/parsers/python_ast_parser.py +340 -0
- blasttrace/parsers/sql_schema_parser.py +376 -0
- blasttrace/parsers/swift_ios_parser.py +127 -0
- blasttrace/parsers/test_mapper.py +182 -0
- blasttrace/parsers/ts_react_parser.py +299 -0
- blasttrace/parsers/vue_nuxt_parser.py +175 -0
- blasttrace/remediation.py +220 -0
- blasttrace/server.py +935 -0
- blasttrace/visualizer.py +165 -0
- blasttrace-1.2.0.dist-info/METADATA +125 -0
- blasttrace-1.2.0.dist-info/RECORD +24 -0
- blasttrace-1.2.0.dist-info/WHEEL +6 -0
- blasttrace-1.2.0.dist-info/entry_points.txt +3 -0
- blasttrace-1.2.0.dist-info/top_level.txt +1 -0
|
@@ -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
|
+
|