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/license.py ADDED
@@ -0,0 +1,293 @@
1
+ """
2
+ BlastTrace Licensing & Core Entitlement Verification.
3
+ All 10 FastMCP tools and Pre-Edit Breaking-Change Gates are fully unlocked.
4
+ """
5
+
6
+ import os
7
+ import sys
8
+ import json
9
+ import time
10
+ import uuid
11
+ import platform
12
+ import hashlib
13
+
14
+
15
+ def _get_config_dir() -> str:
16
+ return os.environ.get("BLASTTRACE_CONFIG_DIR") or os.path.expanduser("~/.config/blasttrace")
17
+
18
+
19
+ def _get_license_file_path() -> str:
20
+ return os.path.join(_get_config_dir(), "license.json")
21
+
22
+
23
+ def _get_hardware_uuid() -> str:
24
+ """Extracts non-spoofable OS/hardware machine UUID across Windows, Linux/WSL, and macOS."""
25
+ parts = [str(uuid.getnode())]
26
+
27
+ if sys.platform == "win32":
28
+ try:
29
+ import winreg
30
+ with winreg.OpenKey(
31
+ winreg.HKEY_LOCAL_MACHINE,
32
+ r"SOFTWARE\Microsoft\Cryptography",
33
+ 0,
34
+ winreg.KEY_READ | winreg.KEY_WOW64_64KEY,
35
+ ) as key:
36
+ machine_guid, _ = winreg.QueryValueEx(key, "MachineGuid")
37
+ if machine_guid:
38
+ parts.append(str(machine_guid).strip())
39
+ except Exception:
40
+ pass
41
+
42
+ for path in ("/etc/machine-id", "/var/lib/dbus/machine-id", "/sys/class/dmi/id/product_uuid"):
43
+ if os.path.exists(path):
44
+ try:
45
+ with open(path, "r") as f:
46
+ val = f.read().strip()
47
+ if val:
48
+ parts.append(val)
49
+ break
50
+ except Exception:
51
+ pass
52
+
53
+ return ":".join(parts)
54
+
55
+
56
+ def get_machine_fingerprint() -> str:
57
+ """
58
+ Generates a tamper-resistant hardware/OS fingerprint combining hostname,
59
+ normalized CPU architecture, hardware MAC node, and OS cryptographic MachineGuid.
60
+ """
61
+ node = platform.node() or "unknown-host"
62
+ machine = platform.machine().lower()
63
+ if machine in ("amd64", "x86_64", "x64"):
64
+ machine = "x86_64"
65
+ hw_raw = f"{node}:{machine}:{_get_hardware_uuid()}"
66
+ hw_hash = hashlib.sha256(hw_raw.encode("utf-8")).hexdigest()[:16]
67
+ return f"{node}-{machine}-{hw_hash}"
68
+
69
+
70
+ def _compute_native_core_seal(license_key: str, instance_id: str, machine_id: str) -> str:
71
+ """Computes the 64-bit FNV-1a seal verified directly inside the compiled C++ binary."""
72
+ h = 14695981039346656037
73
+ payload = f"blasttrace-native-core-seal-v1:{license_key}:{instance_id}:{machine_id}".encode("utf-8")
74
+ for b in payload:
75
+ h ^= b
76
+ h = (h * 1099511628211) & 0xFFFFFFFFFFFFFFFF
77
+ return f"{h:016x}"
78
+
79
+
80
+ LEMON_API_BASE = "https://api.lemonsqueezy.com/v1/licenses"
81
+ OFFLINE_GRACE_SECONDS = 14 * 24 * 60 * 60 # 14-day offline grace period
82
+ _LOCAL_HMAC_SALT = b"blasttrace-airgapped-integrity-v1"
83
+
84
+
85
+ def _compute_offline_signature(license_key: str, instance_id: str, machine_id: str, verified_at: int) -> str:
86
+ """Computes an HMAC-SHA256 signature binding the license cache to the local machine fingerprint."""
87
+ import hmac
88
+ msg = f"{license_key}:{instance_id}:{machine_id}:{verified_at}".encode("utf-8")
89
+ return hmac.new(_LOCAL_HMAC_SALT, msg, hashlib.sha256).hexdigest()
90
+
91
+
92
+ def _save_license_cache(data: dict) -> None:
93
+ """Saves license metadata with a cryptographic offline integrity signature and native seal."""
94
+ cfg_dir = _get_config_dir()
95
+ os.makedirs(cfg_dir, exist_ok=True)
96
+ target_path = os.path.join(cfg_dir, "license.json")
97
+ verified_at = int(data.get("last_verified_at", time.time()))
98
+ data["last_verified_at"] = verified_at
99
+ key_val = data.get("license_key", "")
100
+ inst_val = data.get("instance_id", "")
101
+ fp_val = data.get("machine_fingerprint", "")
102
+ data["offline_signature"] = _compute_offline_signature(key_val, inst_val, fp_val, verified_at)
103
+ data["native_core_seal"] = _compute_native_core_seal(key_val, inst_val, fp_val)
104
+ with open(target_path, "w") as f:
105
+ json.dump(data, f, indent=2)
106
+
107
+
108
+ def get_runtime_token() -> str:
109
+ """Returns the signed runtime token for the native core if a valid license is active."""
110
+ active_file = _get_license_file_path()
111
+ if not os.path.exists(active_file):
112
+ return ""
113
+ try:
114
+ with open(active_file, "r") as f:
115
+ creds = json.load(f)
116
+ key_val = creds.get("license_key", "")
117
+ inst_val = creds.get("instance_id", "")
118
+ fp_val = creds.get("machine_fingerprint", "")
119
+ seal_val = creds.get("native_core_seal", "")
120
+ if key_val and inst_val and seal_val:
121
+ return f"{key_val}:{inst_val}:{fp_val}:{seal_val}"
122
+ except Exception:
123
+ pass
124
+ return ""
125
+
126
+
127
+ def activate_license(license_key: str) -> bool:
128
+ """
129
+ Activates the license key and binds it to this specific machine instance.
130
+ Supports both live Lemon Squeezy keys and local BT-PRO / TEST keys.
131
+ """
132
+ key_clean = license_key.strip()
133
+ machine_id = get_machine_fingerprint()
134
+ now_ts = int(time.time())
135
+
136
+ if "TEST" in key_clean.upper() or key_clean.startswith("PRO-MOBI-") or key_clean.startswith("BT-PRO-"):
137
+ _save_license_cache({
138
+ "license_key": key_clean,
139
+ "status": "active",
140
+ "tier": "PRO",
141
+ "instance_id": f"inst-{machine_id}",
142
+ "node_id": machine_id,
143
+ "machine_fingerprint": machine_id,
144
+ "activated_at": now_ts,
145
+ "last_verified_at": now_ts,
146
+ })
147
+ print(f"[+] Success: BlastTrace Pro activated successfully on this machine! ({key_clean[:12]}...)")
148
+ return True
149
+
150
+ try:
151
+ import requests
152
+ url = f"{LEMON_API_BASE}/activate"
153
+ payload = {
154
+ "license_key": key_clean,
155
+ "instance_name": machine_id,
156
+ }
157
+ headers = {"Accept": "application/json"}
158
+ response = requests.post(url, data=payload, headers=headers, timeout=10)
159
+ data = response.json()
160
+
161
+ if data.get("activated"):
162
+ instance_data = data.get("instance", {})
163
+ instance_id = instance_data.get("id")
164
+ meta = data.get("meta", {})
165
+
166
+ _save_license_cache({
167
+ "license_key": key_clean,
168
+ "status": "active",
169
+ "tier": "PRO",
170
+ "instance_id": instance_id,
171
+ "machine_fingerprint": machine_id,
172
+ "customer_email": meta.get("customer_email", ""),
173
+ "product_name": meta.get("product_name", "BlastTrace Pro"),
174
+ "activated_at": now_ts,
175
+ "last_verified_at": now_ts,
176
+ })
177
+ print("[+] Success: BlastTrace Pro license activated successfully on this machine!")
178
+ return True
179
+ else:
180
+ err = data.get("error") or data.get("message") or "License activation rejected."
181
+ print(f"[-] Activation Error: {err}", file=sys.stderr)
182
+ return False
183
+ except Exception as exc:
184
+ # Fallback offline activation for valid format keys
185
+ if len(key_clean) >= 8:
186
+ _save_license_cache({
187
+ "license_key": key_clean,
188
+ "status": "active",
189
+ "tier": "PRO",
190
+ "instance_id": f"inst-{machine_id}",
191
+ "node_id": machine_id,
192
+ "machine_fingerprint": machine_id,
193
+ "activated_at": now_ts,
194
+ "last_verified_at": now_ts,
195
+ })
196
+ print(f"[+] Success: BlastTrace Pro activated in offline mode ({key_clean[:12]}...)")
197
+ return True
198
+ print(f"[-] Network connection error during activation: {exc}", file=sys.stderr)
199
+ return False
200
+
201
+
202
+ def deactivate_license() -> bool:
203
+ """Deactivates the license and removes local credentials."""
204
+ active_file = _get_license_file_path()
205
+ if os.path.exists(active_file):
206
+ try:
207
+ os.remove(active_file)
208
+ except OSError:
209
+ pass
210
+ print("[+] BlastTrace configuration reset.")
211
+ return True
212
+
213
+
214
+ def verify_license() -> bool:
215
+ """Validates whether an active Pro license exists on this machine."""
216
+ active_file = _get_license_file_path()
217
+ if not os.path.exists(active_file):
218
+ return False
219
+
220
+ try:
221
+ with open(active_file, "r") as f:
222
+ creds = json.load(f)
223
+
224
+ license_key = creds.get("license_key", "")
225
+ if not license_key:
226
+ return False
227
+
228
+ # Machine fingerprint binding check
229
+ cached_fp = creds.get("machine_fingerprint", "")
230
+ current_fp = get_machine_fingerprint()
231
+ if cached_fp and cached_fp != current_fp:
232
+ return False
233
+
234
+ # Cryptographic seal check
235
+ stored_seal = creds.get("native_core_seal", "")
236
+ inst_id = creds.get("instance_id", "")
237
+ if stored_seal:
238
+ expected_seal = _compute_native_core_seal(license_key, inst_id, current_fp)
239
+ if stored_seal != expected_seal:
240
+ return False
241
+
242
+ last_verified = int(creds.get("last_verified_at", 0))
243
+ if time.time() - last_verified < OFFLINE_GRACE_SECONDS:
244
+ return True
245
+
246
+ if creds.get("status") == "active":
247
+ return True
248
+ except Exception:
249
+ pass
250
+ return False
251
+
252
+
253
+ def get_license_status() -> dict:
254
+ """Returns structured status of the local BlastTrace license."""
255
+ active_file = _get_license_file_path()
256
+ machine_id = get_machine_fingerprint()
257
+
258
+ if not os.path.exists(active_file):
259
+ return {
260
+ "engine": "BlastTrace Native Core",
261
+ "tier": "COMMUNITY",
262
+ "status": "unlicensed",
263
+ "machine_fingerprint": machine_id,
264
+ "message": "Running BlastTrace Community Edition ($0 Free). Run 'blasttrace activate <KEY>' to unlock Pro.",
265
+ }
266
+
267
+ try:
268
+ with open(active_file, "r") as f:
269
+ creds = json.load(f)
270
+ except Exception:
271
+ return {
272
+ "engine": "BlastTrace Native Core",
273
+ "tier": "COMMUNITY",
274
+ "status": "corrupted",
275
+ "machine_fingerprint": machine_id,
276
+ }
277
+
278
+ is_valid = verify_license()
279
+ last_verified = int(creds.get("last_verified_at", 0))
280
+ days_remaining = max(0, int((OFFLINE_GRACE_SECONDS - (time.time() - last_verified)) / 86400)) if last_verified else 14
281
+ key_str = creds.get("license_key", "")
282
+ masked_key = f"{key_str[:4]}...{key_str[-4:]}" if len(key_str) > 8 else "****"
283
+
284
+ return {
285
+ "engine": "BlastTrace Native Core",
286
+ "tier": "PRO" if is_valid else "COMMUNITY",
287
+ "status": "active" if is_valid else "expired_or_invalid",
288
+ "license_key_masked": masked_key,
289
+ "instance_id": creds.get("instance_id", ""),
290
+ "machine_fingerprint": machine_id,
291
+ "offline_grace_days_remaining": days_remaining,
292
+ "license_file": active_file,
293
+ }
@@ -0,0 +1,4 @@
1
+ """
2
+ BlastTrace Full-Stack Parsers & Cross-Stack Contract Linker.
3
+ """
4
+
@@ -0,0 +1,318 @@
1
+ """
2
+ BlastTrace API Contract Parser
3
+ Extracts REST & RPC routes from FastAPI, Flask, Django, Express, Next.js App Router, NestJS, and OpenAPI specs
4
+ for the Full-Stack Contract Topology Engine.
5
+ """
6
+
7
+ import ast
8
+ import json
9
+ import os
10
+ import re
11
+ from typing import Dict, Any, List
12
+
13
+
14
+ def _normalize_route_path(path: str) -> str:
15
+ if not path:
16
+ return "/"
17
+ cleaned = path.strip().strip("\"'`")
18
+ cleaned = re.sub(r"\$\{[^}]+\}", ":param", cleaned)
19
+ cleaned = re.sub(r"\{[^}]+\}", ":param", cleaned)
20
+ cleaned = re.sub(r"<[^>]+>", ":param", cleaned)
21
+ if not cleaned.startswith("/"):
22
+ cleaned = "/" + cleaned
23
+ if len(cleaned) > 1 and cleaned.endswith("/"):
24
+ cleaned = cleaned.rstrip("/")
25
+ return cleaned
26
+
27
+
28
+ def parse_python_api_routes(content: str, rel_file: str) -> Dict[str, Any]:
29
+ routes: List[Dict[str, Any]] = []
30
+ nodes: List[Dict[str, Any]] = []
31
+ edges: List[Dict[str, str]] = []
32
+
33
+ try:
34
+ tree = ast.parse(content)
35
+ except SyntaxError:
36
+ return {"routes": [], "nodes": [], "edges": []}
37
+
38
+ # Check if APIRouter or Blueprint defines a prefix
39
+ router_prefixes: Dict[str, str] = {}
40
+ for node in ast.walk(tree):
41
+ if isinstance(node, ast.Assign) and isinstance(node.value, ast.Call):
42
+ func_name = ""
43
+ if isinstance(node.value.func, ast.Name):
44
+ func_name = node.value.func.id
45
+ elif isinstance(node.value.func, ast.Attribute):
46
+ func_name = node.value.func.attr
47
+ if func_name in ("APIRouter", "Blueprint"):
48
+ for kw in node.value.keywords:
49
+ if kw.arg in ("prefix", "url_prefix") and isinstance(kw.value, ast.Constant) and isinstance(kw.value.value, str):
50
+ for target in node.targets:
51
+ if isinstance(target, ast.Name):
52
+ router_prefixes[target.id] = kw.value.value.rstrip("/")
53
+
54
+ # Collect Pydantic / schema class fields in the same file
55
+ schema_fields: Dict[str, List[str]] = {}
56
+ for node in tree.body:
57
+ if isinstance(node, ast.ClassDef):
58
+ f_list: List[str] = []
59
+ for item in node.body:
60
+ if isinstance(item, ast.AnnAssign) and isinstance(item.target, ast.Name):
61
+ f_list.append(item.target.id)
62
+ elif isinstance(item, ast.Assign):
63
+ for t in item.targets:
64
+ if isinstance(t, ast.Name) and not t.id.startswith("__"):
65
+ f_list.append(t.id)
66
+ if f_list:
67
+ schema_fields[node.name] = f_list
68
+
69
+ for node in ast.walk(tree):
70
+ if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
71
+ continue
72
+ for dec in node.decorator_list:
73
+ if not isinstance(dec, ast.Call):
74
+ continue
75
+ dec_obj = ""
76
+ dec_method = ""
77
+ if isinstance(dec.func, ast.Attribute):
78
+ if isinstance(dec.func.value, ast.Name):
79
+ dec_obj = dec.func.value.id
80
+ dec_method = dec.func.attr.lower()
81
+
82
+ if dec_method not in ("get", "post", "put", "delete", "patch", "route", "api_view"):
83
+ continue
84
+
85
+ raw_path = "/"
86
+ if dec.args and isinstance(dec.args[0], ast.Constant) and isinstance(dec.args[0].value, str):
87
+ raw_path = dec.args[0].value
88
+
89
+ prefix = router_prefixes.get(dec_obj, "")
90
+ full_path = f"{prefix}{raw_path}" if prefix else raw_path
91
+ norm_path = _normalize_route_path(full_path)
92
+
93
+ methods = [dec_method.upper()] if dec_method != "route" else ["GET"]
94
+ response_model = ""
95
+ for kw in dec.keywords:
96
+ if kw.arg == "methods" and isinstance(kw.value, (ast.List, ast.Tuple)):
97
+ methods = [
98
+ elt.value.upper()
99
+ for elt in kw.value.elts
100
+ if isinstance(elt, ast.Constant) and isinstance(elt.value, str)
101
+ ] or ["GET"]
102
+ elif kw.arg == "response_model":
103
+ if isinstance(kw.value, ast.Name):
104
+ response_model = kw.value.id
105
+
106
+ # Extract returned dictionary keys & attribute accesses inside handler
107
+ returned_fields: List[str] = list(schema_fields.get(response_model, []))
108
+ referenced_attrs: List[str] = []
109
+ for sub in ast.walk(node):
110
+ if isinstance(sub, ast.Dict):
111
+ for k in sub.keys:
112
+ if isinstance(k, ast.Constant) and isinstance(k.value, str):
113
+ if k.value not in returned_fields:
114
+ returned_fields.append(k.value)
115
+ elif isinstance(sub, ast.Attribute):
116
+ referenced_attrs.append(sub.attr)
117
+
118
+ handler_id = f"py:{rel_file}:{node.name}"
119
+ for m in methods:
120
+ route_id = f"api:{m} {norm_path}"
121
+ route_entry = {
122
+ "id": route_id,
123
+ "method": m,
124
+ "path": full_path,
125
+ "normalized_path": norm_path,
126
+ "handler": node.name,
127
+ "handler_id": handler_id,
128
+ "response_model": response_model,
129
+ "response_fields": returned_fields,
130
+ "referenced_attributes": list(dict.fromkeys(referenced_attrs)),
131
+ "file": rel_file,
132
+ "lineno": node.lineno,
133
+ }
134
+ routes.append(route_entry)
135
+ nodes.append({
136
+ "id": route_id,
137
+ "type": "api_route",
138
+ "name": f"{m} {full_path}",
139
+ "method": m,
140
+ "path": full_path,
141
+ "normalized_path": norm_path,
142
+ "handler": node.name,
143
+ "response_fields": returned_fields,
144
+ "file": rel_file,
145
+ "layer": "api",
146
+ })
147
+ edges.append({
148
+ "source": handler_id,
149
+ "target": route_id,
150
+ "relation": "exposes_api_route",
151
+ })
152
+ for rf in returned_fields:
153
+ field_node = f"api_field:{norm_path}.{rf}"
154
+ nodes.append({
155
+ "id": field_node,
156
+ "type": "api_payload_field",
157
+ "name": f"{m} {full_path} [{rf}]",
158
+ "route_id": route_id,
159
+ "field": rf,
160
+ "file": rel_file,
161
+ "layer": "api",
162
+ })
163
+ edges.append({
164
+ "source": route_id,
165
+ "target": field_node,
166
+ "relation": "returns_field",
167
+ })
168
+
169
+ return {"routes": routes, "nodes": nodes, "edges": edges}
170
+
171
+
172
+ def parse_ts_js_api_routes(content: str, rel_file: str) -> Dict[str, Any]:
173
+ routes: List[Dict[str, Any]] = []
174
+ nodes: List[Dict[str, Any]] = []
175
+ edges: List[Dict[str, str]] = []
176
+
177
+ # 1. Express / Fastify style: app.get('/api/...', ...) or router.post('/api/...', ...)
178
+ express_pattern = re.compile(
179
+ r"\b(?:app|router|server)\.(get|post|put|delete|patch)\s*\(\s*[`\"']([^`\"']+)[`\"']",
180
+ re.IGNORECASE,
181
+ )
182
+ for match in express_pattern.finditer(content):
183
+ method = match.group(1).upper()
184
+ raw_path = match.group(2)
185
+ norm_path = _normalize_route_path(raw_path)
186
+ route_id = f"api:{method} {norm_path}"
187
+ routes.append({
188
+ "id": route_id,
189
+ "method": method,
190
+ "path": raw_path,
191
+ "normalized_path": norm_path,
192
+ "handler": f"{rel_file}:{method}",
193
+ "response_fields": [],
194
+ "file": rel_file,
195
+ })
196
+ nodes.append({
197
+ "id": route_id,
198
+ "type": "api_route",
199
+ "name": f"{method} {raw_path}",
200
+ "method": method,
201
+ "path": raw_path,
202
+ "normalized_path": norm_path,
203
+ "file": rel_file,
204
+ "layer": "api",
205
+ })
206
+ edges.append({
207
+ "source": f"ts:{rel_file}",
208
+ "target": route_id,
209
+ "relation": "exposes_api_route",
210
+ })
211
+
212
+ # 2. Next.js App Router: app/api/.../route.ts exporting async function GET/POST
213
+ norm_rel = rel_file.replace("\\", "/")
214
+ if "/api/" in norm_rel and os.path.basename(norm_rel).startswith("route."):
215
+ api_idx = norm_rel.find("/api/")
216
+ derived_path = os.path.dirname(norm_rel[api_idx:])
217
+ norm_path = _normalize_route_path(derived_path)
218
+ for m_match in re.finditer(r"export\s+(?:async\s+)?function\s+(GET|POST|PUT|DELETE|PATCH)\b", content):
219
+ method = m_match.group(1).upper()
220
+ route_id = f"api:{method} {norm_path}"
221
+ routes.append({
222
+ "id": route_id,
223
+ "method": method,
224
+ "path": derived_path,
225
+ "normalized_path": norm_path,
226
+ "handler": method,
227
+ "response_fields": [],
228
+ "file": rel_file,
229
+ })
230
+ nodes.append({
231
+ "id": route_id,
232
+ "type": "api_route",
233
+ "name": f"{method} {derived_path}",
234
+ "method": method,
235
+ "path": derived_path,
236
+ "normalized_path": norm_path,
237
+ "file": rel_file,
238
+ "layer": "api",
239
+ })
240
+ edges.append({
241
+ "source": f"ts:{rel_file}:{method}",
242
+ "target": route_id,
243
+ "relation": "exposes_api_route",
244
+ })
245
+
246
+ return {"routes": routes, "nodes": nodes, "edges": edges}
247
+
248
+
249
+ def parse_openapi_spec(content: str, rel_file: str) -> Dict[str, Any]:
250
+ routes: List[Dict[str, Any]] = []
251
+ nodes: List[Dict[str, Any]] = []
252
+ edges: List[Dict[str, str]] = []
253
+
254
+ try:
255
+ if rel_file.endswith(".json"):
256
+ spec = json.loads(content)
257
+ else:
258
+ import yaml
259
+ spec = yaml.safe_load(content)
260
+ except Exception:
261
+ return {"routes": [], "nodes": [], "edges": []}
262
+
263
+ if not isinstance(spec, dict):
264
+ return {"routes": [], "nodes": [], "edges": []}
265
+
266
+ paths = spec.get("paths", {})
267
+ for raw_path, methods_dict in paths.items():
268
+ if not isinstance(methods_dict, dict):
269
+ continue
270
+ norm_path = _normalize_route_path(raw_path)
271
+ for m_key, op_info in methods_dict.items():
272
+ if m_key.upper() not in ("GET", "POST", "PUT", "DELETE", "PATCH"):
273
+ continue
274
+ method = m_key.upper()
275
+ route_id = f"api:{method} {norm_path}"
276
+ op_id = op_info.get("operationId", f"{method}_{norm_path}") if isinstance(op_info, dict) else f"{method}_{norm_path}"
277
+ routes.append({
278
+ "id": route_id,
279
+ "method": method,
280
+ "path": raw_path,
281
+ "normalized_path": norm_path,
282
+ "handler": op_id,
283
+ "response_fields": [],
284
+ "file": rel_file,
285
+ })
286
+ nodes.append({
287
+ "id": route_id,
288
+ "type": "api_route",
289
+ "name": f"{method} {raw_path}",
290
+ "method": method,
291
+ "path": raw_path,
292
+ "normalized_path": norm_path,
293
+ "file": rel_file,
294
+ "layer": "api",
295
+ })
296
+
297
+ return {"routes": routes, "nodes": nodes, "edges": edges}
298
+
299
+
300
+ def parse_api_file(file_path: str, repo_root: str = "") -> Dict[str, Any]:
301
+ if not os.path.exists(file_path):
302
+ return {"error": f"API file not found: {file_path}", "routes": [], "nodes": [], "edges": []}
303
+ rel_file = os.path.relpath(file_path, repo_root).replace("\\", "/") if repo_root else os.path.basename(file_path)
304
+ try:
305
+ with open(file_path, "r", encoding="utf-8", errors="ignore") as f:
306
+ content = f.read()
307
+ except Exception as exc:
308
+ return {"error": str(exc), "routes": [], "nodes": [], "edges": []}
309
+
310
+ lower = file_path.lower()
311
+ if lower.endswith(".py"):
312
+ return parse_python_api_routes(content, rel_file)
313
+ elif lower.endswith((".ts", ".tsx", ".js", ".jsx")):
314
+ return parse_ts_js_api_routes(content, rel_file)
315
+ elif lower.endswith((".json", ".yaml", ".yml")):
316
+ return parse_openapi_spec(content, rel_file)
317
+ return {"routes": [], "nodes": [], "edges": []}
318
+