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 ADDED
@@ -0,0 +1,7 @@
1
+ """
2
+ BlastTrace — Pre-Edit Full-Stack Breaking-Change & Contract Blast-Radius Engine.
3
+ """
4
+
5
+ __version__ = "1.0.2"
6
+ __engine__ = "BlastTrace Native Core"
7
+
@@ -0,0 +1,415 @@
1
+ """
2
+ BlastTrace Evidence-Based Breaking-Change Classifier
3
+ Evaluates proposed code, schema, and API contract edits for the Pre-Edit Breaking-Change Gate.
4
+ """
5
+
6
+ import ast
7
+ import os
8
+ import re
9
+ from typing import Dict, Any, List, Optional
10
+
11
+ from blasttrace.parsers.python_ast_parser import _extract_function_signature
12
+ from blasttrace.parsers.ts_react_parser import _parse_ts_params
13
+
14
+
15
+ def classify_proposed_edit(
16
+ topology: Dict[str, Any],
17
+ file_path: str,
18
+ symbol_name: str,
19
+ proposed_signature_or_diff: str,
20
+ downstream_chains: List[List[str]],
21
+ ) -> Dict[str, Any]:
22
+ """
23
+ Compares the existing symbol, database column, or API route contract against
24
+ the proposed signature or diff, returning the breaking classification, gate status
25
+ (PASS / WARN / BLOCK), detailed reasons, and formatted hop-by-hop evidence chains.
26
+ """
27
+ rel_file = _normalize_rel_path(file_path, topology.get("repo_root", ""))
28
+ lower_file = rel_file.lower()
29
+ prop_text = proposed_signature_or_diff.strip()
30
+ has_consumers = len(downstream_chains) > 0
31
+
32
+ # 1. Database Schema / Migration Classification
33
+ if lower_file.endswith((".sql", ".prisma")) or symbol_name.lower().startswith("db:") or "." in symbol_name and symbol_name.split(".")[0].lower() in topology.get("db_tables", {}):
34
+ return _classify_db_edit(topology, rel_file, symbol_name, prop_text, downstream_chains)
35
+
36
+ # 2. API Contract Classification
37
+ if symbol_name.upper().startswith(("GET ", "POST ", "PUT ", "DELETE ", "PATCH ", "API:")) or symbol_name.startswith("/api"):
38
+ return _classify_api_edit(topology, rel_file, symbol_name, prop_text, downstream_chains)
39
+
40
+ # 3. Python Function / Method / Class / ORM Classification
41
+ if lower_file.endswith(".py"):
42
+ # Check if proposed edit modifies an API decorator or response payload field
43
+ if any(tok in prop_text for tok in ("@app.", "@router.", "route(", "response_model")):
44
+ return _classify_api_edit(topology, rel_file, symbol_name, prop_text, downstream_chains)
45
+ return _classify_python_signature_edit(topology, rel_file, symbol_name, prop_text, downstream_chains)
46
+
47
+ # 4. TypeScript / React Component / Function Classification
48
+ if lower_file.endswith((".ts", ".tsx", ".js", ".jsx")):
49
+ return _classify_ts_signature_edit(topology, rel_file, symbol_name, prop_text, downstream_chains)
50
+
51
+ # Default fallback inspection
52
+ return _classify_generic_edit(topology, rel_file, symbol_name, prop_text, downstream_chains)
53
+
54
+
55
+ def _normalize_rel_path(file_path: str, repo_root: str) -> str:
56
+ if not file_path:
57
+ return ""
58
+ norm = file_path.replace("\\", "/")
59
+ if repo_root and os.path.isabs(file_path):
60
+ try:
61
+ return os.path.relpath(file_path, repo_root).replace("\\", "/")
62
+ except ValueError:
63
+ return norm
64
+ return norm
65
+
66
+
67
+ def _classify_db_edit(
68
+ topology: Dict[str, Any],
69
+ rel_file: str,
70
+ symbol_name: str,
71
+ prop_text: str,
72
+ downstream_chains: List[List[str]],
73
+ ) -> Dict[str, Any]:
74
+ upper_prop = prop_text.upper()
75
+ reasons: List[str] = []
76
+ classification = "SAFE_INTERNAL_REFACTOR"
77
+
78
+ if any(k in upper_prop for k in ("DROP COLUMN", "DROP TABLE", "RENAME COLUMN", "RENAME TO", "REMOVED")):
79
+ classification = "BREAKING_DATABASE_SCHEMA"
80
+ reasons.append(f"Column or table '{symbol_name}' is being dropped or renamed, breaking active ORM and query bindings.")
81
+ elif "NOT NULL" in upper_prop and "DEFAULT" not in upper_prop:
82
+ classification = "BREAKING_DATABASE_SCHEMA"
83
+ reasons.append(f"Adding NOT NULL constraint on '{symbol_name}' without a DEFAULT value breaks existing INSERT/ORM writes.")
84
+ elif any(k in upper_prop for k in ("ALTER COLUMN", "TYPE VARCHAR(", "TYPE INT", "NARROW")):
85
+ classification = "BREAKING_DATABASE_SCHEMA"
86
+ reasons.append(f"Column type alteration on '{symbol_name}' risks data truncation or serializer type mismatch across consumers.")
87
+ elif "-" in prop_text and "+" in prop_text:
88
+ classification = "BREAKING_DATABASE_SCHEMA"
89
+ reasons.append(f"Schema definition diff on '{symbol_name}' modifies column contract consumed downstream.")
90
+
91
+ gate_status = _compute_gate_status(classification, downstream_chains)
92
+ return {
93
+ "gate_status": gate_status,
94
+ "classification": classification,
95
+ "target_file": rel_file,
96
+ "target_symbol": symbol_name,
97
+ "proposed_edit": prop_text,
98
+ "reasons": reasons or ["Schema modification is backward-compatible with existing table contracts."],
99
+ "evidence_chains": format_evidence_chains(topology, downstream_chains),
100
+ }
101
+
102
+
103
+ def _classify_api_edit(
104
+ topology: Dict[str, Any],
105
+ rel_file: str,
106
+ symbol_name: str,
107
+ prop_text: str,
108
+ downstream_chains: List[List[str]],
109
+ ) -> Dict[str, Any]:
110
+ reasons: List[str] = []
111
+ classification = "SAFE_INTERNAL_REFACTOR"
112
+ upper_prop = prop_text.upper()
113
+
114
+ # Check against existing route metadata if available
115
+ matched_route = None
116
+ for r in topology.get("api_routes", []):
117
+ if symbol_name in (r["id"], r["path"], r["normalized_path"], r["handler"]):
118
+ matched_route = r
119
+ break
120
+
121
+ if matched_route:
122
+ old_path = matched_route["path"]
123
+ old_method = matched_route["method"]
124
+ # Check if new route path or method differs
125
+ path_match = re.search(r'[`"\'](/[^`"\']+)[`"\']', prop_text)
126
+ if path_match and path_match.group(1).rstrip("/") != old_path.rstrip("/"):
127
+ classification = "BREAKING_API_CONTRACT"
128
+ reasons.append(f"API route path changed from '{old_path}' to '{path_match.group(1)}', breaking frontend callers.")
129
+
130
+ method_match = re.search(r"\b(GET|POST|PUT|DELETE|PATCH)\b", upper_prop)
131
+ if method_match and method_match.group(1) != old_method:
132
+ classification = "BREAKING_API_CONTRACT"
133
+ reasons.append(f"HTTP method changed from '{old_method}' to '{method_match.group(1)}'.")
134
+
135
+ for rf in matched_route.get("response_fields", []):
136
+ if f"-{rf}" in prop_text or f"removed {rf}" in prop_text.lower() or (
137
+ "return {" in prop_text and f'"{rf}"' not in prop_text and f"'{rf}'" not in prop_text
138
+ ):
139
+ classification = "BREAKING_API_CONTRACT"
140
+ reasons.append(f"Response payload field '{rf}' was removed or renamed from '{old_method} {old_path}'.")
141
+
142
+ if classification == "SAFE_INTERNAL_REFACTOR":
143
+ if any(w in prop_text.lower() for w in ("remove", "rename", "drop", "v2", "breaking")):
144
+ classification = "BREAKING_API_CONTRACT"
145
+ reasons.append(f"Proposed API contract change on '{symbol_name}' alters endpoint path, method, or response payload shape.")
146
+
147
+ gate_status = _compute_gate_status(classification, downstream_chains)
148
+ return {
149
+ "gate_status": gate_status,
150
+ "classification": classification,
151
+ "target_file": rel_file,
152
+ "target_symbol": symbol_name,
153
+ "proposed_edit": prop_text,
154
+ "reasons": reasons or ["API endpoint contract remains backward-compatible."],
155
+ "evidence_chains": format_evidence_chains(topology, downstream_chains),
156
+ }
157
+
158
+
159
+ def _classify_python_signature_edit(
160
+ topology: Dict[str, Any],
161
+ rel_file: str,
162
+ symbol_name: str,
163
+ prop_text: str,
164
+ downstream_chains: List[List[str]],
165
+ ) -> Dict[str, Any]:
166
+ reasons: List[str] = []
167
+ classification = "SAFE_INTERNAL_REFACTOR"
168
+
169
+ # Lookup existing function or class field in topology
170
+ py_mod = topology.get("py_modules", {}).get(rel_file)
171
+ if not py_mod:
172
+ for k, v in topology.get("py_modules", {}).items():
173
+ if k.endswith(rel_file) or os.path.basename(k) == os.path.basename(rel_file):
174
+ py_mod = v
175
+ rel_file = k
176
+ break
177
+
178
+ existing_fn = py_mod.get("functions", {}).get(symbol_name) if py_mod else None
179
+ if not existing_fn and py_mod:
180
+ # Check if symbol_name is Class.field or Class
181
+ if "." in symbol_name:
182
+ cls_n, fld_n = symbol_name.split(".", 1)
183
+ cls_meta = py_mod.get("classes", {}).get(cls_n)
184
+ if cls_meta and fld_n in cls_meta.get("fields", {}):
185
+ if any(tok in prop_text.lower() for tok in ("remove", "drop", "rename", "-")) or fld_n not in prop_text:
186
+ classification = "BREAKING_DATABASE_SCHEMA" if cls_meta.get("tablename") else "BREAKING_API_CONTRACT"
187
+ reasons.append(f"Model/schema field '{symbol_name}' is being renamed, removed, or re-typed.")
188
+ return {
189
+ "gate_status": _compute_gate_status(classification, downstream_chains),
190
+ "classification": classification,
191
+ "target_file": rel_file,
192
+ "target_symbol": symbol_name,
193
+ "proposed_edit": prop_text,
194
+ "reasons": reasons,
195
+ "evidence_chains": format_evidence_chains(topology, downstream_chains),
196
+ }
197
+
198
+ # Parse proposed Python function signature if it starts with def / async def
199
+ prop_sig = _try_parse_python_def(prop_text, symbol_name)
200
+ if existing_fn and prop_sig:
201
+ old_params = existing_fn.get("params", [])
202
+ new_params = prop_sig.get("params", [])
203
+ old_by_name = {p["name"]: p for p in old_params}
204
+ new_by_name = {p["name"]: p for p in new_params}
205
+
206
+ # 1. Removed parameters
207
+ for op in old_params:
208
+ if op["name"] not in new_by_name:
209
+ classification = "BREAKING_FUNCTION_SIGNATURE"
210
+ reasons.append(f"Parameter '{op['name']}' was removed from '{symbol_name}()'.")
211
+
212
+ # 2. Added required parameters (without default)
213
+ for np in new_params:
214
+ if np["name"] not in old_by_name:
215
+ if np["required"]:
216
+ classification = "BREAKING_FUNCTION_SIGNATURE"
217
+ reasons.append(
218
+ f"Added required parameter '{np['name']}' without a default value to '{symbol_name}()', breaking existing call sites."
219
+ )
220
+ else:
221
+ op = old_by_name[np["name"]]
222
+ if not op["required"] and np["required"]:
223
+ classification = "BREAKING_FUNCTION_SIGNATURE"
224
+ reasons.append(
225
+ f"Optional parameter '{np['name']}' was made required (default value removed) in '{symbol_name}()'."
226
+ )
227
+ if op["annotation"] and np["annotation"] and op["annotation"] != np["annotation"]:
228
+ classification = "BREAKING_FUNCTION_SIGNATURE"
229
+ reasons.append(
230
+ f"Parameter '{np['name']}' type annotation changed from '{op['annotation']}' to '{np['annotation']}'."
231
+ )
232
+
233
+ # 3. Reordered positional parameters
234
+ common_old_order = [p["name"] for p in old_params if p["name"] in new_by_name]
235
+ common_new_order = [p["name"] for p in new_params if p["name"] in old_by_name]
236
+ if common_old_order != common_new_order:
237
+ classification = "BREAKING_FUNCTION_SIGNATURE"
238
+ reasons.append(
239
+ f"Positional parameter order changed from ({', '.join(common_old_order)}) to ({', '.join(common_new_order)})."
240
+ )
241
+
242
+ # 4. Return type change
243
+ old_ret = existing_fn.get("return_type", "")
244
+ new_ret = prop_sig.get("return_type", "")
245
+ if old_ret and new_ret and old_ret != new_ret:
246
+ classification = "BREAKING_FUNCTION_SIGNATURE"
247
+ reasons.append(f"Return type annotation changed from '{old_ret}' to '{new_ret}'.")
248
+
249
+ elif prop_sig and not existing_fn:
250
+ # Even if existing function wasn't indexed yet, check if proposed edit has required params or breaking hints
251
+ req_params = [p["name"] for p in prop_sig.get("params", []) if p["required"]]
252
+ if req_params and "-" in prop_text:
253
+ classification = "BREAKING_FUNCTION_SIGNATURE"
254
+ reasons.append(f"Modified signature on '{symbol_name}' introduces required parameters: {', '.join(req_params)}.")
255
+
256
+ gate_status = _compute_gate_status(classification, downstream_chains)
257
+ return {
258
+ "gate_status": gate_status,
259
+ "classification": classification,
260
+ "target_file": rel_file,
261
+ "target_symbol": symbol_name,
262
+ "existing_signature": existing_fn.get("signature_string") if existing_fn else None,
263
+ "proposed_edit": prop_text,
264
+ "reasons": reasons or ["Proposed function change preserves existing call-site signature compatibility."],
265
+ "evidence_chains": format_evidence_chains(topology, downstream_chains),
266
+ }
267
+
268
+
269
+ def _classify_ts_signature_edit(
270
+ topology: Dict[str, Any],
271
+ rel_file: str,
272
+ symbol_name: str,
273
+ prop_text: str,
274
+ downstream_chains: List[List[str]],
275
+ ) -> Dict[str, Any]:
276
+ reasons: List[str] = []
277
+ classification = "SAFE_INTERNAL_REFACTOR"
278
+
279
+ fe_mod = topology.get("fe_modules", {}).get(rel_file)
280
+ if not fe_mod:
281
+ for k, v in topology.get("fe_modules", {}).items():
282
+ if k.endswith(rel_file) or os.path.basename(k) == os.path.basename(rel_file):
283
+ fe_mod = v
284
+ rel_file = k
285
+ break
286
+
287
+ existing_sym = fe_mod.get("symbols", {}).get(symbol_name) if fe_mod else None
288
+ paren_match = re.search(r"\(([^)]*)\)", prop_text)
289
+ if existing_sym and paren_match:
290
+ old_params = existing_sym.get("params", [])
291
+ new_params = _parse_ts_params(paren_match.group(1))
292
+ old_names = {p["name"]: p for p in old_params}
293
+ new_names = {p["name"]: p for p in new_params}
294
+
295
+ for op in old_params:
296
+ if op["name"] not in new_names:
297
+ classification = "BREAKING_FUNCTION_SIGNATURE"
298
+ reasons.append(f"Prop/parameter '{op['name']}' was removed from '{symbol_name}'.")
299
+ for np in new_params:
300
+ if np["name"] not in old_names and np["required"]:
301
+ classification = "BREAKING_FUNCTION_SIGNATURE"
302
+ reasons.append(f"Added required prop/parameter '{np['name']}' without default to '{symbol_name}'.")
303
+
304
+ gate_status = _compute_gate_status(classification, downstream_chains)
305
+ return {
306
+ "gate_status": gate_status,
307
+ "classification": classification,
308
+ "target_file": rel_file,
309
+ "target_symbol": symbol_name,
310
+ "proposed_edit": prop_text,
311
+ "reasons": reasons or ["Frontend component/function signature remains backward-compatible."],
312
+ "evidence_chains": format_evidence_chains(topology, downstream_chains),
313
+ }
314
+
315
+
316
+ def _classify_generic_edit(
317
+ topology: Dict[str, Any],
318
+ rel_file: str,
319
+ symbol_name: str,
320
+ prop_text: str,
321
+ downstream_chains: List[List[str]],
322
+ ) -> Dict[str, Any]:
323
+ classification = "SAFE_INTERNAL_REFACTOR"
324
+ reasons: List[str] = []
325
+ if any(k in prop_text.upper() for k in ("DROP ", "REMOVE", "RENAME", "BREAKING")):
326
+ classification = "BREAKING_FUNCTION_SIGNATURE"
327
+ reasons.append(f"Breaking modification detected on '{symbol_name}'.")
328
+
329
+ gate_status = _compute_gate_status(classification, downstream_chains)
330
+ return {
331
+ "gate_status": gate_status,
332
+ "classification": classification,
333
+ "target_file": rel_file,
334
+ "target_symbol": symbol_name,
335
+ "proposed_edit": prop_text,
336
+ "reasons": reasons or ["Internal refactor with no contract breakage detected."],
337
+ "evidence_chains": format_evidence_chains(topology, downstream_chains),
338
+ }
339
+
340
+
341
+ def _try_parse_python_def(prop_text: str, symbol_name: str) -> Optional[Dict[str, Any]]:
342
+ cleaned = prop_text.strip()
343
+ # If diff lines are passed, extract the '+' line with def
344
+ for line in cleaned.splitlines():
345
+ l_strip = line.lstrip("+").strip()
346
+ if l_strip.startswith(("def ", "async def ")):
347
+ cleaned = l_strip
348
+ break
349
+ if not cleaned.startswith(("def ", "async def ")):
350
+ if cleaned.startswith("(") and cleaned.endswith(")"):
351
+ cleaned = f"def {symbol_name}{cleaned}: pass"
352
+ else:
353
+ return None
354
+ if not cleaned.endswith("pass") and ":" not in cleaned.split(")")[-1]:
355
+ cleaned = cleaned + ":\n pass"
356
+ elif cleaned.endswith(":"):
357
+ cleaned = cleaned + "\n pass"
358
+ try:
359
+ tree = ast.parse(cleaned)
360
+ for node in tree.body:
361
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
362
+ return _extract_function_signature(node)
363
+ except Exception:
364
+ return None
365
+ return None
366
+
367
+
368
+ def _compute_gate_status(classification: str, downstream_chains: List[List[str]]) -> str:
369
+ if classification == "SAFE_INTERNAL_REFACTOR":
370
+ return "PASS"
371
+ if len(downstream_chains) > 0:
372
+ return "BLOCK"
373
+ return "WARN"
374
+
375
+
376
+ def format_node_evidence_label(topology: Dict[str, Any], node_id: str) -> str:
377
+ nodes_by_id = topology.get("nodes_by_id", {})
378
+ n = nodes_by_id.get(node_id)
379
+ if not n:
380
+ return node_id
381
+ rel_file = n.get("file", "")
382
+ name = n.get("name", node_id)
383
+ ntype = n.get("type", "")
384
+ if ntype == "db_column":
385
+ return f"{rel_file} ({n.get('table')}.{n.get('column')})"
386
+ if ntype == "db_table":
387
+ return f"{rel_file} ({name})"
388
+ if ntype in ("orm_field", "class_field", "python_function", "python_method", "python_class"):
389
+ return f"{rel_file} ({name})"
390
+ if ntype == "api_route":
391
+ return f"{rel_file} ({n.get('method')} {n.get('path')})"
392
+ if ntype == "api_payload_field":
393
+ return f"{rel_file} ({name})"
394
+ if ntype in ("react_component", "ts_function", "frontend_property_access"):
395
+ return f"{rel_file} ({name})"
396
+ if ntype in ("test_suite", "test_case"):
397
+ return f"{rel_file} ({n.get('function', 'test_suite')})"
398
+ return f"{rel_file} ({name})" if rel_file else name
399
+
400
+
401
+ def format_evidence_chains(topology: Dict[str, Any], chains: List[List[str]]) -> List[str]:
402
+ formatted: List[str] = []
403
+ for chain in chains:
404
+ if not chain:
405
+ continue
406
+ hops = [format_node_evidence_label(topology, nid) for nid in chain]
407
+ dedup_hops: List[str] = []
408
+ for h in hops:
409
+ if not dedup_hops or dedup_hops[-1] != h:
410
+ dedup_hops.append(h)
411
+ chain_str = " -> ".join(dedup_hops)
412
+ if chain_str not in formatted:
413
+ formatted.append(chain_str)
414
+ return formatted
415
+