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,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,127 @@
1
+ """
2
+ BlastTrace Swift & iOS Mobile Parser
3
+ Parses Swift data structures (Codable/Decodable structs, classes, CodingKeys),
4
+ and API network consumers (URLSession, Alamofire AF.request) in iOS and macOS client apps.
5
+ """
6
+
7
+ import os
8
+ import re
9
+ from typing import Dict, Any, List
10
+
11
+
12
+ def _normalize_route_path(raw_url: str) -> str:
13
+ cleaned = raw_url.strip().strip("\"'`")
14
+ cleaned = re.sub(r"^https?://[^/]+", "", cleaned)
15
+ cleaned = cleaned.split("?")[0]
16
+ cleaned = re.sub(r"\\\(param[^)]*\)", ":param", cleaned)
17
+ cleaned = re.sub(r"\\\([^)]+\)", ":param", cleaned)
18
+ if not cleaned.startswith("/"):
19
+ cleaned = "/" + cleaned
20
+ if len(cleaned) > 1 and cleaned.endswith("/"):
21
+ cleaned = cleaned.rstrip("/")
22
+ return cleaned
23
+
24
+
25
+ def parse_swift_content(content: str, rel_path: str = "") -> Dict[str, Any]:
26
+ """
27
+ Extracts Swift structs/classes, Codable fields, CodingKeys mappings,
28
+ and HTTP API consumer calls from Swift source code.
29
+ """
30
+ classes: List[Dict[str, Any]] = []
31
+ api_calls: List[Dict[str, Any]] = []
32
+ accessed_properties: List[str] = []
33
+
34
+ # 1. Parse Swift Structs and Classes (e.g. struct Product: Codable, class UserModel)
35
+ struct_regex = re.compile(
36
+ r"""(?:public\s+|private\s+|internal\s+)?(?:struct|class)\s+([A-Za-z0-9_]+)(?:\s*:\s*([^{]+))?\s*\{""",
37
+ re.MULTILINE,
38
+ )
39
+ for m in struct_regex.finditer(content):
40
+ sname = m.group(1)
41
+ protocol_str = m.group(2) or ""
42
+ classes.append({
43
+ "name": sname,
44
+ "type": "swift_model",
45
+ "protocols": [p.strip() for p in protocol_str.split(",") if p.strip()],
46
+ "file": rel_path,
47
+ "line": content[:m.start()].count("\n") + 1,
48
+ })
49
+
50
+ # 2. Parse CodingKeys mappings: case sellingPrice = "selling_price"
51
+ coding_key_regex = re.compile(r"""case\s+([a-zA-Z0-9_]+)\s*=\s*["']([a-zA-Z0-9_]+)["']""")
52
+ for cm in coding_key_regex.finditer(content):
53
+ prop = cm.group(2)
54
+ if prop not in accessed_properties:
55
+ accessed_properties.append(prop)
56
+
57
+ # 3. Parse Swift property declarations: let email: String or var balance: Double
58
+ prop_regex = re.compile(r"""(?:let|var)\s+([a-zA-Z0-9_]+)\s*:\s*([A-Za-z0-9_<>?]+)""", re.MULTILINE)
59
+ for pm in prop_regex.finditer(content):
60
+ pname = pm.group(1)
61
+ if pname not in ("body", "shared", "default", "self", "super") and pname not in accessed_properties:
62
+ accessed_properties.append(pname)
63
+
64
+ # 4. Parse URLSession / Alamofire / Moya API calls
65
+ # Examples: URL(string: "..."), AF.request("..."), URLSession.shared.dataTask(with: ...)
66
+ url_patterns = [
67
+ re.compile(r"""(?:URL|URLRequest)\s*\(\s*string:\s*["']([^"']+)["']""", re.IGNORECASE),
68
+ re.compile(r"""AF\.request\s*\(\s*["']([^"']+)["']""", re.IGNORECASE),
69
+ re.compile(r"""["']((?:/api/v[0-9]+|/api/|/products|/sales|/sync|/orders|/users)[^"']*)["']"""),
70
+ ]
71
+
72
+ for m in url_patterns[0].finditer(content):
73
+ raw_url = m.group(1)
74
+ route_path = _normalize_route_path(raw_url)
75
+ api_calls.append({
76
+ "method": "ANY",
77
+ "path": route_path,
78
+ "raw_url": raw_url,
79
+ "file": rel_path,
80
+ "line": content[:m.start()].count("\n") + 1,
81
+ })
82
+
83
+ for m in url_patterns[1].finditer(content):
84
+ raw_url = m.group(1)
85
+ route_path = _normalize_route_path(raw_url)
86
+ if not any(c["path"] == route_path for c in api_calls):
87
+ api_calls.append({
88
+ "method": "ANY",
89
+ "path": route_path,
90
+ "raw_url": raw_url,
91
+ "file": rel_path,
92
+ "line": content[:m.start()].count("\n") + 1,
93
+ })
94
+
95
+ for m in url_patterns[2].finditer(content):
96
+ raw_url = m.group(1)
97
+ route_path = _normalize_route_path(raw_url)
98
+ if not any(c["path"] == route_path for c in api_calls):
99
+ api_calls.append({
100
+ "method": "ANY",
101
+ "path": route_path,
102
+ "raw_url": raw_url,
103
+ "file": rel_path,
104
+ "line": content[:m.start()].count("\n") + 1,
105
+ })
106
+
107
+ return {
108
+ "file": rel_path,
109
+ "layer": "ios_client",
110
+ "classes": classes,
111
+ "api_calls": api_calls,
112
+ "accessed_properties": accessed_properties,
113
+ }
114
+
115
+
116
+ def parse_swift_file(file_path: str, repo_root: str = "") -> Dict[str, Any]:
117
+ if not os.path.exists(file_path):
118
+ return {"error": f"File not found: {file_path}"}
119
+ rel_path = os.path.relpath(file_path, repo_root) if repo_root else file_path
120
+ rel_path = rel_path.replace("\\", "/")
121
+ try:
122
+ with open(file_path, "r", encoding="utf-8", errors="replace") as f:
123
+ content = f.read()
124
+ return parse_swift_content(content, rel_path)
125
+ except Exception as exc:
126
+ return {"error": str(exc), "file": rel_path}
127
+
@@ -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
+