bubble-analysis 0.2.0__py3-none-any.whl → 0.3.4__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.
- bubble/detectors.py +7 -4
- bubble/extractor.py +37 -10
- bubble/formatters.py +7 -1
- bubble/integrations/cli_scripts/cli.py +5 -1
- bubble/integrations/django/cli.py +42 -6
- bubble/integrations/django/detector.py +57 -12
- bubble/integrations/fastapi/cli.py +47 -7
- bubble/integrations/flask/__init__.py +4 -0
- bubble/integrations/flask/cli.py +47 -7
- bubble/integrations/flask/detector.py +243 -5
- bubble/integrations/formatters.py +7 -0
- bubble/integrations/models.py +9 -1
- bubble/integrations/queries.py +40 -13
- bubble/propagation.py +176 -15
- bubble/queries.py +105 -13
- bubble/results.py +1 -0
- {bubble_analysis-0.2.0.dist-info → bubble_analysis-0.3.4.dist-info}/METADATA +56 -35
- {bubble_analysis-0.2.0.dist-info → bubble_analysis-0.3.4.dist-info}/RECORD +22 -22
- {bubble_analysis-0.2.0.dist-info → bubble_analysis-0.3.4.dist-info}/WHEEL +0 -0
- {bubble_analysis-0.2.0.dist-info → bubble_analysis-0.3.4.dist-info}/entry_points.txt +0 -0
- {bubble_analysis-0.2.0.dist-info → bubble_analysis-0.3.4.dist-info}/licenses/LICENSE +0 -0
- {bubble_analysis-0.2.0.dist-info → bubble_analysis-0.3.4.dist-info}/top_level.txt +0 -0
|
@@ -6,6 +6,8 @@ from libcst.metadata import MetadataWrapper, PositionProvider
|
|
|
6
6
|
from bubble.enums import EntrypointKind, Framework
|
|
7
7
|
from bubble.integrations.base import Entrypoint, GlobalHandler
|
|
8
8
|
|
|
9
|
+
HTTP_METHODS = {"get", "post", "put", "delete", "patch", "head", "options"}
|
|
10
|
+
|
|
9
11
|
|
|
10
12
|
class FlaskRouteVisitor(cst.CSTVisitor):
|
|
11
13
|
"""
|
|
@@ -157,21 +159,185 @@ class FlaskErrorHandlerVisitor(cst.CSTVisitor):
|
|
|
157
159
|
return ""
|
|
158
160
|
|
|
159
161
|
|
|
162
|
+
class FlaskRESTfulVisitor(cst.CSTVisitor):
|
|
163
|
+
"""
|
|
164
|
+
Detects Flask-RESTful Resource classes and add_resource() registrations.
|
|
165
|
+
|
|
166
|
+
Supports:
|
|
167
|
+
- api.add_resource(ResourceClass, "/path")
|
|
168
|
+
- api.add_resource(ResourceClass, "/path1", "/path2")
|
|
169
|
+
- Custom methods like api.add_org_resource()
|
|
170
|
+
"""
|
|
171
|
+
|
|
172
|
+
METADATA_DEPENDENCIES = (PositionProvider,)
|
|
173
|
+
|
|
174
|
+
ADD_RESOURCE_METHODS = {"add_resource", "add_org_resource"}
|
|
175
|
+
|
|
176
|
+
def __init__(self, file_path: str) -> None:
|
|
177
|
+
self.file_path = file_path
|
|
178
|
+
self.entrypoints: list[Entrypoint] = []
|
|
179
|
+
self.resource_classes: dict[str, dict[str, int]] = {}
|
|
180
|
+
self.resource_registrations: list[tuple[str, list[str], int]] = []
|
|
181
|
+
|
|
182
|
+
def visit_ClassDef(self, node: cst.ClassDef) -> bool:
|
|
183
|
+
methods_found: dict[str, int] = {}
|
|
184
|
+
for item in node.body.body:
|
|
185
|
+
if isinstance(item, cst.FunctionDef):
|
|
186
|
+
method_name = item.name.value.lower()
|
|
187
|
+
if method_name in HTTP_METHODS and not self._has_route_decorator(item):
|
|
188
|
+
pos = self.get_metadata(PositionProvider, item)
|
|
189
|
+
methods_found[method_name.upper()] = pos.start.line
|
|
190
|
+
|
|
191
|
+
if methods_found:
|
|
192
|
+
self.resource_classes[node.name.value] = methods_found
|
|
193
|
+
|
|
194
|
+
return True
|
|
195
|
+
|
|
196
|
+
def _has_route_decorator(self, node: cst.FunctionDef) -> bool:
|
|
197
|
+
"""Check if a function has a route decorator like @expose, @route."""
|
|
198
|
+
for decorator in node.decorators:
|
|
199
|
+
dec = decorator.decorator
|
|
200
|
+
if isinstance(dec, cst.Call):
|
|
201
|
+
if isinstance(dec.func, cst.Attribute):
|
|
202
|
+
if dec.func.attr.value in ("route", "expose"):
|
|
203
|
+
return True
|
|
204
|
+
elif isinstance(dec.func, cst.Name):
|
|
205
|
+
if dec.func.value in ("route", "expose"):
|
|
206
|
+
return True
|
|
207
|
+
elif isinstance(dec, cst.Attribute):
|
|
208
|
+
if dec.attr.value in ("route", "expose"):
|
|
209
|
+
return True
|
|
210
|
+
elif isinstance(dec, cst.Name):
|
|
211
|
+
if dec.value in ("route", "expose"):
|
|
212
|
+
return True
|
|
213
|
+
return False
|
|
214
|
+
|
|
215
|
+
def visit_Call(self, node: cst.Call) -> bool:
|
|
216
|
+
if not isinstance(node.func, cst.Attribute):
|
|
217
|
+
return True
|
|
218
|
+
|
|
219
|
+
method_name = node.func.attr.value
|
|
220
|
+
if method_name not in self.ADD_RESOURCE_METHODS:
|
|
221
|
+
return True
|
|
222
|
+
|
|
223
|
+
if len(node.args) < 2:
|
|
224
|
+
return True
|
|
225
|
+
|
|
226
|
+
first_arg = node.args[0].value
|
|
227
|
+
resource_name = self._get_name_from_expr(first_arg)
|
|
228
|
+
if not resource_name:
|
|
229
|
+
return True
|
|
230
|
+
|
|
231
|
+
urls: list[str] = []
|
|
232
|
+
for arg in node.args[1:]:
|
|
233
|
+
if arg.keyword is not None:
|
|
234
|
+
continue
|
|
235
|
+
url = self._extract_string(arg.value)
|
|
236
|
+
if url:
|
|
237
|
+
urls.append(url)
|
|
238
|
+
|
|
239
|
+
if urls:
|
|
240
|
+
pos = self.get_metadata(PositionProvider, node)
|
|
241
|
+
self.resource_registrations.append((resource_name, urls, pos.start.line))
|
|
242
|
+
|
|
243
|
+
return True
|
|
244
|
+
|
|
245
|
+
def leave_Module(self, original_node: cst.Module) -> None:
|
|
246
|
+
registered_classes: set[str] = set()
|
|
247
|
+
|
|
248
|
+
for resource_name, urls, reg_line in self.resource_registrations:
|
|
249
|
+
registered_classes.add(resource_name)
|
|
250
|
+
methods = self.resource_classes.get(resource_name, {})
|
|
251
|
+
if not methods:
|
|
252
|
+
methods = {"GET": reg_line}
|
|
253
|
+
|
|
254
|
+
for url in urls:
|
|
255
|
+
for method, method_line in methods.items():
|
|
256
|
+
self.entrypoints.append(
|
|
257
|
+
Entrypoint(
|
|
258
|
+
file=self.file_path,
|
|
259
|
+
function=f"{resource_name}.{method.lower()}",
|
|
260
|
+
line=method_line,
|
|
261
|
+
kind=EntrypointKind.HTTP_ROUTE,
|
|
262
|
+
metadata={
|
|
263
|
+
"http_method": method,
|
|
264
|
+
"http_path": url,
|
|
265
|
+
"framework": Framework.FLASK,
|
|
266
|
+
"flask_restful": True,
|
|
267
|
+
},
|
|
268
|
+
)
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
for class_name, methods in self.resource_classes.items():
|
|
272
|
+
if class_name in registered_classes:
|
|
273
|
+
continue
|
|
274
|
+
|
|
275
|
+
for method, method_line in methods.items():
|
|
276
|
+
self.entrypoints.append(
|
|
277
|
+
Entrypoint(
|
|
278
|
+
file=self.file_path,
|
|
279
|
+
function=f"{class_name}.{method.lower()}",
|
|
280
|
+
line=method_line,
|
|
281
|
+
kind=EntrypointKind.HTTP_ROUTE,
|
|
282
|
+
metadata={
|
|
283
|
+
"http_method": method,
|
|
284
|
+
"http_path": f"<flask-restful:{class_name}>",
|
|
285
|
+
"framework": Framework.FLASK,
|
|
286
|
+
"flask_restful": True,
|
|
287
|
+
},
|
|
288
|
+
)
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
def _get_name_from_expr(self, expr: cst.BaseExpression) -> str:
|
|
292
|
+
if isinstance(expr, cst.Name):
|
|
293
|
+
return expr.value
|
|
294
|
+
elif isinstance(expr, cst.Attribute):
|
|
295
|
+
return expr.attr.value
|
|
296
|
+
return ""
|
|
297
|
+
|
|
298
|
+
def _extract_string(self, node: cst.BaseExpression) -> str | None:
|
|
299
|
+
if isinstance(node, cst.SimpleString):
|
|
300
|
+
return node.evaluated_value
|
|
301
|
+
elif isinstance(node, cst.ConcatenatedString):
|
|
302
|
+
parts = []
|
|
303
|
+
for part in (node.left, node.right):
|
|
304
|
+
if isinstance(part, cst.SimpleString):
|
|
305
|
+
val = part.evaluated_value
|
|
306
|
+
if val:
|
|
307
|
+
parts.append(val)
|
|
308
|
+
return "".join(parts) if parts else None
|
|
309
|
+
return None
|
|
310
|
+
|
|
311
|
+
|
|
160
312
|
def detect_flask_entrypoints(source: str, file_path: str) -> list[Entrypoint]:
|
|
161
|
-
"""Detect Flask route entrypoints in a Python source file.
|
|
313
|
+
"""Detect Flask route entrypoints in a Python source file.
|
|
314
|
+
|
|
315
|
+
Detects both decorator-based routes (@app.route) and
|
|
316
|
+
Flask-RESTful call-based routes (api.add_resource).
|
|
317
|
+
"""
|
|
162
318
|
try:
|
|
163
319
|
module = cst.parse_module(source)
|
|
164
320
|
except Exception:
|
|
165
321
|
return []
|
|
166
322
|
|
|
323
|
+
entrypoints: list[Entrypoint] = []
|
|
167
324
|
wrapper = MetadataWrapper(module)
|
|
168
|
-
visitor = FlaskRouteVisitor(file_path)
|
|
169
325
|
|
|
326
|
+
route_visitor = FlaskRouteVisitor(file_path)
|
|
170
327
|
try:
|
|
171
|
-
wrapper.visit(
|
|
172
|
-
|
|
328
|
+
wrapper.visit(route_visitor)
|
|
329
|
+
entrypoints.extend(route_visitor.entrypoints)
|
|
173
330
|
except Exception:
|
|
174
|
-
|
|
331
|
+
pass
|
|
332
|
+
|
|
333
|
+
restful_visitor = FlaskRESTfulVisitor(file_path)
|
|
334
|
+
try:
|
|
335
|
+
wrapper.visit(restful_visitor)
|
|
336
|
+
entrypoints.extend(restful_visitor.entrypoints)
|
|
337
|
+
except Exception:
|
|
338
|
+
pass
|
|
339
|
+
|
|
340
|
+
return entrypoints
|
|
175
341
|
|
|
176
342
|
|
|
177
343
|
def detect_flask_global_handlers(source: str, file_path: str) -> list[GlobalHandler]:
|
|
@@ -189,3 +355,75 @@ def detect_flask_global_handlers(source: str, file_path: str) -> list[GlobalHand
|
|
|
189
355
|
return visitor.handlers
|
|
190
356
|
except Exception:
|
|
191
357
|
return []
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def correlate_flask_restful_entrypoints(entrypoints: list[Entrypoint]) -> list[Entrypoint]:
|
|
361
|
+
"""Correlate Flask-RESTful entrypoints across files.
|
|
362
|
+
|
|
363
|
+
Flask-RESTful apps often define Resource classes in one file and register
|
|
364
|
+
them with api.add_resource() in another. This function merges information
|
|
365
|
+
from both sources:
|
|
366
|
+
- Class definitions have correct methods but placeholder paths
|
|
367
|
+
- Registrations have correct paths but fallback methods
|
|
368
|
+
|
|
369
|
+
Same-file cases (already fully resolved) are passed through unchanged.
|
|
370
|
+
Only placeholder entries trigger cross-file correlation.
|
|
371
|
+
|
|
372
|
+
Returns a new list with correlated entrypoints.
|
|
373
|
+
"""
|
|
374
|
+
placeholder_classes: dict[str, list[Entrypoint]] = {}
|
|
375
|
+
real_path_entries: dict[str, list[Entrypoint]] = {}
|
|
376
|
+
non_flask_restful: list[Entrypoint] = []
|
|
377
|
+
|
|
378
|
+
for ep in entrypoints:
|
|
379
|
+
if not ep.metadata.get("flask_restful"):
|
|
380
|
+
non_flask_restful.append(ep)
|
|
381
|
+
continue
|
|
382
|
+
|
|
383
|
+
path = ep.metadata.get("http_path", "")
|
|
384
|
+
|
|
385
|
+
if path.startswith("<flask-restful:") and path.endswith(">"):
|
|
386
|
+
class_name = path[15:-1]
|
|
387
|
+
if class_name not in placeholder_classes:
|
|
388
|
+
placeholder_classes[class_name] = []
|
|
389
|
+
placeholder_classes[class_name].append(ep)
|
|
390
|
+
else:
|
|
391
|
+
parts = ep.function.rsplit(".", 1)
|
|
392
|
+
if len(parts) == 2:
|
|
393
|
+
class_name = parts[0]
|
|
394
|
+
if class_name not in real_path_entries:
|
|
395
|
+
real_path_entries[class_name] = []
|
|
396
|
+
real_path_entries[class_name].append(ep)
|
|
397
|
+
else:
|
|
398
|
+
non_flask_restful.append(ep)
|
|
399
|
+
|
|
400
|
+
result: list[Entrypoint] = list(non_flask_restful)
|
|
401
|
+
|
|
402
|
+
for class_name, class_eps in placeholder_classes.items():
|
|
403
|
+
if class_name in real_path_entries:
|
|
404
|
+
reg_eps = real_path_entries[class_name]
|
|
405
|
+
paths_from_registrations = list({ep.metadata.get("http_path") for ep in reg_eps})
|
|
406
|
+
|
|
407
|
+
for class_ep in class_eps:
|
|
408
|
+
for reg_path in paths_from_registrations:
|
|
409
|
+
result.append(
|
|
410
|
+
Entrypoint(
|
|
411
|
+
file=class_ep.file,
|
|
412
|
+
function=class_ep.function,
|
|
413
|
+
line=class_ep.line,
|
|
414
|
+
kind=class_ep.kind,
|
|
415
|
+
metadata={
|
|
416
|
+
**class_ep.metadata,
|
|
417
|
+
"http_path": reg_path,
|
|
418
|
+
},
|
|
419
|
+
)
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
del real_path_entries[class_name]
|
|
423
|
+
else:
|
|
424
|
+
result.extend(class_eps)
|
|
425
|
+
|
|
426
|
+
for class_name, eps in real_path_entries.items():
|
|
427
|
+
result.extend(eps)
|
|
428
|
+
|
|
429
|
+
return result
|
|
@@ -53,6 +53,13 @@ def audit(
|
|
|
53
53
|
]
|
|
54
54
|
for exc_type, raises_list in issue.caught_by_generic.items()
|
|
55
55
|
},
|
|
56
|
+
"caught_by_remote": {
|
|
57
|
+
exc_type: [
|
|
58
|
+
{"file": r.file, "line": r.line, "function": r.function}
|
|
59
|
+
for r in raises_list
|
|
60
|
+
]
|
|
61
|
+
for exc_type, raises_list in issue.caught_by_remote.items()
|
|
62
|
+
},
|
|
56
63
|
}
|
|
57
64
|
for issue in result.issues
|
|
58
65
|
],
|
bubble/integrations/models.py
CHANGED
|
@@ -22,11 +22,19 @@ class IntegrationData:
|
|
|
22
22
|
|
|
23
23
|
@dataclass
|
|
24
24
|
class AuditIssue:
|
|
25
|
-
"""An entrypoint with uncaught or poorly-handled exceptions.
|
|
25
|
+
"""An entrypoint with uncaught or poorly-handled exceptions.
|
|
26
|
+
|
|
27
|
+
Fields:
|
|
28
|
+
- uncaught: Exceptions with no handler at all
|
|
29
|
+
- caught_by_generic: Caught by generic handler (@errorhandler(Exception))
|
|
30
|
+
- caught_by_remote: Caught by a handler in a different file (may indicate missing local handler)
|
|
31
|
+
- caught: Caught by a handler in the same file
|
|
32
|
+
"""
|
|
26
33
|
|
|
27
34
|
entrypoint: Entrypoint
|
|
28
35
|
uncaught: dict[str, list["RaiseSite"]]
|
|
29
36
|
caught_by_generic: dict[str, list["RaiseSite"]]
|
|
37
|
+
caught_by_remote: dict[str, list["RaiseSite"]]
|
|
30
38
|
caught: dict[str, list["RaiseSite"]]
|
|
31
39
|
|
|
32
40
|
|
bubble/integrations/queries.py
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
Shared audit/entrypoint logic that all integrations use.
|
|
4
4
|
"""
|
|
5
5
|
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
6
8
|
from bubble.config import FlowConfig
|
|
7
9
|
from bubble.integrations.base import Entrypoint, GlobalHandler, Integration
|
|
8
10
|
from bubble.integrations.models import (
|
|
@@ -23,6 +25,9 @@ from bubble.propagation import (
|
|
|
23
25
|
propagate_exceptions,
|
|
24
26
|
)
|
|
25
27
|
|
|
28
|
+
if TYPE_CHECKING:
|
|
29
|
+
from bubble.stubs import StubLibrary
|
|
30
|
+
|
|
26
31
|
|
|
27
32
|
def _filter_async_boundaries(
|
|
28
33
|
forward_graph: dict[str, set[str]], config: FlowConfig
|
|
@@ -51,6 +56,7 @@ def _compute_exception_flow_for_integration(
|
|
|
51
56
|
forward_graph: dict[str, set[str]] | None = None,
|
|
52
57
|
name_to_qualified: dict[str, list[str]] | None = None,
|
|
53
58
|
config: FlowConfig | None = None,
|
|
59
|
+
entrypoint_file: str | None = None,
|
|
54
60
|
) -> ExceptionFlow:
|
|
55
61
|
"""Compute exception flow for a function with integration-specific handling.
|
|
56
62
|
|
|
@@ -134,9 +140,17 @@ def _compute_exception_flow_for_integration(
|
|
|
134
140
|
flow.caught_by_generic[exc_type] = []
|
|
135
141
|
flow.caught_by_generic[exc_type].extend(raise_sites)
|
|
136
142
|
else:
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
143
|
+
is_same_file = (
|
|
144
|
+
entrypoint_file is not None and caught_by_handler.file == entrypoint_file
|
|
145
|
+
)
|
|
146
|
+
if is_same_file:
|
|
147
|
+
if exc_type not in flow.caught_by_global:
|
|
148
|
+
flow.caught_by_global[exc_type] = []
|
|
149
|
+
flow.caught_by_global[exc_type].extend(raise_sites)
|
|
150
|
+
else:
|
|
151
|
+
if exc_type not in flow.caught_by_remote_global:
|
|
152
|
+
flow.caught_by_remote_global[exc_type] = []
|
|
153
|
+
flow.caught_by_remote_global[exc_type].extend(raise_sites)
|
|
140
154
|
continue
|
|
141
155
|
|
|
142
156
|
framework_response = integration.get_exception_response(exc_type)
|
|
@@ -181,6 +195,7 @@ def audit_integration(
|
|
|
181
195
|
global_handlers: list[GlobalHandler],
|
|
182
196
|
skip_evidence: bool = True,
|
|
183
197
|
config: FlowConfig | None = None,
|
|
198
|
+
stub_library: "StubLibrary | None" = None,
|
|
184
199
|
) -> AuditResult:
|
|
185
200
|
"""Audit entrypoints for a specific integration.
|
|
186
201
|
|
|
@@ -188,6 +203,7 @@ def audit_integration(
|
|
|
188
203
|
skip_evidence: Skip building evidence paths for faster auditing.
|
|
189
204
|
Set to False if you need path details.
|
|
190
205
|
config: Optional FlowConfig with handled_base_classes and async_boundaries.
|
|
206
|
+
stub_library: Optional stub library for external library exceptions.
|
|
191
207
|
"""
|
|
192
208
|
if not entrypoints:
|
|
193
209
|
return AuditResult(
|
|
@@ -197,7 +213,9 @@ def audit_integration(
|
|
|
197
213
|
clean_count=0,
|
|
198
214
|
)
|
|
199
215
|
|
|
200
|
-
propagation = propagate_exceptions(
|
|
216
|
+
propagation = propagate_exceptions(
|
|
217
|
+
model, skip_evidence=skip_evidence, stub_library=stub_library
|
|
218
|
+
)
|
|
201
219
|
reraise_patterns = {"Unknown", "e", "ex", "err", "exc", "error", "exception"}
|
|
202
220
|
|
|
203
221
|
forward_graph = build_forward_call_graph(model)
|
|
@@ -218,12 +236,16 @@ def audit_integration(
|
|
|
218
236
|
forward_graph,
|
|
219
237
|
name_to_qualified,
|
|
220
238
|
config,
|
|
239
|
+
entrypoint_file=entrypoint.file,
|
|
221
240
|
)
|
|
222
241
|
|
|
223
242
|
real_uncaught = {k: v for k, v in flow.uncaught.items() if k not in reraise_patterns}
|
|
224
243
|
real_generic = {
|
|
225
244
|
k: v for k, v in flow.caught_by_generic.items() if k not in reraise_patterns
|
|
226
245
|
}
|
|
246
|
+
real_remote = {
|
|
247
|
+
k: v for k, v in flow.caught_by_remote_global.items() if k not in reraise_patterns
|
|
248
|
+
}
|
|
227
249
|
|
|
228
250
|
if real_uncaught or real_generic:
|
|
229
251
|
issues.append(
|
|
@@ -231,6 +253,7 @@ def audit_integration(
|
|
|
231
253
|
entrypoint=entrypoint,
|
|
232
254
|
uncaught=real_uncaught,
|
|
233
255
|
caught_by_generic=real_generic,
|
|
256
|
+
caught_by_remote=real_remote,
|
|
234
257
|
caught=flow.caught_by_global,
|
|
235
258
|
)
|
|
236
259
|
)
|
|
@@ -399,10 +422,14 @@ def _trace_to_entrypoints(
|
|
|
399
422
|
return
|
|
400
423
|
visited.add(current)
|
|
401
424
|
|
|
402
|
-
|
|
403
|
-
current_simple =
|
|
425
|
+
current_qualified = current.split("::")[-1] if "::" in current else current
|
|
426
|
+
current_simple = current_qualified.split(".")[-1]
|
|
404
427
|
|
|
405
|
-
if
|
|
428
|
+
if (
|
|
429
|
+
current in entrypoint_functions
|
|
430
|
+
or current_qualified in entrypoint_functions
|
|
431
|
+
or current_simple in entrypoint_functions
|
|
432
|
+
):
|
|
406
433
|
paths.append(list(path))
|
|
407
434
|
return
|
|
408
435
|
|
|
@@ -411,13 +438,11 @@ def _trace_to_entrypoints(
|
|
|
411
438
|
if len(paths) >= max_paths:
|
|
412
439
|
return
|
|
413
440
|
if reachable_from_entrypoints is not None:
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
if "::" in caller
|
|
417
|
-
else caller.split(".")[-1]
|
|
418
|
-
)
|
|
441
|
+
caller_qualified = caller.split("::")[-1] if "::" in caller else caller
|
|
442
|
+
caller_simple = caller_qualified.split(".")[-1]
|
|
419
443
|
if (
|
|
420
444
|
caller not in reachable_from_entrypoints
|
|
445
|
+
and caller_qualified not in reachable_from_entrypoints
|
|
421
446
|
and caller_simple not in reachable_from_entrypoints
|
|
422
447
|
):
|
|
423
448
|
continue
|
|
@@ -460,7 +485,9 @@ def trace_routes_to_exception(
|
|
|
460
485
|
endpoint = path[-1]
|
|
461
486
|
entrypoints_reached.add(endpoint)
|
|
462
487
|
if "::" in endpoint:
|
|
463
|
-
|
|
488
|
+
qualified_part = endpoint.split("::")[-1]
|
|
489
|
+
entrypoints_reached.add(qualified_part)
|
|
490
|
+
entrypoints_reached.add(qualified_part.split(".")[-1])
|
|
464
491
|
|
|
465
492
|
matching_entrypoints = [e for e in entrypoints if e.function in entrypoints_reached]
|
|
466
493
|
|