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/__init__.py
ADDED
blasttrace/classifier.py
ADDED
|
@@ -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
|
+
|