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
blasttrace/server.py
ADDED
|
@@ -0,0 +1,824 @@
|
|
|
1
|
+
"""
|
|
2
|
+
BlastTrace FastMCP Server & Standalone CLI
|
|
3
|
+
Pre-Edit Full-Stack Breaking-Change & Contract Blast-Radius Engine.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
from typing import List, Dict, Any, Optional
|
|
11
|
+
|
|
12
|
+
# Ensure project root and build directories are on sys.path
|
|
13
|
+
PACKAGE_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
14
|
+
ROOT_DIR = os.path.dirname(PACKAGE_DIR)
|
|
15
|
+
for candidate in (ROOT_DIR, os.path.join(ROOT_DIR, "build")):
|
|
16
|
+
if candidate not in sys.path:
|
|
17
|
+
sys.path.insert(0, candidate)
|
|
18
|
+
|
|
19
|
+
from blasttrace.license import (
|
|
20
|
+
activate_license,
|
|
21
|
+
deactivate_license,
|
|
22
|
+
verify_license,
|
|
23
|
+
get_license_status,
|
|
24
|
+
get_runtime_token,
|
|
25
|
+
)
|
|
26
|
+
from blasttrace.parsers.contract_linker import (
|
|
27
|
+
build_fullstack_topology,
|
|
28
|
+
resolve_entity_id,
|
|
29
|
+
)
|
|
30
|
+
from blasttrace.parsers.sql_schema_parser import parse_schema_file
|
|
31
|
+
from blasttrace.parsers.python_ast_parser import parse_python_file
|
|
32
|
+
from blasttrace.parsers.ts_react_parser import parse_ts_react_file
|
|
33
|
+
from blasttrace.parsers.api_route_parser import parse_api_file
|
|
34
|
+
from blasttrace.classifier import (
|
|
35
|
+
classify_proposed_edit,
|
|
36
|
+
format_evidence_chains,
|
|
37
|
+
)
|
|
38
|
+
from blasttrace.visualizer import (
|
|
39
|
+
generate_mermaid_blast_radius,
|
|
40
|
+
generate_mermaid_contract_chain,
|
|
41
|
+
generate_mermaid_cycles,
|
|
42
|
+
)
|
|
43
|
+
from blasttrace.remediation import (
|
|
44
|
+
generate_surgical_sync_plan,
|
|
45
|
+
generate_cycle_refactoring_plan,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
from collections import deque
|
|
49
|
+
|
|
50
|
+
class _DeterministicReachabilityEngine:
|
|
51
|
+
"""
|
|
52
|
+
BlastTrace Native Core — Pure-environment deterministic dependency reachability engine.
|
|
53
|
+
Ensures seamless cross-platform execution on Windows, macOS, and Linux without external build tools.
|
|
54
|
+
"""
|
|
55
|
+
def __init__(self):
|
|
56
|
+
self._topology_links: Dict[str, List[str]] = {}
|
|
57
|
+
|
|
58
|
+
def add_edge(self, u: str, v: str) -> None:
|
|
59
|
+
if u not in self._topology_links:
|
|
60
|
+
self._topology_links[u] = []
|
|
61
|
+
if v not in self._topology_links[u]:
|
|
62
|
+
self._topology_links[u].append(v)
|
|
63
|
+
if v not in self._topology_links:
|
|
64
|
+
self._topology_links[v] = []
|
|
65
|
+
|
|
66
|
+
def find_shortest_path(self, start: str, target: str) -> List[str]:
|
|
67
|
+
if start not in self._topology_links or target not in self._topology_links:
|
|
68
|
+
return []
|
|
69
|
+
origin_hop: Dict[str, str] = {}
|
|
70
|
+
seen = {start}
|
|
71
|
+
horizon = deque([start])
|
|
72
|
+
reached = False
|
|
73
|
+
while horizon:
|
|
74
|
+
curr = horizon.popleft()
|
|
75
|
+
if curr == target:
|
|
76
|
+
reached = True
|
|
77
|
+
break
|
|
78
|
+
for nxt in self._topology_links.get(curr, []):
|
|
79
|
+
if nxt not in seen:
|
|
80
|
+
seen.add(nxt)
|
|
81
|
+
origin_hop[nxt] = curr
|
|
82
|
+
horizon.append(nxt)
|
|
83
|
+
if not reached:
|
|
84
|
+
return []
|
|
85
|
+
trace_seq = []
|
|
86
|
+
curr = target
|
|
87
|
+
while curr != start:
|
|
88
|
+
trace_seq.append(curr)
|
|
89
|
+
curr = origin_hop[curr]
|
|
90
|
+
trace_seq.append(start)
|
|
91
|
+
trace_seq.reverse()
|
|
92
|
+
return trace_seq
|
|
93
|
+
|
|
94
|
+
def trace_blast_radius_paths(self, start: str, max_hops: int = 4) -> List[List[str]]:
|
|
95
|
+
all_chains: List[List[str]] = []
|
|
96
|
+
if start not in self._topology_links or max_hops <= 0:
|
|
97
|
+
return all_chains
|
|
98
|
+
origin_hop: Dict[str, str] = {}
|
|
99
|
+
hop_depth: Dict[str, int] = {start: 0}
|
|
100
|
+
horizon = deque([start])
|
|
101
|
+
while horizon:
|
|
102
|
+
curr = horizon.popleft()
|
|
103
|
+
d = hop_depth[curr]
|
|
104
|
+
if d >= max_hops:
|
|
105
|
+
continue
|
|
106
|
+
for nxt in self._topology_links.get(curr, []):
|
|
107
|
+
if nxt not in hop_depth:
|
|
108
|
+
hop_depth[nxt] = d + 1
|
|
109
|
+
origin_hop[nxt] = curr
|
|
110
|
+
horizon.append(nxt)
|
|
111
|
+
chain = []
|
|
112
|
+
walk = nxt
|
|
113
|
+
while walk != start:
|
|
114
|
+
chain.append(walk)
|
|
115
|
+
walk = origin_hop[walk]
|
|
116
|
+
chain.append(start)
|
|
117
|
+
chain.reverse()
|
|
118
|
+
all_chains.append(chain)
|
|
119
|
+
return all_chains
|
|
120
|
+
|
|
121
|
+
def detect_circular_dependencies(self, runtime_token: str = "") -> List[List[str]]:
|
|
122
|
+
idx_map: Dict[str, int] = {}
|
|
123
|
+
boundary_map: Dict[str, int] = {}
|
|
124
|
+
active_frame: Dict[str, bool] = {}
|
|
125
|
+
frame_st: List[str] = []
|
|
126
|
+
seq_counter = [0]
|
|
127
|
+
detected_rings: List[List[str]] = []
|
|
128
|
+
|
|
129
|
+
def _eval_recursive_ring(u: str):
|
|
130
|
+
seq_counter[0] += 1
|
|
131
|
+
idx_map[u] = boundary_map[u] = seq_counter[0]
|
|
132
|
+
frame_st.append(u)
|
|
133
|
+
active_frame[u] = True
|
|
134
|
+
|
|
135
|
+
for v in self._topology_links.get(u, []):
|
|
136
|
+
if v not in idx_map:
|
|
137
|
+
_eval_recursive_ring(v)
|
|
138
|
+
boundary_map[u] = min(boundary_map[u], boundary_map[v])
|
|
139
|
+
elif active_frame.get(v, False):
|
|
140
|
+
boundary_map[u] = min(boundary_map[u], idx_map[v])
|
|
141
|
+
|
|
142
|
+
if boundary_map[u] == idx_map[u]:
|
|
143
|
+
ring_group = []
|
|
144
|
+
while True:
|
|
145
|
+
v = frame_st.pop()
|
|
146
|
+
active_frame[v] = False
|
|
147
|
+
ring_group.append(v)
|
|
148
|
+
if u == v:
|
|
149
|
+
break
|
|
150
|
+
if len(ring_group) > 1 or (u in self._topology_links.get(u, [])):
|
|
151
|
+
detected_rings.append(ring_group)
|
|
152
|
+
|
|
153
|
+
for node in list(self._topology_links.keys()):
|
|
154
|
+
if node not in idx_map:
|
|
155
|
+
_eval_recursive_ring(node)
|
|
156
|
+
|
|
157
|
+
return detected_rings
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
try:
|
|
161
|
+
import blasttrace_cpp_core
|
|
162
|
+
CppBlastEngine = blasttrace_cpp_core.CppBlastEngine
|
|
163
|
+
except ImportError:
|
|
164
|
+
CppBlastEngine = _DeterministicReachabilityEngine
|
|
165
|
+
|
|
166
|
+
from fastmcp import FastMCP
|
|
167
|
+
|
|
168
|
+
mcp = FastMCP(name="BlastTrace")
|
|
169
|
+
IS_PRO_TIER = False
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _populate_cpp_engine(topology: Dict[str, Any]) -> Any:
|
|
173
|
+
"""Instantiates and populates the BlastTrace Native Core from parsed full-stack topology."""
|
|
174
|
+
engine = CppBlastEngine()
|
|
175
|
+
for edge in topology.get("edges", []):
|
|
176
|
+
engine.add_edge(edge["source"], edge["target"])
|
|
177
|
+
return engine
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _pro_gate_error(tool_label: str) -> str:
|
|
181
|
+
return json.dumps(
|
|
182
|
+
{
|
|
183
|
+
"error": "Pro License Required",
|
|
184
|
+
"engine": "BlastTrace Native Core",
|
|
185
|
+
"tier": "COMMUNITY",
|
|
186
|
+
"message": f"{tool_label} requires an active BlastTrace Pro license. Run 'blasttrace activate <KEY>' to unlock.",
|
|
187
|
+
},
|
|
188
|
+
indent=2,
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
# ============================================================================
|
|
193
|
+
# FREE COMMUNITY TIER TOOLS (2 Tools)
|
|
194
|
+
# ============================================================================
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
@mcp.tool()
|
|
198
|
+
def index_repository_topology(repo_path: str) -> str:
|
|
199
|
+
"""
|
|
200
|
+
Scans the repository across Database, Backend, API, Frontend, and Tests,
|
|
201
|
+
populates the BlastTrace Native Core, and returns structural summary counts.
|
|
202
|
+
"""
|
|
203
|
+
topology = build_fullstack_topology(repo_path)
|
|
204
|
+
if "error" in topology:
|
|
205
|
+
return json.dumps({"engine": "BlastTrace Native Core", "error": topology["error"]}, indent=2)
|
|
206
|
+
|
|
207
|
+
_populate_cpp_engine(topology)
|
|
208
|
+
layer_counts: Dict[str, int] = {"database": 0, "backend": 0, "api": 0, "frontend": 0, "tests": 0}
|
|
209
|
+
for n in topology["nodes"]:
|
|
210
|
+
layer = n.get("layer", "backend")
|
|
211
|
+
layer_counts[layer] = layer_counts.get(layer, 0) + 1
|
|
212
|
+
|
|
213
|
+
return json.dumps(
|
|
214
|
+
{
|
|
215
|
+
"status": "Indexed",
|
|
216
|
+
"engine": "BlastTrace Native Core",
|
|
217
|
+
"tier": "PRO" if verify_license() else "COMMUNITY",
|
|
218
|
+
"repo_root": topology["repo_root"],
|
|
219
|
+
"node_count": len(topology["nodes"]),
|
|
220
|
+
"edge_count": len(topology["edges"]),
|
|
221
|
+
"layer_breakdown": layer_counts,
|
|
222
|
+
"tables_indexed": list(topology["db_tables"].keys()),
|
|
223
|
+
"api_routes_indexed": [f"{r['method']} {r['path']}" for r in topology["api_routes"]],
|
|
224
|
+
"test_suites_indexed": list(topology["test_suites"].keys()),
|
|
225
|
+
},
|
|
226
|
+
indent=2,
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
@mcp.tool()
|
|
231
|
+
def analyze_symbol_reachability(repo_path: str, target_symbol: str, max_hops: int = 4) -> str:
|
|
232
|
+
"""
|
|
233
|
+
Traces direct and transitive downstream callers/importers of a function, class, or file
|
|
234
|
+
within the repository using the Deterministic Dependency Reachability Engine.
|
|
235
|
+
"""
|
|
236
|
+
topology = build_fullstack_topology(repo_path)
|
|
237
|
+
if "error" in topology:
|
|
238
|
+
return json.dumps({"engine": "BlastTrace Native Core", "error": topology["error"]}, indent=2)
|
|
239
|
+
|
|
240
|
+
engine = _populate_cpp_engine(topology)
|
|
241
|
+
matched_ids = resolve_entity_id(topology, target_symbol)
|
|
242
|
+
|
|
243
|
+
all_chains: List[List[str]] = []
|
|
244
|
+
impacted_symbols: List[str] = []
|
|
245
|
+
for start_id in matched_ids:
|
|
246
|
+
chains = engine.trace_blast_radius_paths(start_id, max_hops)
|
|
247
|
+
for ch in chains:
|
|
248
|
+
all_chains.append(ch)
|
|
249
|
+
for nid in ch[1:]:
|
|
250
|
+
if nid not in impacted_symbols:
|
|
251
|
+
impacted_symbols.append(nid)
|
|
252
|
+
|
|
253
|
+
return json.dumps(
|
|
254
|
+
{
|
|
255
|
+
"status": "Reachability Evaluated",
|
|
256
|
+
"engine": "BlastTrace Native Core",
|
|
257
|
+
"tier": "PRO" if verify_license() else "COMMUNITY",
|
|
258
|
+
"target_symbol": target_symbol,
|
|
259
|
+
"resolved_node_ids": matched_ids,
|
|
260
|
+
"max_hops": max_hops,
|
|
261
|
+
"impacted_count": len(impacted_symbols),
|
|
262
|
+
"impacted_nodes": impacted_symbols,
|
|
263
|
+
"reachability_chains": format_evidence_chains(topology, all_chains),
|
|
264
|
+
},
|
|
265
|
+
indent=2,
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
# ============================================================================
|
|
270
|
+
# PRO TIER TOOLS (8 Tools — Gated by verify_license() + Native C++ Seal)
|
|
271
|
+
# ============================================================================
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
@mcp.tool()
|
|
275
|
+
def verify_pre_edit_safety_gate(
|
|
276
|
+
repo_path: str,
|
|
277
|
+
file_path: str,
|
|
278
|
+
symbol_name: str,
|
|
279
|
+
proposed_signature_or_diff: str,
|
|
280
|
+
) -> str:
|
|
281
|
+
"""
|
|
282
|
+
[PRO TIER] Flagship Pre-Edit Breaking-Change Gate: Compares existing symbol/schema/route
|
|
283
|
+
contract against the proposed edit, classifies severity (PASS / WARN / BLOCK), traces
|
|
284
|
+
all downstream consumers across DB/Backend/API/Frontend/Tests, and returns a Mermaid
|
|
285
|
+
blast-radius diagram and surgical multi-file synchronization plan.
|
|
286
|
+
"""
|
|
287
|
+
if not verify_license():
|
|
288
|
+
return _pro_gate_error("Pre-Edit Breaking-Change Safety Gate")
|
|
289
|
+
|
|
290
|
+
topology = build_fullstack_topology(repo_path)
|
|
291
|
+
if "error" in topology:
|
|
292
|
+
return json.dumps({"engine": "BlastTrace Native Core", "error": topology["error"]}, indent=2)
|
|
293
|
+
|
|
294
|
+
engine = _populate_cpp_engine(topology)
|
|
295
|
+
matched_ids = resolve_entity_id(topology, symbol_name)
|
|
296
|
+
if not matched_ids and file_path:
|
|
297
|
+
matched_ids = resolve_entity_id(topology, f"{os.path.basename(file_path)}:{symbol_name}")
|
|
298
|
+
|
|
299
|
+
downstream_chains: List[List[str]] = []
|
|
300
|
+
for start_id in matched_ids:
|
|
301
|
+
chains = engine.trace_blast_radius_paths(start_id, 6)
|
|
302
|
+
downstream_chains.extend(chains)
|
|
303
|
+
|
|
304
|
+
classification_res = classify_proposed_edit(
|
|
305
|
+
topology,
|
|
306
|
+
file_path,
|
|
307
|
+
symbol_name,
|
|
308
|
+
proposed_signature_or_diff,
|
|
309
|
+
downstream_chains,
|
|
310
|
+
)
|
|
311
|
+
gate_status = classification_res["gate_status"]
|
|
312
|
+
sync_plan = generate_surgical_sync_plan(topology, classification_res, downstream_chains)
|
|
313
|
+
mermaid_diag = generate_mermaid_blast_radius(
|
|
314
|
+
downstream_chains,
|
|
315
|
+
title=f"Pre-Edit Blast Radius: {symbol_name}",
|
|
316
|
+
gate_status=gate_status,
|
|
317
|
+
topology=topology,
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
return json.dumps(
|
|
321
|
+
{
|
|
322
|
+
"engine": "BlastTrace Native Core",
|
|
323
|
+
"tier": "PRO",
|
|
324
|
+
"gate_status": gate_status,
|
|
325
|
+
"classification": classification_res["classification"],
|
|
326
|
+
"target_file": classification_res["target_file"],
|
|
327
|
+
"target_symbol": symbol_name,
|
|
328
|
+
"reasons": classification_res["reasons"],
|
|
329
|
+
"evidence_chains": classification_res["evidence_chains"],
|
|
330
|
+
"mermaid_diagram": mermaid_diag,
|
|
331
|
+
"surgical_sync_plan": sync_plan,
|
|
332
|
+
},
|
|
333
|
+
indent=2,
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
@mcp.tool()
|
|
338
|
+
def trace_fullstack_contract_chain(repo_path: str, start_entity: str, target_entity: str = "") -> str:
|
|
339
|
+
"""
|
|
340
|
+
[PRO TIER] Traces end-to-end contract paths from a Database Column/Table -> ORM Model ->
|
|
341
|
+
Backend Function -> API Route -> Frontend UI Component -> Covering Test Suite.
|
|
342
|
+
"""
|
|
343
|
+
if not verify_license():
|
|
344
|
+
return _pro_gate_error("Full-Stack Cross-Boundary Contract Tracing")
|
|
345
|
+
|
|
346
|
+
topology = build_fullstack_topology(repo_path)
|
|
347
|
+
if "error" in topology:
|
|
348
|
+
return json.dumps({"engine": "BlastTrace Native Core", "error": topology["error"]}, indent=2)
|
|
349
|
+
|
|
350
|
+
engine = _populate_cpp_engine(topology)
|
|
351
|
+
start_ids = resolve_entity_id(topology, start_entity)
|
|
352
|
+
target_ids = resolve_entity_id(topology, target_entity) if target_entity else []
|
|
353
|
+
|
|
354
|
+
chosen_path: List[str] = []
|
|
355
|
+
all_chains: List[List[str]] = []
|
|
356
|
+
|
|
357
|
+
if start_ids and target_ids:
|
|
358
|
+
for s_id in start_ids:
|
|
359
|
+
for t_id in target_ids:
|
|
360
|
+
path = engine.find_shortest_path(s_id, t_id)
|
|
361
|
+
if path:
|
|
362
|
+
all_chains.append(path)
|
|
363
|
+
if not chosen_path or len(path) > len(chosen_path):
|
|
364
|
+
chosen_path = path
|
|
365
|
+
elif start_ids:
|
|
366
|
+
for s_id in start_ids:
|
|
367
|
+
chains = engine.trace_blast_radius_paths(s_id, 6)
|
|
368
|
+
for ch in chains:
|
|
369
|
+
all_chains.append(ch)
|
|
370
|
+
if len(ch) > len(chosen_path):
|
|
371
|
+
chosen_path = ch
|
|
372
|
+
|
|
373
|
+
evidence = format_evidence_chains(topology, [chosen_path] if chosen_path else all_chains)
|
|
374
|
+
return json.dumps(
|
|
375
|
+
{
|
|
376
|
+
"status": "Contract Chain Identified" if (chosen_path or all_chains) else "No Contract Link Found",
|
|
377
|
+
"engine": "BlastTrace Native Core",
|
|
378
|
+
"tier": "PRO",
|
|
379
|
+
"start_entity": start_entity,
|
|
380
|
+
"target_entity": target_entity or "all_downstream_consumers",
|
|
381
|
+
"path_sequence": chosen_path,
|
|
382
|
+
"hop_count": max(0, len(chosen_path) - 1),
|
|
383
|
+
"evidence_chains": evidence,
|
|
384
|
+
"mermaid_diagram": generate_mermaid_contract_chain(
|
|
385
|
+
chosen_path if chosen_path else (all_chains[0] if all_chains else []),
|
|
386
|
+
title=f"Full-Stack Contract Chain: {start_entity}",
|
|
387
|
+
topology=topology,
|
|
388
|
+
),
|
|
389
|
+
},
|
|
390
|
+
indent=2,
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
@mcp.tool()
|
|
395
|
+
def analyze_db_schema_blast_radius(repo_path: str, schema_or_migration_file: str, table_or_column: str) -> str:
|
|
396
|
+
"""
|
|
397
|
+
[PRO TIER] Parses SQL/Prisma/ORM changes and identifies every backend query, ORM model,
|
|
398
|
+
API endpoint, frontend UI component, and test suite that will break if the column or table
|
|
399
|
+
is modified or dropped.
|
|
400
|
+
"""
|
|
401
|
+
if not verify_license():
|
|
402
|
+
return _pro_gate_error("Database Schema & Migration Blast-Radius Analyzer")
|
|
403
|
+
|
|
404
|
+
topology = build_fullstack_topology(repo_path)
|
|
405
|
+
if "error" in topology:
|
|
406
|
+
return json.dumps({"engine": "BlastTrace Native Core", "error": topology["error"]}, indent=2)
|
|
407
|
+
|
|
408
|
+
engine = _populate_cpp_engine(topology)
|
|
409
|
+
matched_ids = resolve_entity_id(topology, f"db:{table_or_column.lower()}")
|
|
410
|
+
if not matched_ids:
|
|
411
|
+
matched_ids = resolve_entity_id(topology, table_or_column)
|
|
412
|
+
|
|
413
|
+
chains: List[List[str]] = []
|
|
414
|
+
for sid in matched_ids:
|
|
415
|
+
chains.extend(engine.trace_blast_radius_paths(sid, 6))
|
|
416
|
+
|
|
417
|
+
impacted_by_layer: Dict[str, List[str]] = {
|
|
418
|
+
"backend_orm_and_functions": [],
|
|
419
|
+
"api_endpoints": [],
|
|
420
|
+
"frontend_components": [],
|
|
421
|
+
"tests": [],
|
|
422
|
+
}
|
|
423
|
+
nodes_by_id = topology["nodes_by_id"]
|
|
424
|
+
for ch in chains:
|
|
425
|
+
for nid in ch[1:]:
|
|
426
|
+
nmeta = nodes_by_id.get(nid, {})
|
|
427
|
+
layer = nmeta.get("layer", "")
|
|
428
|
+
label = f"{nmeta.get('file', '')}:{nmeta.get('name', nid)}" if nmeta.get("file") else nid
|
|
429
|
+
if layer == "backend" and label not in impacted_by_layer["backend_orm_and_functions"]:
|
|
430
|
+
impacted_by_layer["backend_orm_and_functions"].append(label)
|
|
431
|
+
elif layer == "api" and label not in impacted_by_layer["api_endpoints"]:
|
|
432
|
+
impacted_by_layer["api_endpoints"].append(label)
|
|
433
|
+
elif layer == "frontend" and label not in impacted_by_layer["frontend_components"]:
|
|
434
|
+
impacted_by_layer["frontend_components"].append(label)
|
|
435
|
+
elif layer == "tests" and label not in impacted_by_layer["tests"]:
|
|
436
|
+
impacted_by_layer["tests"].append(label)
|
|
437
|
+
|
|
438
|
+
cls_res = classify_proposed_edit(
|
|
439
|
+
topology,
|
|
440
|
+
schema_or_migration_file,
|
|
441
|
+
table_or_column,
|
|
442
|
+
f"DROP COLUMN {table_or_column}",
|
|
443
|
+
chains,
|
|
444
|
+
)
|
|
445
|
+
sync_plan = generate_surgical_sync_plan(topology, cls_res, chains)
|
|
446
|
+
|
|
447
|
+
return json.dumps(
|
|
448
|
+
{
|
|
449
|
+
"status": "Schema Blast Radius Computed",
|
|
450
|
+
"engine": "BlastTrace Native Core",
|
|
451
|
+
"tier": "PRO",
|
|
452
|
+
"schema_file": schema_or_migration_file,
|
|
453
|
+
"target_table_or_column": table_or_column,
|
|
454
|
+
"gate_status": cls_res["gate_status"],
|
|
455
|
+
"classification": cls_res["classification"],
|
|
456
|
+
"impacted_layers": impacted_by_layer,
|
|
457
|
+
"evidence_chains": format_evidence_chains(topology, chains),
|
|
458
|
+
"mermaid_diagram": generate_mermaid_blast_radius(
|
|
459
|
+
chains,
|
|
460
|
+
title=f"DB Schema Blast Radius: {table_or_column}",
|
|
461
|
+
gate_status=cls_res["gate_status"],
|
|
462
|
+
topology=topology,
|
|
463
|
+
),
|
|
464
|
+
"surgical_sync_plan": sync_plan,
|
|
465
|
+
},
|
|
466
|
+
indent=2,
|
|
467
|
+
)
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
@mcp.tool()
|
|
471
|
+
def analyze_api_contract_impact(repo_path: str, route_path_or_file: str, changed_field: str = "") -> str:
|
|
472
|
+
"""
|
|
473
|
+
[PRO TIER] Traces an API endpoint (/api/v1/...) to all backend handlers and all frontend
|
|
474
|
+
fetch/axios/useQuery consumers, flagging broken payload fields.
|
|
475
|
+
"""
|
|
476
|
+
if not verify_license():
|
|
477
|
+
return _pro_gate_error("API Contract Breaking-Change Scanner")
|
|
478
|
+
|
|
479
|
+
topology = build_fullstack_topology(repo_path)
|
|
480
|
+
if "error" in topology:
|
|
481
|
+
return json.dumps({"engine": "BlastTrace Native Core", "error": topology["error"]}, indent=2)
|
|
482
|
+
|
|
483
|
+
engine = _populate_cpp_engine(topology)
|
|
484
|
+
query_ids = resolve_entity_id(topology, route_path_or_file)
|
|
485
|
+
if changed_field:
|
|
486
|
+
field_ids = resolve_entity_id(topology, f"{route_path_or_file}.{changed_field}")
|
|
487
|
+
if field_ids:
|
|
488
|
+
query_ids = field_ids + [qid for qid in query_ids if qid not in field_ids]
|
|
489
|
+
|
|
490
|
+
chains: List[List[str]] = []
|
|
491
|
+
for qid in query_ids:
|
|
492
|
+
chains.extend(engine.trace_blast_radius_paths(qid, 5))
|
|
493
|
+
|
|
494
|
+
frontend_consumers: List[str] = []
|
|
495
|
+
covering_tests: List[str] = []
|
|
496
|
+
nodes_by_id = topology["nodes_by_id"]
|
|
497
|
+
for ch in chains:
|
|
498
|
+
for nid in ch[1:]:
|
|
499
|
+
nmeta = nodes_by_id.get(nid, {})
|
|
500
|
+
if nmeta.get("layer") == "frontend":
|
|
501
|
+
entry = f"{nmeta.get('file', '')} ({nmeta.get('name', nid)})"
|
|
502
|
+
if entry not in frontend_consumers:
|
|
503
|
+
frontend_consumers.append(entry)
|
|
504
|
+
elif nmeta.get("layer") == "tests":
|
|
505
|
+
entry = f"{nmeta.get('file', '')} ({nmeta.get('name', nid)})"
|
|
506
|
+
if entry not in covering_tests:
|
|
507
|
+
covering_tests.append(entry)
|
|
508
|
+
|
|
509
|
+
cls_res = classify_proposed_edit(
|
|
510
|
+
topology,
|
|
511
|
+
route_path_or_file,
|
|
512
|
+
route_path_or_file,
|
|
513
|
+
f"removed {changed_field}" if changed_field else "breaking route modification",
|
|
514
|
+
chains,
|
|
515
|
+
)
|
|
516
|
+
|
|
517
|
+
return json.dumps(
|
|
518
|
+
{
|
|
519
|
+
"status": "API Contract Impact Evaluated",
|
|
520
|
+
"engine": "BlastTrace Native Core",
|
|
521
|
+
"tier": "PRO",
|
|
522
|
+
"route_or_file": route_path_or_file,
|
|
523
|
+
"changed_field": changed_field,
|
|
524
|
+
"gate_status": cls_res["gate_status"],
|
|
525
|
+
"classification": "BREAKING_API_CONTRACT" if chains else cls_res["classification"],
|
|
526
|
+
"frontend_consumers": frontend_consumers,
|
|
527
|
+
"covering_tests": covering_tests,
|
|
528
|
+
"evidence_chains": format_evidence_chains(topology, chains),
|
|
529
|
+
"mermaid_diagram": generate_mermaid_blast_radius(
|
|
530
|
+
chains,
|
|
531
|
+
title=f"API Contract Impact: {route_path_or_file}" + (f" [{changed_field}]" if changed_field else ""),
|
|
532
|
+
gate_status=cls_res["gate_status"],
|
|
533
|
+
topology=topology,
|
|
534
|
+
),
|
|
535
|
+
"surgical_sync_plan": generate_surgical_sync_plan(topology, cls_res, chains),
|
|
536
|
+
},
|
|
537
|
+
indent=2,
|
|
538
|
+
)
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
@mcp.tool()
|
|
542
|
+
def analyze_python_module_impact(repo_path: str, file_path: str, target_symbol: str) -> str:
|
|
543
|
+
"""
|
|
544
|
+
[PRO TIER] Deep Python AST signature, type-hint, decorator, and cross-module call-chain
|
|
545
|
+
impact analysis.
|
|
546
|
+
"""
|
|
547
|
+
if not verify_license():
|
|
548
|
+
return _pro_gate_error("Python AST Module & Signature Impact Analyzer")
|
|
549
|
+
|
|
550
|
+
topology = build_fullstack_topology(repo_path)
|
|
551
|
+
if "error" in topology:
|
|
552
|
+
return json.dumps({"engine": "BlastTrace Native Core", "error": topology["error"]}, indent=2)
|
|
553
|
+
|
|
554
|
+
abs_target = file_path if os.path.isabs(file_path) else os.path.join(topology["repo_root"], file_path)
|
|
555
|
+
parsed_py = parse_python_file(abs_target, topology["repo_root"])
|
|
556
|
+
|
|
557
|
+
engine = _populate_cpp_engine(topology)
|
|
558
|
+
matched_ids = resolve_entity_id(topology, target_symbol)
|
|
559
|
+
chains: List[List[str]] = []
|
|
560
|
+
for mid in matched_ids:
|
|
561
|
+
chains.extend(engine.trace_blast_radius_paths(mid, 5))
|
|
562
|
+
|
|
563
|
+
sym_details = parsed_py.get("functions", {}).get(target_symbol) or parsed_py.get("classes", {}).get(target_symbol)
|
|
564
|
+
|
|
565
|
+
return json.dumps(
|
|
566
|
+
{
|
|
567
|
+
"status": "Python AST Impact Analyzed",
|
|
568
|
+
"engine": "BlastTrace Native Core",
|
|
569
|
+
"tier": "PRO",
|
|
570
|
+
"file": parsed_py.get("file", file_path),
|
|
571
|
+
"target_symbol": target_symbol,
|
|
572
|
+
"symbol_metadata": sym_details,
|
|
573
|
+
"downstream_chains": format_evidence_chains(topology, chains),
|
|
574
|
+
"mermaid_diagram": generate_mermaid_blast_radius(
|
|
575
|
+
chains,
|
|
576
|
+
title=f"Python Symbol Impact: {target_symbol}",
|
|
577
|
+
gate_status="WARN" if chains else "PASS",
|
|
578
|
+
topology=topology,
|
|
579
|
+
),
|
|
580
|
+
},
|
|
581
|
+
indent=2,
|
|
582
|
+
)
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
@mcp.tool()
|
|
586
|
+
def analyze_typescript_frontend_impact(repo_path: str, file_path: str, target_symbol: str) -> str:
|
|
587
|
+
"""
|
|
588
|
+
[PRO TIER] Deep TypeScript/React component prop, interface, hook, and API consumer
|
|
589
|
+
blast-radius analysis.
|
|
590
|
+
"""
|
|
591
|
+
if not verify_license():
|
|
592
|
+
return _pro_gate_error("TypeScript & React Frontend Impact Analyzer")
|
|
593
|
+
|
|
594
|
+
topology = build_fullstack_topology(repo_path)
|
|
595
|
+
if "error" in topology:
|
|
596
|
+
return json.dumps({"engine": "BlastTrace Native Core", "error": topology["error"]}, indent=2)
|
|
597
|
+
|
|
598
|
+
abs_target = file_path if os.path.isabs(file_path) else os.path.join(topology["repo_root"], file_path)
|
|
599
|
+
parsed_fe = parse_ts_react_file(abs_target, topology["repo_root"])
|
|
600
|
+
|
|
601
|
+
engine = _populate_cpp_engine(topology)
|
|
602
|
+
matched_ids = resolve_entity_id(topology, target_symbol)
|
|
603
|
+
chains: List[List[str]] = []
|
|
604
|
+
for mid in matched_ids:
|
|
605
|
+
chains.extend(engine.trace_blast_radius_paths(mid, 5))
|
|
606
|
+
|
|
607
|
+
sym_details = parsed_fe.get("symbols", {}).get(target_symbol) or parsed_fe.get("interfaces", {}).get(target_symbol)
|
|
608
|
+
|
|
609
|
+
return json.dumps(
|
|
610
|
+
{
|
|
611
|
+
"status": "Frontend Component Impact Analyzed",
|
|
612
|
+
"engine": "BlastTrace Native Core",
|
|
613
|
+
"tier": "PRO",
|
|
614
|
+
"file": parsed_fe.get("file", file_path),
|
|
615
|
+
"target_symbol": target_symbol,
|
|
616
|
+
"component_or_interface": sym_details,
|
|
617
|
+
"api_dependencies": parsed_fe.get("api_calls", []),
|
|
618
|
+
"accessed_response_fields": parsed_fe.get("property_accesses", []),
|
|
619
|
+
"downstream_chains": format_evidence_chains(topology, chains),
|
|
620
|
+
"mermaid_diagram": generate_mermaid_blast_radius(
|
|
621
|
+
chains,
|
|
622
|
+
title=f"Frontend Blast Radius: {target_symbol}",
|
|
623
|
+
gate_status="WARN" if chains else "PASS",
|
|
624
|
+
topology=topology,
|
|
625
|
+
),
|
|
626
|
+
},
|
|
627
|
+
indent=2,
|
|
628
|
+
)
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
@mcp.tool()
|
|
632
|
+
def select_impacted_tests(repo_path: str, changed_files_or_symbols: List[str]) -> str:
|
|
633
|
+
"""
|
|
634
|
+
[PRO TIER] Deterministically selects the exact unit, integration, and E2E test files
|
|
635
|
+
and test cases that cover the modified symbols, schema columns, or API endpoints.
|
|
636
|
+
"""
|
|
637
|
+
if not verify_license():
|
|
638
|
+
return _pro_gate_error("Change-Aware Test Selector")
|
|
639
|
+
|
|
640
|
+
topology = build_fullstack_topology(repo_path)
|
|
641
|
+
if "error" in topology:
|
|
642
|
+
return json.dumps({"engine": "BlastTrace Native Core", "error": topology["error"]}, indent=2)
|
|
643
|
+
|
|
644
|
+
engine = _populate_cpp_engine(topology)
|
|
645
|
+
nodes_by_id = topology["nodes_by_id"]
|
|
646
|
+
|
|
647
|
+
selected_test_files: List[str] = []
|
|
648
|
+
selected_test_cases: List[str] = []
|
|
649
|
+
evidence_chains: List[List[str]] = []
|
|
650
|
+
|
|
651
|
+
for item in changed_files_or_symbols:
|
|
652
|
+
matched_ids = resolve_entity_id(topology, item)
|
|
653
|
+
for mid in matched_ids:
|
|
654
|
+
chains = engine.trace_blast_radius_paths(mid, 6)
|
|
655
|
+
for ch in chains:
|
|
656
|
+
for nid in ch:
|
|
657
|
+
nmeta = nodes_by_id.get(nid, {})
|
|
658
|
+
if nmeta.get("layer") == "tests":
|
|
659
|
+
evidence_chains.append(ch)
|
|
660
|
+
tfile = nmeta.get("file", "")
|
|
661
|
+
if tfile and tfile not in selected_test_files:
|
|
662
|
+
selected_test_files.append(tfile)
|
|
663
|
+
if nmeta.get("type") == "test_case":
|
|
664
|
+
tc_name = nmeta.get("name", nid)
|
|
665
|
+
if tc_name not in selected_test_cases:
|
|
666
|
+
selected_test_cases.append(tc_name)
|
|
667
|
+
|
|
668
|
+
return json.dumps(
|
|
669
|
+
{
|
|
670
|
+
"status": "Impacted Tests Selected",
|
|
671
|
+
"engine": "BlastTrace Native Core",
|
|
672
|
+
"tier": "PRO",
|
|
673
|
+
"changed_inputs": changed_files_or_symbols,
|
|
674
|
+
"selected_test_files": selected_test_files,
|
|
675
|
+
"selected_test_cases": selected_test_cases,
|
|
676
|
+
"pytest_command": f"pytest {' '.join(selected_test_files)}" if selected_test_files else "pytest",
|
|
677
|
+
"evidence_chains": format_evidence_chains(topology, evidence_chains),
|
|
678
|
+
},
|
|
679
|
+
indent=2,
|
|
680
|
+
)
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
@mcp.tool()
|
|
684
|
+
def detect_circular_code_dependencies(repo_path: str) -> str:
|
|
685
|
+
"""
|
|
686
|
+
[PRO TIER] Uses the BlastTrace Native Core Recursive Loop Analysis engine to detect
|
|
687
|
+
circular import and call loops across modules/packages, outputting a Mermaid cycle graph
|
|
688
|
+
and surgical refactoring patch.
|
|
689
|
+
"""
|
|
690
|
+
if not verify_license():
|
|
691
|
+
return _pro_gate_error("Circular Dependency Interceptor")
|
|
692
|
+
|
|
693
|
+
topology = build_fullstack_topology(repo_path)
|
|
694
|
+
if "error" in topology:
|
|
695
|
+
return json.dumps({"engine": "BlastTrace Native Core", "error": topology["error"]}, indent=2)
|
|
696
|
+
|
|
697
|
+
engine = CppBlastEngine()
|
|
698
|
+
for mod_edge in topology.get("module_import_edges", []):
|
|
699
|
+
engine.add_edge(mod_edge["source"], mod_edge["target"])
|
|
700
|
+
|
|
701
|
+
runtime_token = get_runtime_token()
|
|
702
|
+
cycles = engine.detect_circular_dependencies(runtime_token)
|
|
703
|
+
|
|
704
|
+
return json.dumps(
|
|
705
|
+
{
|
|
706
|
+
"status": "Circular Dependencies Detected" if cycles else "Clean",
|
|
707
|
+
"engine": "BlastTrace Native Core",
|
|
708
|
+
"tier": "PRO",
|
|
709
|
+
"cycles_detected": len(cycles),
|
|
710
|
+
"dependency_loops": cycles,
|
|
711
|
+
"mermaid_diagram": generate_mermaid_cycles(cycles),
|
|
712
|
+
"surgical_refactoring": generate_cycle_refactoring_plan(cycles),
|
|
713
|
+
},
|
|
714
|
+
indent=2,
|
|
715
|
+
)
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
# ============================================================================
|
|
719
|
+
# STANDALONE TERMINAL CLI & GIT PRE-COMMIT GATE
|
|
720
|
+
# ============================================================================
|
|
721
|
+
|
|
722
|
+
|
|
723
|
+
def _run_git_diff_gate(repo_path: str, is_pre_commit: bool = False) -> int:
|
|
724
|
+
"""Inspects modified files in git working tree / staged index and evaluates blast radius."""
|
|
725
|
+
cmd = ["git", "diff", "--cached", "--name-only"] if is_pre_commit else ["git", "diff", "--name-only"]
|
|
726
|
+
try:
|
|
727
|
+
out = subprocess.check_output(cmd, cwd=repo_path, stderr=subprocess.DEVNULL, text=True)
|
|
728
|
+
changed_files = [line.strip() for line in out.splitlines() if line.strip()]
|
|
729
|
+
except Exception:
|
|
730
|
+
changed_files = []
|
|
731
|
+
|
|
732
|
+
topology = build_fullstack_topology(repo_path)
|
|
733
|
+
if "error" in topology:
|
|
734
|
+
print(json.dumps({"engine": "BlastTrace Native Core", "error": topology["error"]}, indent=2))
|
|
735
|
+
return 1
|
|
736
|
+
|
|
737
|
+
engine = _populate_cpp_engine(topology)
|
|
738
|
+
impacts: List[str] = []
|
|
739
|
+
for fpath in changed_files:
|
|
740
|
+
for mid in resolve_entity_id(topology, fpath):
|
|
741
|
+
chains = engine.trace_blast_radius_paths(mid, 5)
|
|
742
|
+
impacts.extend(format_evidence_chains(topology, chains))
|
|
743
|
+
|
|
744
|
+
gate = "BLOCK" if impacts else "PASS"
|
|
745
|
+
report = {
|
|
746
|
+
"engine": "BlastTrace Native Core",
|
|
747
|
+
"mode": "pre-commit" if is_pre_commit else "diff",
|
|
748
|
+
"gate_status": gate,
|
|
749
|
+
"changed_files": changed_files,
|
|
750
|
+
"downstream_evidence_chains": list(dict.fromkeys(impacts)),
|
|
751
|
+
}
|
|
752
|
+
print(json.dumps(report, indent=2))
|
|
753
|
+
return 1 if (gate == "BLOCK" and is_pre_commit) else 0
|
|
754
|
+
|
|
755
|
+
|
|
756
|
+
def main() -> None:
|
|
757
|
+
global IS_PRO_TIER
|
|
758
|
+
if len(sys.argv) > 1:
|
|
759
|
+
cmd = sys.argv[1].lower()
|
|
760
|
+
if cmd == "activate":
|
|
761
|
+
if len(sys.argv) < 3:
|
|
762
|
+
print("Usage: blasttrace activate <LICENSE_KEY>")
|
|
763
|
+
sys.exit(1)
|
|
764
|
+
key = sys.argv[2]
|
|
765
|
+
success = activate_license(key)
|
|
766
|
+
sys.exit(0 if success else 1)
|
|
767
|
+
elif cmd == "deactivate":
|
|
768
|
+
success = deactivate_license()
|
|
769
|
+
sys.exit(0 if success else 1)
|
|
770
|
+
elif cmd == "status":
|
|
771
|
+
status_info = get_license_status()
|
|
772
|
+
print(json.dumps(status_info, indent=2))
|
|
773
|
+
sys.exit(0)
|
|
774
|
+
elif cmd == "check":
|
|
775
|
+
if len(sys.argv) < 3:
|
|
776
|
+
print("Usage: blasttrace check <file_or_symbol> [repo_path]")
|
|
777
|
+
sys.exit(1)
|
|
778
|
+
target = sys.argv[2]
|
|
779
|
+
repo = sys.argv[3] if len(sys.argv) > 3 else os.getcwd()
|
|
780
|
+
print(analyze_symbol_reachability(repo, target))
|
|
781
|
+
sys.exit(0)
|
|
782
|
+
elif cmd == "diff":
|
|
783
|
+
repo = sys.argv[2] if len(sys.argv) > 2 else os.getcwd()
|
|
784
|
+
sys.exit(_run_git_diff_gate(repo, is_pre_commit=False))
|
|
785
|
+
elif cmd == "pre-commit":
|
|
786
|
+
if len(sys.argv) > 2 and sys.argv[2] == "--install":
|
|
787
|
+
repo = sys.argv[3] if len(sys.argv) > 3 else os.getcwd()
|
|
788
|
+
hook_dir = os.path.join(repo, ".git", "hooks")
|
|
789
|
+
os.makedirs(hook_dir, exist_ok=True)
|
|
790
|
+
hook_path = os.path.join(hook_dir, "pre-commit")
|
|
791
|
+
with open(hook_path, "w") as f:
|
|
792
|
+
f.write("#!/bin/sh\n# BlastTrace Pre-Commit Breaking-Change Gate\nblasttrace pre-commit\n")
|
|
793
|
+
try:
|
|
794
|
+
os.chmod(hook_path, 0o755)
|
|
795
|
+
except Exception:
|
|
796
|
+
pass
|
|
797
|
+
print(f"[+] Success: Hook installed successfully at {hook_path}")
|
|
798
|
+
sys.exit(0)
|
|
799
|
+
repo = sys.argv[2] if len(sys.argv) > 2 else os.getcwd()
|
|
800
|
+
sys.exit(_run_git_diff_gate(repo, is_pre_commit=True))
|
|
801
|
+
elif cmd in ("-h", "--help", "help"):
|
|
802
|
+
print("BlastTrace CLI - Pre-Edit Full-Stack Breaking-Change & Contract Blast-Radius Engine")
|
|
803
|
+
print("Commands:")
|
|
804
|
+
print(" blasttrace check <symbol> [repo] Check full-stack blast radius of a file or symbol")
|
|
805
|
+
print(" blasttrace diff [repo] Analyze blast radius of uncommitted git changes")
|
|
806
|
+
print(" blasttrace pre-commit [repo] Run pre-commit breaking-change gate on staged changes")
|
|
807
|
+
print(" blasttrace activate <KEY> Activate Pro license on this machine")
|
|
808
|
+
print(" blasttrace deactivate Deactivate Pro license and release hardware seat")
|
|
809
|
+
print(" blasttrace status Show current license and machine status")
|
|
810
|
+
print(" blasttrace Start FastMCP stdio server")
|
|
811
|
+
sys.exit(0)
|
|
812
|
+
|
|
813
|
+
IS_PRO_TIER = verify_license()
|
|
814
|
+
if IS_PRO_TIER:
|
|
815
|
+
print("[*] BlastTrace running in PRO Tier (BlastTrace Native Core Unlocked)", file=sys.stderr)
|
|
816
|
+
else:
|
|
817
|
+
print("[*] BlastTrace running in COMMUNITY Tier (Free Edition)", file=sys.stderr)
|
|
818
|
+
|
|
819
|
+
mcp.run()
|
|
820
|
+
|
|
821
|
+
|
|
822
|
+
if __name__ == "__main__":
|
|
823
|
+
main()
|
|
824
|
+
|