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,376 @@
|
|
|
1
|
+
"""
|
|
2
|
+
BlastTrace Database & Schema Parser
|
|
3
|
+
Parses SQL migrations/schemas (.sql), Prisma (schema.prisma), Drizzle ORM, and SQLAlchemy/Django ORM models
|
|
4
|
+
for the Full-Stack Contract Topology Engine.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
from typing import Dict, Any, List
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
_SQL_KEYWORDS = {
|
|
13
|
+
"CONSTRAINT", "PRIMARY", "FOREIGN", "UNIQUE", "CHECK", "INDEX",
|
|
14
|
+
"KEY", "REFERENCES", "CREATE", "ALTER", "DROP", "TABLE", "IF", "NOT", "EXISTS"
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _singularize(name: str) -> str:
|
|
19
|
+
lower = name.lower().strip()
|
|
20
|
+
if lower.endswith("ies") and len(lower) > 3:
|
|
21
|
+
return lower[:-3] + "y"
|
|
22
|
+
if lower.endswith("ses") and len(lower) > 3:
|
|
23
|
+
return lower[:-2]
|
|
24
|
+
if lower.endswith("s") and not lower.endswith("ss") and len(lower) > 1:
|
|
25
|
+
return lower[:-1]
|
|
26
|
+
return lower
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _pluralize(name: str) -> str:
|
|
30
|
+
lower = name.lower().strip()
|
|
31
|
+
if lower.endswith("s"):
|
|
32
|
+
return lower
|
|
33
|
+
if lower.endswith("y") and len(lower) > 1 and lower[-2] not in "aeiou":
|
|
34
|
+
return lower[:-1] + "ies"
|
|
35
|
+
return lower + "s"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def parse_sql_content(content: str, rel_file: str) -> Dict[str, Any]:
|
|
39
|
+
tables: Dict[str, Dict[str, Any]] = {}
|
|
40
|
+
nodes: List[Dict[str, Any]] = []
|
|
41
|
+
edges: List[Dict[str, str]] = []
|
|
42
|
+
|
|
43
|
+
# Match CREATE TABLE [IF NOT EXISTS] <table_name> ( ... );
|
|
44
|
+
create_pattern = re.compile(
|
|
45
|
+
r"CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?[`\"']?([a-zA-Z0-9_.]+)[`\"']?\s*\((.*?)\)\s*;",
|
|
46
|
+
re.IGNORECASE | re.DOTALL,
|
|
47
|
+
)
|
|
48
|
+
for match in create_pattern.finditer(content):
|
|
49
|
+
raw_table = match.group(1).split(".")[-1].strip().lower()
|
|
50
|
+
body = match.group(2)
|
|
51
|
+
table_node_id = f"db:{raw_table}"
|
|
52
|
+
tables.setdefault(raw_table, {"name": raw_table, "file": rel_file, "columns": {}})
|
|
53
|
+
nodes.append({
|
|
54
|
+
"id": table_node_id,
|
|
55
|
+
"type": "db_table",
|
|
56
|
+
"name": raw_table,
|
|
57
|
+
"file": rel_file,
|
|
58
|
+
"layer": "database",
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
for raw_line in body.splitlines():
|
|
62
|
+
line = raw_line.strip().rstrip(",")
|
|
63
|
+
if not line or line.startswith("--") or line.startswith("/*"):
|
|
64
|
+
continue
|
|
65
|
+
tokens = line.split()
|
|
66
|
+
if not tokens:
|
|
67
|
+
continue
|
|
68
|
+
first_word = tokens[0].strip("`\"'").upper()
|
|
69
|
+
if first_word in _SQL_KEYWORDS or first_word.startswith("("):
|
|
70
|
+
# Check for table-level FOREIGN KEY (col) REFERENCES other_table(other_col)
|
|
71
|
+
fk_match = re.search(
|
|
72
|
+
r"FOREIGN\s+KEY\s*\([`\"']?(\w+)[`\"']?\)\s*REFERENCES\s+[`\"']?(\w+)[`\"']?\s*\([`\"']?(\w+)[`\"']?\)",
|
|
73
|
+
line,
|
|
74
|
+
re.IGNORECASE,
|
|
75
|
+
)
|
|
76
|
+
if fk_match:
|
|
77
|
+
src_col, ref_table, ref_col = (
|
|
78
|
+
fk_match.group(1).lower(),
|
|
79
|
+
fk_match.group(2).lower(),
|
|
80
|
+
fk_match.group(3).lower(),
|
|
81
|
+
)
|
|
82
|
+
edges.append({
|
|
83
|
+
"source": f"db:{ref_table}.{ref_col}",
|
|
84
|
+
"target": f"db:{raw_table}.{src_col}",
|
|
85
|
+
"relation": "foreign_key",
|
|
86
|
+
})
|
|
87
|
+
continue
|
|
88
|
+
|
|
89
|
+
col_name = tokens[0].strip("`\"'").lower()
|
|
90
|
+
col_type = tokens[1].upper() if len(tokens) > 1 else "UNKNOWN"
|
|
91
|
+
upper_line = line.upper()
|
|
92
|
+
nullable = "NOT NULL" not in upper_line and "PRIMARY KEY" not in upper_line
|
|
93
|
+
has_default = "DEFAULT" in upper_line
|
|
94
|
+
|
|
95
|
+
col_node_id = f"db:{raw_table}.{col_name}"
|
|
96
|
+
col_meta = {
|
|
97
|
+
"name": col_name,
|
|
98
|
+
"table": raw_table,
|
|
99
|
+
"type": col_type,
|
|
100
|
+
"nullable": nullable,
|
|
101
|
+
"has_default": has_default,
|
|
102
|
+
"raw_definition": line,
|
|
103
|
+
"file": rel_file,
|
|
104
|
+
}
|
|
105
|
+
tables[raw_table]["columns"][col_name] = col_meta
|
|
106
|
+
nodes.append({
|
|
107
|
+
"id": col_node_id,
|
|
108
|
+
"type": "db_column",
|
|
109
|
+
"name": f"{raw_table}.{col_name}",
|
|
110
|
+
"table": raw_table,
|
|
111
|
+
"column": col_name,
|
|
112
|
+
"data_type": col_type,
|
|
113
|
+
"nullable": nullable,
|
|
114
|
+
"has_default": has_default,
|
|
115
|
+
"file": rel_file,
|
|
116
|
+
"layer": "database",
|
|
117
|
+
})
|
|
118
|
+
edges.append({
|
|
119
|
+
"source": table_node_id,
|
|
120
|
+
"target": col_node_id,
|
|
121
|
+
"relation": "has_column",
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
inline_fk = re.search(
|
|
125
|
+
r"REFERENCES\s+[`\"']?(\w+)[`\"']?\s*(?:\([`\"']?(\w+)[`\"']?\))?",
|
|
126
|
+
line,
|
|
127
|
+
re.IGNORECASE,
|
|
128
|
+
)
|
|
129
|
+
if inline_fk:
|
|
130
|
+
ref_table = inline_fk.group(1).lower()
|
|
131
|
+
ref_col = (inline_fk.group(2) or "id").lower()
|
|
132
|
+
edges.append({
|
|
133
|
+
"source": f"db:{ref_table}.{ref_col}",
|
|
134
|
+
"target": col_node_id,
|
|
135
|
+
"relation": "foreign_key",
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
# Also parse ALTER TABLE statements in migration files
|
|
139
|
+
alter_pattern = re.compile(
|
|
140
|
+
r"ALTER\s+TABLE\s+[`\"']?([a-zA-Z0-9_.]+)[`\"']?\s+(.*?);",
|
|
141
|
+
re.IGNORECASE | re.DOTALL,
|
|
142
|
+
)
|
|
143
|
+
for match in alter_pattern.finditer(content):
|
|
144
|
+
raw_table = match.group(1).split(".")[-1].strip().lower()
|
|
145
|
+
action = match.group(2).strip()
|
|
146
|
+
table_node_id = f"db:{raw_table}"
|
|
147
|
+
tables.setdefault(raw_table, {"name": raw_table, "file": rel_file, "columns": {}})
|
|
148
|
+
|
|
149
|
+
col_match = re.search(
|
|
150
|
+
r"(?:ADD|DROP|ALTER|MODIFY|RENAME)\s+(?:COLUMN\s+)?[`\"']?([a-zA-Z0-9_]+)[`\"']?(?:\s+([A-Z0-9_()]+))?",
|
|
151
|
+
action,
|
|
152
|
+
re.IGNORECASE,
|
|
153
|
+
)
|
|
154
|
+
if col_match:
|
|
155
|
+
col_name = col_match.group(1).lower()
|
|
156
|
+
col_type = (col_match.group(2) or "MODIFIED").upper()
|
|
157
|
+
col_node_id = f"db:{raw_table}.{col_name}"
|
|
158
|
+
tables[raw_table]["columns"].setdefault(col_name, {
|
|
159
|
+
"name": col_name,
|
|
160
|
+
"table": raw_table,
|
|
161
|
+
"type": col_type,
|
|
162
|
+
"nullable": "NOT NULL" not in action.upper(),
|
|
163
|
+
"has_default": "DEFAULT" in action.upper(),
|
|
164
|
+
"raw_definition": action,
|
|
165
|
+
"file": rel_file,
|
|
166
|
+
})
|
|
167
|
+
nodes.append({
|
|
168
|
+
"id": col_node_id,
|
|
169
|
+
"type": "db_column",
|
|
170
|
+
"name": f"{raw_table}.{col_name}",
|
|
171
|
+
"table": raw_table,
|
|
172
|
+
"column": col_name,
|
|
173
|
+
"data_type": col_type,
|
|
174
|
+
"file": rel_file,
|
|
175
|
+
"layer": "database",
|
|
176
|
+
})
|
|
177
|
+
edges.append({
|
|
178
|
+
"source": table_node_id,
|
|
179
|
+
"target": col_node_id,
|
|
180
|
+
"relation": "has_column",
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
return {"tables": tables, "nodes": nodes, "edges": edges}
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def parse_prisma_content(content: str, rel_file: str) -> Dict[str, Any]:
|
|
187
|
+
tables: Dict[str, Dict[str, Any]] = {}
|
|
188
|
+
nodes: List[Dict[str, Any]] = []
|
|
189
|
+
edges: List[Dict[str, str]] = []
|
|
190
|
+
|
|
191
|
+
model_pattern = re.compile(r"model\s+([A-Za-z0-9_]+)\s*\{(.*?)\}", re.DOTALL)
|
|
192
|
+
for match in model_pattern.finditer(content):
|
|
193
|
+
model_name = match.group(1).strip()
|
|
194
|
+
body = match.group(2)
|
|
195
|
+
map_match = re.search(r'@@map\(["\']([^"\']+)["\']\)', body)
|
|
196
|
+
table_name = map_match.group(1).lower() if map_match else _pluralize(model_name)
|
|
197
|
+
table_node_id = f"db:{table_name}"
|
|
198
|
+
tables.setdefault(table_name, {"name": table_name, "model": model_name, "file": rel_file, "columns": {}})
|
|
199
|
+
nodes.append({
|
|
200
|
+
"id": table_node_id,
|
|
201
|
+
"type": "db_table",
|
|
202
|
+
"name": table_name,
|
|
203
|
+
"model": model_name,
|
|
204
|
+
"file": rel_file,
|
|
205
|
+
"layer": "database",
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
for raw_line in body.splitlines():
|
|
209
|
+
line = raw_line.strip()
|
|
210
|
+
if not line or line.startswith("//") or line.startswith("@@"):
|
|
211
|
+
continue
|
|
212
|
+
parts = line.split()
|
|
213
|
+
if len(parts) < 2:
|
|
214
|
+
continue
|
|
215
|
+
col_name = parts[0].strip()
|
|
216
|
+
col_type = parts[1].strip()
|
|
217
|
+
nullable = col_type.endswith("?")
|
|
218
|
+
has_default = "@default" in line
|
|
219
|
+
col_node_id = f"db:{table_name}.{col_name.lower()}"
|
|
220
|
+
tables[table_name]["columns"][col_name.lower()] = {
|
|
221
|
+
"name": col_name.lower(),
|
|
222
|
+
"table": table_name,
|
|
223
|
+
"type": col_type.rstrip("?"),
|
|
224
|
+
"nullable": nullable,
|
|
225
|
+
"has_default": has_default,
|
|
226
|
+
"raw_definition": line,
|
|
227
|
+
"file": rel_file,
|
|
228
|
+
}
|
|
229
|
+
nodes.append({
|
|
230
|
+
"id": col_node_id,
|
|
231
|
+
"type": "db_column",
|
|
232
|
+
"name": f"{table_name}.{col_name.lower()}",
|
|
233
|
+
"table": table_name,
|
|
234
|
+
"column": col_name.lower(),
|
|
235
|
+
"data_type": col_type.rstrip("?"),
|
|
236
|
+
"nullable": nullable,
|
|
237
|
+
"has_default": has_default,
|
|
238
|
+
"file": rel_file,
|
|
239
|
+
"layer": "database",
|
|
240
|
+
})
|
|
241
|
+
edges.append({
|
|
242
|
+
"source": table_node_id,
|
|
243
|
+
"target": col_node_id,
|
|
244
|
+
"relation": "has_column",
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
return {"tables": tables, "nodes": nodes, "edges": edges}
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def parse_orm_python_content(content: str, rel_file: str) -> Dict[str, Any]:
|
|
251
|
+
"""Extracts SQLAlchemy and Django ORM model definitions and maps them to database tables/columns."""
|
|
252
|
+
orm_models: Dict[str, Dict[str, Any]] = {}
|
|
253
|
+
nodes: List[Dict[str, Any]] = []
|
|
254
|
+
edges: List[Dict[str, str]] = []
|
|
255
|
+
|
|
256
|
+
class_pattern = re.compile(
|
|
257
|
+
r"class\s+([A-Za-z0-9_]+)\s*\(([^)]*)\)\s*:(.*?)(?=\nclass\s+[A-Za-z0-9_]+|\ndef\s+[A-Za-z0-9_]+|\n@[A-Za-z0-9_.]|\Z)",
|
|
258
|
+
re.DOTALL,
|
|
259
|
+
)
|
|
260
|
+
for match in class_pattern.finditer(content):
|
|
261
|
+
cls_name = match.group(1).strip()
|
|
262
|
+
bases = match.group(2).strip()
|
|
263
|
+
body = match.group(3)
|
|
264
|
+
|
|
265
|
+
is_orm = (
|
|
266
|
+
"Base" in bases
|
|
267
|
+
or "Model" in bases
|
|
268
|
+
or "__tablename__" in body
|
|
269
|
+
or "Column(" in body
|
|
270
|
+
or "mapped_column(" in body
|
|
271
|
+
or "models." in body
|
|
272
|
+
)
|
|
273
|
+
if not is_orm:
|
|
274
|
+
continue
|
|
275
|
+
|
|
276
|
+
tbl_match = re.search(r'__tablename__\s*=\s*["\']([^"\']+)["\']', body)
|
|
277
|
+
table_name = tbl_match.group(1).lower() if tbl_match else _pluralize(cls_name)
|
|
278
|
+
orm_node_id = f"orm:{cls_name}"
|
|
279
|
+
orm_models[cls_name] = {
|
|
280
|
+
"class_name": cls_name,
|
|
281
|
+
"table": table_name,
|
|
282
|
+
"file": rel_file,
|
|
283
|
+
"fields": {},
|
|
284
|
+
}
|
|
285
|
+
nodes.append({
|
|
286
|
+
"id": orm_node_id,
|
|
287
|
+
"type": "orm_model",
|
|
288
|
+
"name": cls_name,
|
|
289
|
+
"table": table_name,
|
|
290
|
+
"file": rel_file,
|
|
291
|
+
"layer": "backend",
|
|
292
|
+
})
|
|
293
|
+
edges.append({
|
|
294
|
+
"source": f"db:{table_name}",
|
|
295
|
+
"target": orm_node_id,
|
|
296
|
+
"relation": "maps_to_orm",
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
field_pattern = re.compile(
|
|
300
|
+
r"^\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*(?::\s*[^=]+)?\s*=\s*(?:db\.)?(?:Column|mapped_column|models\.[A-Za-z0-9_]+|Field)\s*\(([^\n]*)\)",
|
|
301
|
+
re.MULTILINE,
|
|
302
|
+
)
|
|
303
|
+
for f_match in field_pattern.finditer(body):
|
|
304
|
+
field_name = f_match.group(1).strip()
|
|
305
|
+
field_args = f_match.group(2).strip()
|
|
306
|
+
if field_name.startswith("__"):
|
|
307
|
+
continue
|
|
308
|
+
field_node_id = f"orm:{cls_name}.{field_name}"
|
|
309
|
+
orm_models[cls_name]["fields"][field_name] = {
|
|
310
|
+
"name": field_name,
|
|
311
|
+
"args": field_args,
|
|
312
|
+
"table": table_name,
|
|
313
|
+
"file": rel_file,
|
|
314
|
+
}
|
|
315
|
+
nodes.append({
|
|
316
|
+
"id": field_node_id,
|
|
317
|
+
"type": "orm_field",
|
|
318
|
+
"name": f"{cls_name}.{field_name}",
|
|
319
|
+
"model": cls_name,
|
|
320
|
+
"field": field_name,
|
|
321
|
+
"table": table_name,
|
|
322
|
+
"file": rel_file,
|
|
323
|
+
"layer": "backend",
|
|
324
|
+
})
|
|
325
|
+
edges.append({
|
|
326
|
+
"source": orm_node_id,
|
|
327
|
+
"target": field_node_id,
|
|
328
|
+
"relation": "has_field",
|
|
329
|
+
})
|
|
330
|
+
edges.append({
|
|
331
|
+
"source": f"db:{table_name}.{field_name.lower()}",
|
|
332
|
+
"target": field_node_id,
|
|
333
|
+
"relation": "maps_column_to_orm_field",
|
|
334
|
+
})
|
|
335
|
+
|
|
336
|
+
return {"orm_models": orm_models, "nodes": nodes, "edges": edges}
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def parse_schema_file(file_path: str, repo_root: str = "") -> Dict[str, Any]:
|
|
340
|
+
"""Parses a single SQL, Prisma, Drizzle, or Python ORM schema file."""
|
|
341
|
+
if not os.path.exists(file_path):
|
|
342
|
+
return {"error": f"Schema file not found: {file_path}", "tables": {}, "nodes": [], "edges": []}
|
|
343
|
+
|
|
344
|
+
rel_file = os.path.relpath(file_path, repo_root).replace("\\", "/") if repo_root else os.path.basename(file_path)
|
|
345
|
+
try:
|
|
346
|
+
with open(file_path, "r", encoding="utf-8", errors="ignore") as f:
|
|
347
|
+
content = f.read()
|
|
348
|
+
except Exception as exc:
|
|
349
|
+
return {"error": str(exc), "tables": {}, "nodes": [], "edges": []}
|
|
350
|
+
|
|
351
|
+
lower_path = file_path.lower()
|
|
352
|
+
if lower_path.endswith(".sql"):
|
|
353
|
+
return parse_sql_content(content, rel_file)
|
|
354
|
+
elif lower_path.endswith(".prisma"):
|
|
355
|
+
return parse_prisma_content(content, rel_file)
|
|
356
|
+
elif lower_path.endswith(".py"):
|
|
357
|
+
res = parse_orm_python_content(content, rel_file)
|
|
358
|
+
# Also populate a synthetic tables dict if no explicit SQL file was given
|
|
359
|
+
tables: Dict[str, Dict[str, Any]] = {}
|
|
360
|
+
for cls_name, model_info in res.get("orm_models", {}).items():
|
|
361
|
+
tbl = model_info["table"]
|
|
362
|
+
tables.setdefault(tbl, {"name": tbl, "file": rel_file, "columns": {}})
|
|
363
|
+
for fname, finfo in model_info["fields"].items():
|
|
364
|
+
tables[tbl]["columns"][fname.lower()] = {
|
|
365
|
+
"name": fname.lower(),
|
|
366
|
+
"table": tbl,
|
|
367
|
+
"type": finfo.get("args", "ORM_FIELD"),
|
|
368
|
+
"nullable": "nullable=False" not in finfo.get("args", ""),
|
|
369
|
+
"has_default": "default=" in finfo.get("args", ""),
|
|
370
|
+
"file": rel_file,
|
|
371
|
+
}
|
|
372
|
+
res["tables"] = tables
|
|
373
|
+
return res
|
|
374
|
+
else:
|
|
375
|
+
return parse_sql_content(content, rel_file)
|
|
376
|
+
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""
|
|
2
|
+
BlastTrace Test Discovery & Mapping Parser
|
|
3
|
+
Parses Pytest/Unittest (test_*.py, *_test.py) and Jest/Vitest/Playwright (*.test.ts, *.spec.ts, *.test.tsx)
|
|
4
|
+
to deterministically map code symbols, API endpoints, and schema objects to covering test suites.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import ast
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
from typing import Dict, Any, List
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def is_test_file(rel_file: str) -> bool:
|
|
14
|
+
norm = rel_file.replace("\\", "/").lower()
|
|
15
|
+
base = os.path.basename(norm)
|
|
16
|
+
if base.startswith("test_") and base.endswith(".py"):
|
|
17
|
+
return True
|
|
18
|
+
if base.endswith("_test.py"):
|
|
19
|
+
return True
|
|
20
|
+
if any(base.endswith(ext) for ext in (".test.ts", ".spec.ts", ".test.tsx", ".spec.tsx", ".test.js", ".spec.js")):
|
|
21
|
+
return True
|
|
22
|
+
if "/tests/" in f"/{norm}" and base.endswith((".py", ".ts", ".tsx", ".js")):
|
|
23
|
+
return True
|
|
24
|
+
return False
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def parse_python_test_content(content: str, rel_file: str) -> Dict[str, Any]:
|
|
28
|
+
test_cases: List[Dict[str, Any]] = []
|
|
29
|
+
imported_symbols: List[str] = []
|
|
30
|
+
tested_endpoints: List[str] = []
|
|
31
|
+
nodes: List[Dict[str, Any]] = []
|
|
32
|
+
edges: List[Dict[str, str]] = []
|
|
33
|
+
|
|
34
|
+
suite_id = f"test:{rel_file}"
|
|
35
|
+
nodes.append({
|
|
36
|
+
"id": suite_id,
|
|
37
|
+
"type": "test_suite",
|
|
38
|
+
"name": rel_file,
|
|
39
|
+
"framework": "pytest",
|
|
40
|
+
"file": rel_file,
|
|
41
|
+
"layer": "tests",
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
try:
|
|
45
|
+
tree = ast.parse(content)
|
|
46
|
+
except SyntaxError:
|
|
47
|
+
return {"test_cases": [], "imported_symbols": [], "tested_endpoints": [], "nodes": nodes, "edges": edges}
|
|
48
|
+
|
|
49
|
+
for node in tree.body:
|
|
50
|
+
if isinstance(node, ast.ImportFrom):
|
|
51
|
+
for alias in node.names:
|
|
52
|
+
imported_symbols.append(alias.name)
|
|
53
|
+
elif isinstance(node, ast.Import):
|
|
54
|
+
for alias in node.names:
|
|
55
|
+
imported_symbols.append(alias.name)
|
|
56
|
+
|
|
57
|
+
for node in ast.walk(tree):
|
|
58
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name.startswith("test_"):
|
|
59
|
+
calls: List[str] = []
|
|
60
|
+
endpoints: List[str] = []
|
|
61
|
+
for sub in ast.walk(node):
|
|
62
|
+
if isinstance(sub, ast.Call):
|
|
63
|
+
if isinstance(sub.func, ast.Name):
|
|
64
|
+
calls.append(sub.func.id)
|
|
65
|
+
elif isinstance(sub.func, ast.Attribute):
|
|
66
|
+
calls.append(sub.func.attr)
|
|
67
|
+
if sub.func.attr.lower() in ("get", "post", "put", "delete", "patch") and sub.args:
|
|
68
|
+
first_arg = sub.args[0]
|
|
69
|
+
if isinstance(first_arg, ast.Constant) and isinstance(first_arg.value, str):
|
|
70
|
+
if first_arg.value.startswith("/"):
|
|
71
|
+
ep = f"{sub.func.attr.upper()} {first_arg.value.rstrip('/') or '/'}"
|
|
72
|
+
endpoints.append(ep)
|
|
73
|
+
if ep not in tested_endpoints:
|
|
74
|
+
tested_endpoints.append(ep)
|
|
75
|
+
tc_id = f"test:{rel_file}:{node.name}"
|
|
76
|
+
tc_entry = {
|
|
77
|
+
"id": tc_id,
|
|
78
|
+
"name": node.name,
|
|
79
|
+
"file": rel_file,
|
|
80
|
+
"lineno": node.lineno,
|
|
81
|
+
"called_symbols": list(dict.fromkeys(calls)),
|
|
82
|
+
"tested_endpoints": list(dict.fromkeys(endpoints)),
|
|
83
|
+
}
|
|
84
|
+
test_cases.append(tc_entry)
|
|
85
|
+
nodes.append({
|
|
86
|
+
"id": tc_id,
|
|
87
|
+
"type": "test_case",
|
|
88
|
+
"name": f"{rel_file}::{node.name}",
|
|
89
|
+
"function": node.name,
|
|
90
|
+
"file": rel_file,
|
|
91
|
+
"lineno": node.lineno,
|
|
92
|
+
"layer": "tests",
|
|
93
|
+
})
|
|
94
|
+
edges.append({
|
|
95
|
+
"source": suite_id,
|
|
96
|
+
"target": tc_id,
|
|
97
|
+
"relation": "contains_test",
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
"file": rel_file,
|
|
102
|
+
"framework": "pytest",
|
|
103
|
+
"test_cases": test_cases,
|
|
104
|
+
"imported_symbols": list(dict.fromkeys(imported_symbols)),
|
|
105
|
+
"tested_endpoints": tested_endpoints,
|
|
106
|
+
"nodes": nodes,
|
|
107
|
+
"edges": edges,
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def parse_ts_test_content(content: str, rel_file: str) -> Dict[str, Any]:
|
|
112
|
+
test_cases: List[Dict[str, Any]] = []
|
|
113
|
+
imported_symbols: List[str] = []
|
|
114
|
+
tested_endpoints: List[str] = []
|
|
115
|
+
nodes: List[Dict[str, Any]] = []
|
|
116
|
+
edges: List[Dict[str, str]] = []
|
|
117
|
+
|
|
118
|
+
suite_id = f"test:{rel_file}"
|
|
119
|
+
nodes.append({
|
|
120
|
+
"id": suite_id,
|
|
121
|
+
"type": "test_suite",
|
|
122
|
+
"name": rel_file,
|
|
123
|
+
"framework": "jest_vitest",
|
|
124
|
+
"file": rel_file,
|
|
125
|
+
"layer": "tests",
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
for imp_match in re.finditer(r"import\s+(?:\{([^}]+)\}|([A-Za-z0-9_]+))\s+from\s+[`\"']([^`\"']+)[`\"']", content):
|
|
129
|
+
if imp_match.group(1):
|
|
130
|
+
for s in imp_match.group(1).split(","):
|
|
131
|
+
sym = s.strip().split(" as ")[0].strip()
|
|
132
|
+
if sym:
|
|
133
|
+
imported_symbols.append(sym)
|
|
134
|
+
elif imp_match.group(2):
|
|
135
|
+
imported_symbols.append(imp_match.group(2).strip())
|
|
136
|
+
|
|
137
|
+
for tc_match in re.finditer(r"\b(?:it|test)\s*\(\s*[`\"']([^`\"']+)[`\"']", content):
|
|
138
|
+
tc_name = tc_match.group(1)
|
|
139
|
+
safe_name = re.sub(r"[^a-zA-Z0-9_]+", "_", tc_name).strip("_")
|
|
140
|
+
tc_id = f"test:{rel_file}:{safe_name}"
|
|
141
|
+
test_cases.append({
|
|
142
|
+
"id": tc_id,
|
|
143
|
+
"name": tc_name,
|
|
144
|
+
"file": rel_file,
|
|
145
|
+
"called_symbols": imported_symbols,
|
|
146
|
+
"tested_endpoints": [],
|
|
147
|
+
})
|
|
148
|
+
nodes.append({
|
|
149
|
+
"id": tc_id,
|
|
150
|
+
"type": "test_case",
|
|
151
|
+
"name": f"{rel_file}::{tc_name}",
|
|
152
|
+
"function": tc_name,
|
|
153
|
+
"file": rel_file,
|
|
154
|
+
"layer": "tests",
|
|
155
|
+
})
|
|
156
|
+
edges.append({"source": suite_id, "target": tc_id, "relation": "contains_test"})
|
|
157
|
+
|
|
158
|
+
return {
|
|
159
|
+
"file": rel_file,
|
|
160
|
+
"framework": "jest_vitest",
|
|
161
|
+
"test_cases": test_cases,
|
|
162
|
+
"imported_symbols": list(dict.fromkeys(imported_symbols)),
|
|
163
|
+
"tested_endpoints": tested_endpoints,
|
|
164
|
+
"nodes": nodes,
|
|
165
|
+
"edges": edges,
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def parse_test_file(file_path: str, repo_root: str = "") -> Dict[str, Any]:
|
|
170
|
+
if not os.path.exists(file_path):
|
|
171
|
+
return {"error": f"Test file not found: {file_path}", "test_cases": [], "nodes": [], "edges": []}
|
|
172
|
+
rel_file = os.path.relpath(file_path, repo_root).replace("\\", "/") if repo_root else os.path.basename(file_path)
|
|
173
|
+
try:
|
|
174
|
+
with open(file_path, "r", encoding="utf-8", errors="ignore") as f:
|
|
175
|
+
content = f.read()
|
|
176
|
+
except Exception as exc:
|
|
177
|
+
return {"error": str(exc), "test_cases": [], "nodes": [], "edges": []}
|
|
178
|
+
|
|
179
|
+
if file_path.lower().endswith(".py"):
|
|
180
|
+
return parse_python_test_content(content, rel_file)
|
|
181
|
+
return parse_ts_test_content(content, rel_file)
|
|
182
|
+
|