bubble-analysis 0.2.0__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/__init__.py +3 -0
- bubble/cache.py +207 -0
- bubble/cli.py +470 -0
- bubble/config.py +52 -0
- bubble/detectors.py +90 -0
- bubble/enums.py +65 -0
- bubble/extractor.py +829 -0
- bubble/formatters.py +887 -0
- bubble/integrations/__init__.py +92 -0
- bubble/integrations/base.py +98 -0
- bubble/integrations/cli_scripts/__init__.py +49 -0
- bubble/integrations/cli_scripts/cli.py +108 -0
- bubble/integrations/cli_scripts/detector.py +149 -0
- bubble/integrations/django/__init__.py +63 -0
- bubble/integrations/django/cli.py +111 -0
- bubble/integrations/django/detector.py +331 -0
- bubble/integrations/django/semantics.py +40 -0
- bubble/integrations/fastapi/__init__.py +57 -0
- bubble/integrations/fastapi/cli.py +110 -0
- bubble/integrations/fastapi/detector.py +176 -0
- bubble/integrations/fastapi/semantics.py +14 -0
- bubble/integrations/flask/__init__.py +57 -0
- bubble/integrations/flask/cli.py +110 -0
- bubble/integrations/flask/detector.py +191 -0
- bubble/integrations/flask/semantics.py +19 -0
- bubble/integrations/formatters.py +268 -0
- bubble/integrations/generic/__init__.py +13 -0
- bubble/integrations/generic/config.py +106 -0
- bubble/integrations/generic/detector.py +346 -0
- bubble/integrations/generic/frameworks.py +145 -0
- bubble/integrations/models.py +68 -0
- bubble/integrations/queries.py +481 -0
- bubble/loader.py +118 -0
- bubble/models.py +397 -0
- bubble/propagation.py +737 -0
- bubble/protocols.py +104 -0
- bubble/queries.py +627 -0
- bubble/results.py +211 -0
- bubble/stubs.py +89 -0
- bubble/timing.py +144 -0
- bubble_analysis-0.2.0.dist-info/METADATA +264 -0
- bubble_analysis-0.2.0.dist-info/RECORD +46 -0
- bubble_analysis-0.2.0.dist-info/WHEEL +5 -0
- bubble_analysis-0.2.0.dist-info/entry_points.txt +2 -0
- bubble_analysis-0.2.0.dist-info/licenses/LICENSE +21 -0
- bubble_analysis-0.2.0.dist-info/top_level.txt +1 -0
bubble/propagation.py
ADDED
|
@@ -0,0 +1,737 @@
|
|
|
1
|
+
"""Exception propagation analysis.
|
|
2
|
+
|
|
3
|
+
Computes which exceptions can escape from each function and entrypoint.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from bubble.stubs import StubLibrary
|
|
14
|
+
|
|
15
|
+
from bubble.enums import ResolutionKind, ResolutionMode
|
|
16
|
+
from bubble.models import (
|
|
17
|
+
CallSite,
|
|
18
|
+
CatchSite,
|
|
19
|
+
ClassHierarchy,
|
|
20
|
+
ExceptionEvidence,
|
|
21
|
+
GlobalHandler,
|
|
22
|
+
ProgramModel,
|
|
23
|
+
RaiseSite,
|
|
24
|
+
ResolutionEdge,
|
|
25
|
+
compute_confidence,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
_propagation_cache: dict[tuple[int, ResolutionMode, int | None, bool], PropagationResult] = {}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class PropagatedRaise:
|
|
33
|
+
"""A raise site propagated to a function with its call path."""
|
|
34
|
+
|
|
35
|
+
exception_type: str
|
|
36
|
+
raise_site: RaiseSite
|
|
37
|
+
path: tuple[ResolutionEdge, ...]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class ExceptionFlow:
|
|
42
|
+
"""The computed exception flow for a function or entrypoint."""
|
|
43
|
+
|
|
44
|
+
caught_locally: dict[str, list[RaiseSite]] = field(default_factory=dict)
|
|
45
|
+
caught_by_global: dict[str, list[RaiseSite]] = field(default_factory=dict)
|
|
46
|
+
caught_by_generic: dict[str, list[RaiseSite]] = field(default_factory=dict)
|
|
47
|
+
uncaught: dict[str, list[RaiseSite]] = field(default_factory=dict)
|
|
48
|
+
framework_handled: dict[str, list[tuple[RaiseSite, str]]] = field(default_factory=dict)
|
|
49
|
+
evidence: dict[str, list[ExceptionEvidence]] = field(default_factory=dict)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class PropagationResult:
|
|
54
|
+
"""Results of exception propagation analysis."""
|
|
55
|
+
|
|
56
|
+
direct_raises: dict[str, set[str]] = field(default_factory=dict)
|
|
57
|
+
propagated_raises: dict[str, set[str]] = field(default_factory=dict)
|
|
58
|
+
catches_by_function: dict[str, list[CatchSite]] = field(default_factory=dict)
|
|
59
|
+
propagated_with_evidence: dict[str, dict[tuple[str, str, int], PropagatedRaise]] = field(
|
|
60
|
+
default_factory=dict
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def build_forward_call_graph(model: ProgramModel) -> dict[str, set[str]]:
|
|
65
|
+
"""Build a map from caller to callees."""
|
|
66
|
+
graph: dict[str, set[str]] = {}
|
|
67
|
+
|
|
68
|
+
for call_site in model.call_sites:
|
|
69
|
+
caller = call_site.caller_qualified or f"{call_site.file}::{call_site.caller_function}"
|
|
70
|
+
callee = call_site.callee_qualified or call_site.callee_name
|
|
71
|
+
|
|
72
|
+
if caller not in graph:
|
|
73
|
+
graph[caller] = set()
|
|
74
|
+
graph[caller].add(callee)
|
|
75
|
+
|
|
76
|
+
return graph
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def build_name_to_qualified(propagation: PropagationResult) -> dict[str, list[str]]:
|
|
80
|
+
"""Build a map from simple function names to their qualified names."""
|
|
81
|
+
name_to_qualified: dict[str, list[str]] = {}
|
|
82
|
+
for key in propagation.propagated_raises:
|
|
83
|
+
simple = key.split("::")[-1].split(".")[-1] if "::" in key else key.split(".")[-1]
|
|
84
|
+
if simple not in name_to_qualified:
|
|
85
|
+
name_to_qualified[simple] = []
|
|
86
|
+
name_to_qualified[simple].append(key)
|
|
87
|
+
return name_to_qualified
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def build_reverse_call_graph(
|
|
91
|
+
model: ProgramModel,
|
|
92
|
+
) -> tuple[dict[str, set[str]], dict[str, set[str]]]:
|
|
93
|
+
"""Build maps from callee to callers (both qualified and name-based)."""
|
|
94
|
+
qualified_graph: dict[str, set[str]] = {}
|
|
95
|
+
name_graph: dict[str, set[str]] = {}
|
|
96
|
+
|
|
97
|
+
for call_site in model.call_sites:
|
|
98
|
+
caller = call_site.caller_qualified or call_site.caller_function
|
|
99
|
+
|
|
100
|
+
if call_site.callee_qualified:
|
|
101
|
+
if call_site.callee_qualified not in qualified_graph:
|
|
102
|
+
qualified_graph[call_site.callee_qualified] = set()
|
|
103
|
+
qualified_graph[call_site.callee_qualified].add(caller)
|
|
104
|
+
|
|
105
|
+
if call_site.callee_name not in name_graph:
|
|
106
|
+
name_graph[call_site.callee_name] = set()
|
|
107
|
+
name_graph[call_site.callee_name].add(caller)
|
|
108
|
+
|
|
109
|
+
return qualified_graph, name_graph
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def compute_direct_raises(model: ProgramModel) -> dict[str, set[str]]:
|
|
113
|
+
"""Compute the set of exceptions directly raised in each function."""
|
|
114
|
+
direct_raises: dict[str, set[str]] = {}
|
|
115
|
+
|
|
116
|
+
for raise_site in model.raise_sites:
|
|
117
|
+
func_key = f"{raise_site.file}::{raise_site.function}"
|
|
118
|
+
if func_key not in direct_raises:
|
|
119
|
+
direct_raises[func_key] = set()
|
|
120
|
+
direct_raises[func_key].add(raise_site.exception_type)
|
|
121
|
+
|
|
122
|
+
return direct_raises
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def compute_catches_by_function(model: ProgramModel) -> dict[str, list[CatchSite]]:
|
|
126
|
+
"""Group catch sites by the function they belong to."""
|
|
127
|
+
catches: dict[str, list[CatchSite]] = {}
|
|
128
|
+
|
|
129
|
+
for catch_site in model.catch_sites:
|
|
130
|
+
func_key = f"{catch_site.file}::{catch_site.function}"
|
|
131
|
+
if func_key not in catches:
|
|
132
|
+
catches[func_key] = []
|
|
133
|
+
catches[func_key].append(catch_site)
|
|
134
|
+
|
|
135
|
+
return catches
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def expand_polymorphic_call(
|
|
139
|
+
callee: str,
|
|
140
|
+
hierarchy: ClassHierarchy,
|
|
141
|
+
method_to_qualified: dict[str, list[str]],
|
|
142
|
+
) -> list[str]:
|
|
143
|
+
"""Expand a polymorphic method call to all concrete implementations.
|
|
144
|
+
|
|
145
|
+
If callee is a method on an abstract class, returns qualified names of
|
|
146
|
+
all concrete implementations. Otherwise returns [callee].
|
|
147
|
+
"""
|
|
148
|
+
if "." not in callee:
|
|
149
|
+
return [callee]
|
|
150
|
+
|
|
151
|
+
parts = callee.split(".")
|
|
152
|
+
if len(parts) < 2:
|
|
153
|
+
return [callee]
|
|
154
|
+
|
|
155
|
+
method_name = parts[-1]
|
|
156
|
+
class_name = parts[-2] if len(parts) >= 2 else None
|
|
157
|
+
|
|
158
|
+
if not class_name:
|
|
159
|
+
return [callee]
|
|
160
|
+
|
|
161
|
+
if not hierarchy.is_abstract_method(class_name, method_name):
|
|
162
|
+
return [callee]
|
|
163
|
+
|
|
164
|
+
implementations = hierarchy.get_concrete_implementations(class_name, method_name)
|
|
165
|
+
if not implementations:
|
|
166
|
+
return [callee]
|
|
167
|
+
|
|
168
|
+
result: list[str] = []
|
|
169
|
+
for impl_class, _ in implementations:
|
|
170
|
+
for qualified in method_to_qualified.get(method_name, []):
|
|
171
|
+
if impl_class in qualified:
|
|
172
|
+
result.append(qualified)
|
|
173
|
+
break
|
|
174
|
+
else:
|
|
175
|
+
result.append(f"{impl_class}.{method_name}")
|
|
176
|
+
|
|
177
|
+
return result if result else [callee]
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def exception_is_caught(
|
|
181
|
+
exception_type: str,
|
|
182
|
+
catch_site: CatchSite,
|
|
183
|
+
hierarchy: ClassHierarchy,
|
|
184
|
+
) -> bool:
|
|
185
|
+
"""Check if an exception type would be caught by a catch site."""
|
|
186
|
+
if catch_site.has_bare_except:
|
|
187
|
+
return True
|
|
188
|
+
|
|
189
|
+
exception_simple = exception_type.split(".")[-1]
|
|
190
|
+
|
|
191
|
+
for caught_type in catch_site.caught_types:
|
|
192
|
+
caught_simple = caught_type.split(".")[-1]
|
|
193
|
+
|
|
194
|
+
if exception_type == caught_type or exception_simple == caught_simple:
|
|
195
|
+
return True
|
|
196
|
+
|
|
197
|
+
if caught_simple in ("Exception", "BaseException"):
|
|
198
|
+
return True
|
|
199
|
+
|
|
200
|
+
if hierarchy.is_subclass_of(exception_simple, caught_simple):
|
|
201
|
+
return True
|
|
202
|
+
|
|
203
|
+
return False
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _build_call_site_lookup(
|
|
207
|
+
model: ProgramModel,
|
|
208
|
+
) -> dict[tuple[str, str], list[CallSite]]:
|
|
209
|
+
"""Build a lookup from (caller, callee) pairs to CallSite objects."""
|
|
210
|
+
lookup: dict[tuple[str, str], list[CallSite]] = {}
|
|
211
|
+
for cs in model.call_sites:
|
|
212
|
+
caller = cs.caller_qualified or f"{cs.file}::{cs.caller_function}"
|
|
213
|
+
callee = cs.callee_qualified or cs.callee_name
|
|
214
|
+
key = (caller, callee)
|
|
215
|
+
if key not in lookup:
|
|
216
|
+
lookup[key] = []
|
|
217
|
+
lookup[key].append(cs)
|
|
218
|
+
return lookup
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _build_raise_site_lookup(
|
|
222
|
+
model: ProgramModel,
|
|
223
|
+
) -> dict[tuple[str, str, int], RaiseSite]:
|
|
224
|
+
"""Build a lookup from (exc_type, file, line) to RaiseSite."""
|
|
225
|
+
lookup: dict[tuple[str, str, int], RaiseSite] = {}
|
|
226
|
+
for rs in model.raise_sites:
|
|
227
|
+
lookup[(rs.exception_type, rs.file, rs.line)] = rs
|
|
228
|
+
return lookup
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _create_resolution_edge(
|
|
232
|
+
call_site: CallSite,
|
|
233
|
+
caller: str,
|
|
234
|
+
callee: str,
|
|
235
|
+
used_name_fallback: bool,
|
|
236
|
+
is_polymorphic: bool,
|
|
237
|
+
match_count: int = 1,
|
|
238
|
+
) -> ResolutionEdge:
|
|
239
|
+
"""Create a ResolutionEdge from a CallSite."""
|
|
240
|
+
if used_name_fallback:
|
|
241
|
+
kind = ResolutionKind.NAME_FALLBACK
|
|
242
|
+
elif is_polymorphic:
|
|
243
|
+
kind = ResolutionKind.POLYMORPHIC
|
|
244
|
+
else:
|
|
245
|
+
kind = call_site.resolution_kind
|
|
246
|
+
|
|
247
|
+
is_heuristic = kind in (ResolutionKind.NAME_FALLBACK, ResolutionKind.POLYMORPHIC)
|
|
248
|
+
|
|
249
|
+
return ResolutionEdge(
|
|
250
|
+
caller=caller,
|
|
251
|
+
callee=callee,
|
|
252
|
+
file=call_site.file,
|
|
253
|
+
line=call_site.line,
|
|
254
|
+
resolution_kind=kind,
|
|
255
|
+
is_heuristic=is_heuristic,
|
|
256
|
+
match_count=match_count,
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
FallbackKey = tuple[str, bool]
|
|
261
|
+
FallbackCacheKey = tuple[str, bool, str]
|
|
262
|
+
_fallback_cache: dict[FallbackCacheKey, tuple[list[str], str]] = {}
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _scoped_fallback_lookup(
|
|
266
|
+
callee_simple: str,
|
|
267
|
+
is_method: bool,
|
|
268
|
+
caller_file: str,
|
|
269
|
+
import_map: dict[str, str],
|
|
270
|
+
name_to_qualified: dict[FallbackKey, list[str]],
|
|
271
|
+
) -> tuple[list[str], str]:
|
|
272
|
+
"""Scoped fallback: same_file > direct_import > same_package > project."""
|
|
273
|
+
cache_key: FallbackCacheKey = (callee_simple, is_method, caller_file)
|
|
274
|
+
if cache_key in _fallback_cache:
|
|
275
|
+
return _fallback_cache[cache_key]
|
|
276
|
+
|
|
277
|
+
fallback_key = (callee_simple, is_method)
|
|
278
|
+
candidates = name_to_qualified.get(fallback_key, [])
|
|
279
|
+
|
|
280
|
+
if not candidates:
|
|
281
|
+
result = ([], "none")
|
|
282
|
+
_fallback_cache[cache_key] = result
|
|
283
|
+
return result
|
|
284
|
+
|
|
285
|
+
same_file = [c for c in candidates if c.startswith(f"{caller_file}::")]
|
|
286
|
+
if same_file:
|
|
287
|
+
result = (same_file, "same_file")
|
|
288
|
+
_fallback_cache[cache_key] = result
|
|
289
|
+
return result
|
|
290
|
+
|
|
291
|
+
imported_modules = set(import_map.values())
|
|
292
|
+
direct_imports = [c for c in candidates if any(c.startswith(mod) for mod in imported_modules)]
|
|
293
|
+
if direct_imports:
|
|
294
|
+
result = (direct_imports, "direct_import")
|
|
295
|
+
_fallback_cache[cache_key] = result
|
|
296
|
+
return result
|
|
297
|
+
|
|
298
|
+
caller_dir = "/".join(caller_file.split("/")[:-1]) if "/" in caller_file else ""
|
|
299
|
+
if caller_dir:
|
|
300
|
+
same_package = [c for c in candidates if c.split("::")[0].startswith(caller_dir + "/")]
|
|
301
|
+
if same_package:
|
|
302
|
+
result = (same_package, "same_package")
|
|
303
|
+
_fallback_cache[cache_key] = result
|
|
304
|
+
return result
|
|
305
|
+
|
|
306
|
+
result = (candidates, "project")
|
|
307
|
+
_fallback_cache[cache_key] = result
|
|
308
|
+
return result
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def propagate_exceptions(
|
|
312
|
+
model: ProgramModel,
|
|
313
|
+
max_iterations: int = 100,
|
|
314
|
+
resolution_mode: ResolutionMode = ResolutionMode.DEFAULT,
|
|
315
|
+
stub_library: StubLibrary | None = None,
|
|
316
|
+
skip_evidence: bool = False,
|
|
317
|
+
) -> PropagationResult:
|
|
318
|
+
"""
|
|
319
|
+
Propagate exceptions through the call graph.
|
|
320
|
+
|
|
321
|
+
For each function, compute the set of exceptions that can escape from it,
|
|
322
|
+
taking into account what it catches.
|
|
323
|
+
|
|
324
|
+
Args:
|
|
325
|
+
skip_evidence: If True, skip building evidence paths for faster propagation.
|
|
326
|
+
Use for audit commands where only exception types matter.
|
|
327
|
+
|
|
328
|
+
Resolution modes:
|
|
329
|
+
- strict: Only follow resolved calls (no name_fallback or polymorphic)
|
|
330
|
+
- default: Normal propagation with name fallback
|
|
331
|
+
- aggressive: Include fuzzy matching (not yet implemented)
|
|
332
|
+
"""
|
|
333
|
+
from bubble import timing
|
|
334
|
+
|
|
335
|
+
cache_key = (
|
|
336
|
+
id(model),
|
|
337
|
+
resolution_mode,
|
|
338
|
+
id(stub_library) if stub_library else None,
|
|
339
|
+
skip_evidence,
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
if cache_key in _propagation_cache:
|
|
343
|
+
return _propagation_cache[cache_key]
|
|
344
|
+
|
|
345
|
+
with timing.timed("propagation_setup"):
|
|
346
|
+
direct_raises = compute_direct_raises(model)
|
|
347
|
+
catches_by_function = compute_catches_by_function(model)
|
|
348
|
+
forward_graph = build_forward_call_graph(model)
|
|
349
|
+
call_site_lookup = _build_call_site_lookup(model) if not skip_evidence else {}
|
|
350
|
+
|
|
351
|
+
propagated: dict[str, set[str]] = {}
|
|
352
|
+
propagated_evidence: dict[str, dict[tuple[str, str, int], PropagatedRaise]] = {}
|
|
353
|
+
|
|
354
|
+
for func, raises in direct_raises.items():
|
|
355
|
+
propagated[func] = raises.copy()
|
|
356
|
+
if skip_evidence:
|
|
357
|
+
continue
|
|
358
|
+
propagated_evidence[func] = {}
|
|
359
|
+
for exc_type in raises:
|
|
360
|
+
for rs in model.raise_sites:
|
|
361
|
+
if f"{rs.file}::{rs.function}" == func and rs.exception_type == exc_type:
|
|
362
|
+
key = (exc_type, rs.file, rs.line)
|
|
363
|
+
propagated_evidence[func][key] = PropagatedRaise(
|
|
364
|
+
exception_type=exc_type,
|
|
365
|
+
raise_site=rs,
|
|
366
|
+
path=(),
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
name_to_qualified: dict[FallbackKey, list[str]] = {}
|
|
370
|
+
method_to_qualified: dict[str, list[str]] = {}
|
|
371
|
+
for qualified_key in propagated:
|
|
372
|
+
simple_name = qualified_key.split("::")[-1].split(".")[-1]
|
|
373
|
+
is_method = "." in qualified_key.split("::")[-1] if "::" in qualified_key else False
|
|
374
|
+
fallback_key: FallbackKey = (simple_name, is_method)
|
|
375
|
+
if fallback_key not in name_to_qualified:
|
|
376
|
+
name_to_qualified[fallback_key] = []
|
|
377
|
+
name_to_qualified[fallback_key].append(qualified_key)
|
|
378
|
+
|
|
379
|
+
if "::" in qualified_key:
|
|
380
|
+
method_name = qualified_key.split("::")[-1].split(".")[-1]
|
|
381
|
+
if method_name not in method_to_qualified:
|
|
382
|
+
method_to_qualified[method_name] = []
|
|
383
|
+
method_to_qualified[method_name].append(qualified_key)
|
|
384
|
+
|
|
385
|
+
with timing.timed("propagation_fixpoint"):
|
|
386
|
+
iteration_count = 0
|
|
387
|
+
total_fallback_lookups = 0
|
|
388
|
+
total_catch_checks = 0
|
|
389
|
+
total_propagations = 0
|
|
390
|
+
|
|
391
|
+
for _ in range(max_iterations):
|
|
392
|
+
iteration_count += 1
|
|
393
|
+
changed = False
|
|
394
|
+
|
|
395
|
+
for caller, callees in forward_graph.items():
|
|
396
|
+
if caller not in propagated:
|
|
397
|
+
propagated[caller] = set()
|
|
398
|
+
if not skip_evidence and caller not in propagated_evidence:
|
|
399
|
+
propagated_evidence[caller] = {}
|
|
400
|
+
|
|
401
|
+
for callee in callees:
|
|
402
|
+
call_sites = call_site_lookup.get((caller, callee), []) if not skip_evidence else []
|
|
403
|
+
call_site = call_sites[0] if call_sites else None
|
|
404
|
+
expanded_callees = expand_polymorphic_call(
|
|
405
|
+
callee, model.exception_hierarchy, method_to_qualified
|
|
406
|
+
)
|
|
407
|
+
is_polymorphic = len(expanded_callees) > 1
|
|
408
|
+
|
|
409
|
+
for expanded_callee in expanded_callees:
|
|
410
|
+
used_name_fallback = False
|
|
411
|
+
fallback_match_count = 1
|
|
412
|
+
callee_exceptions = propagated.get(expanded_callee, set())
|
|
413
|
+
callee_evidence = propagated_evidence.get(expanded_callee, {}) if not skip_evidence else {}
|
|
414
|
+
|
|
415
|
+
if not callee_exceptions:
|
|
416
|
+
callee_simple = (
|
|
417
|
+
expanded_callee.split("::")[-1].split(".")[-1]
|
|
418
|
+
if "::" in expanded_callee
|
|
419
|
+
else expanded_callee.split(".")[-1]
|
|
420
|
+
)
|
|
421
|
+
is_method = call_site.is_method_call if call_site else False
|
|
422
|
+
caller_file = caller.split("::")[0] if "::" in caller else caller
|
|
423
|
+
import_map = model.import_maps.get(caller_file, {})
|
|
424
|
+
|
|
425
|
+
total_fallback_lookups += 1
|
|
426
|
+
matched_keys, _ = _scoped_fallback_lookup(
|
|
427
|
+
callee_simple,
|
|
428
|
+
is_method,
|
|
429
|
+
caller_file,
|
|
430
|
+
import_map,
|
|
431
|
+
name_to_qualified,
|
|
432
|
+
)
|
|
433
|
+
fallback_match_count = len(matched_keys) if matched_keys else 1
|
|
434
|
+
|
|
435
|
+
for qualified_key in matched_keys:
|
|
436
|
+
callee_exceptions = callee_exceptions | propagated.get(
|
|
437
|
+
qualified_key, set()
|
|
438
|
+
)
|
|
439
|
+
callee_evidence = {
|
|
440
|
+
**callee_evidence,
|
|
441
|
+
**propagated_evidence.get(qualified_key, {}),
|
|
442
|
+
}
|
|
443
|
+
if callee_exceptions:
|
|
444
|
+
used_name_fallback = True
|
|
445
|
+
|
|
446
|
+
if stub_library and not callee_exceptions:
|
|
447
|
+
callee_parts = expanded_callee.split(".")
|
|
448
|
+
if len(callee_parts) >= 2:
|
|
449
|
+
module = callee_parts[0]
|
|
450
|
+
func = callee_parts[-1]
|
|
451
|
+
stub_exceptions = stub_library.get_raises(module, func)
|
|
452
|
+
if stub_exceptions:
|
|
453
|
+
callee_exceptions = set(stub_exceptions)
|
|
454
|
+
|
|
455
|
+
if resolution_mode == ResolutionMode.STRICT and (
|
|
456
|
+
used_name_fallback or is_polymorphic
|
|
457
|
+
):
|
|
458
|
+
continue
|
|
459
|
+
|
|
460
|
+
for exc_type in callee_exceptions:
|
|
461
|
+
catches = catches_by_function.get(caller, [])
|
|
462
|
+
is_caught = False
|
|
463
|
+
|
|
464
|
+
for catch_site in catches:
|
|
465
|
+
total_catch_checks += 1
|
|
466
|
+
if exception_is_caught(
|
|
467
|
+
exc_type, catch_site, model.exception_hierarchy
|
|
468
|
+
):
|
|
469
|
+
if not catch_site.has_reraise:
|
|
470
|
+
is_caught = True
|
|
471
|
+
break
|
|
472
|
+
|
|
473
|
+
if not is_caught and exc_type not in propagated[caller]:
|
|
474
|
+
propagated[caller].add(exc_type)
|
|
475
|
+
total_propagations += 1
|
|
476
|
+
changed = True
|
|
477
|
+
|
|
478
|
+
caller_simple = (
|
|
479
|
+
caller.split("::")[-1].split(".")[-1]
|
|
480
|
+
if "::" in caller
|
|
481
|
+
else caller
|
|
482
|
+
)
|
|
483
|
+
caller_is_method = (
|
|
484
|
+
"." in caller.split("::")[-1] if "::" in caller else False
|
|
485
|
+
)
|
|
486
|
+
caller_fallback_key: FallbackKey = (
|
|
487
|
+
caller_simple,
|
|
488
|
+
caller_is_method,
|
|
489
|
+
)
|
|
490
|
+
if caller_fallback_key not in name_to_qualified:
|
|
491
|
+
name_to_qualified[caller_fallback_key] = []
|
|
492
|
+
if caller not in name_to_qualified[caller_fallback_key]:
|
|
493
|
+
name_to_qualified[caller_fallback_key].append(caller)
|
|
494
|
+
|
|
495
|
+
if not is_caught and not skip_evidence:
|
|
496
|
+
for key, prop_raise in callee_evidence.items():
|
|
497
|
+
if key[0] != exc_type:
|
|
498
|
+
continue
|
|
499
|
+
if key in propagated_evidence[caller]:
|
|
500
|
+
continue
|
|
501
|
+
if call_site is None:
|
|
502
|
+
continue
|
|
503
|
+
edge = _create_resolution_edge(
|
|
504
|
+
call_site,
|
|
505
|
+
caller,
|
|
506
|
+
expanded_callee,
|
|
507
|
+
used_name_fallback,
|
|
508
|
+
is_polymorphic,
|
|
509
|
+
fallback_match_count,
|
|
510
|
+
)
|
|
511
|
+
new_path = (edge,) + prop_raise.path
|
|
512
|
+
propagated_evidence[caller][key] = PropagatedRaise(
|
|
513
|
+
exception_type=exc_type,
|
|
514
|
+
raise_site=prop_raise.raise_site,
|
|
515
|
+
path=new_path,
|
|
516
|
+
)
|
|
517
|
+
|
|
518
|
+
if not changed:
|
|
519
|
+
break
|
|
520
|
+
|
|
521
|
+
if timing.is_enabled():
|
|
522
|
+
timing.record_count("propagation_iterations", iteration_count)
|
|
523
|
+
timing.record_count("propagation_fallback_lookups", total_fallback_lookups)
|
|
524
|
+
timing.record_count("propagation_catch_checks", total_catch_checks)
|
|
525
|
+
timing.record_count("propagation_new_exceptions", total_propagations)
|
|
526
|
+
timing.record_count("propagation_call_graph_size", len(forward_graph))
|
|
527
|
+
timing.record_count("propagation_functions_with_raises", len(propagated))
|
|
528
|
+
|
|
529
|
+
result = PropagationResult(
|
|
530
|
+
direct_raises=direct_raises,
|
|
531
|
+
propagated_raises=propagated,
|
|
532
|
+
catches_by_function=catches_by_function,
|
|
533
|
+
propagated_with_evidence=propagated_evidence,
|
|
534
|
+
)
|
|
535
|
+
|
|
536
|
+
_propagation_cache[cache_key] = result
|
|
537
|
+
return result
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def clear_propagation_cache() -> None:
|
|
541
|
+
"""Clear the propagation cache.
|
|
542
|
+
|
|
543
|
+
Call this when memory management is needed or in tests to ensure
|
|
544
|
+
fresh propagation results.
|
|
545
|
+
"""
|
|
546
|
+
_propagation_cache.clear()
|
|
547
|
+
_fallback_cache.clear()
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
def compute_reachable_functions(
|
|
551
|
+
start_func: str,
|
|
552
|
+
model: ProgramModel,
|
|
553
|
+
propagation: PropagationResult,
|
|
554
|
+
forward_graph: dict[str, set[str]] | None = None,
|
|
555
|
+
name_to_qualified: dict[str, list[str]] | None = None,
|
|
556
|
+
) -> set[str]:
|
|
557
|
+
"""Compute all functions reachable from a starting function via the call graph.
|
|
558
|
+
|
|
559
|
+
For better performance when calling repeatedly, pre-compute forward_graph and
|
|
560
|
+
name_to_qualified once and pass them in.
|
|
561
|
+
"""
|
|
562
|
+
if forward_graph is None:
|
|
563
|
+
forward_graph = build_forward_call_graph(model)
|
|
564
|
+
|
|
565
|
+
if name_to_qualified is None:
|
|
566
|
+
name_to_qualified = {}
|
|
567
|
+
for key in propagation.propagated_raises:
|
|
568
|
+
simple = key.split("::")[-1].split(".")[-1] if "::" in key else key.split(".")[-1]
|
|
569
|
+
if simple not in name_to_qualified:
|
|
570
|
+
name_to_qualified[simple] = []
|
|
571
|
+
name_to_qualified[simple].append(key)
|
|
572
|
+
|
|
573
|
+
simple_to_qualified_graph: dict[str, list[str]] = {}
|
|
574
|
+
for key in forward_graph:
|
|
575
|
+
simple = key.split("::")[-1].split(".")[-1] if "::" in key else key.split(".")[-1]
|
|
576
|
+
if simple not in simple_to_qualified_graph:
|
|
577
|
+
simple_to_qualified_graph[simple] = []
|
|
578
|
+
simple_to_qualified_graph[simple].append(key)
|
|
579
|
+
|
|
580
|
+
reachable: set[str] = set()
|
|
581
|
+
worklist = [start_func]
|
|
582
|
+
|
|
583
|
+
while worklist:
|
|
584
|
+
current = worklist.pop()
|
|
585
|
+
if current in reachable:
|
|
586
|
+
continue
|
|
587
|
+
reachable.add(current)
|
|
588
|
+
|
|
589
|
+
current_simple = (
|
|
590
|
+
current.split("::")[-1].split(".")[-1] if "::" in current else current.split(".")[-1]
|
|
591
|
+
)
|
|
592
|
+
reachable.add(current_simple)
|
|
593
|
+
|
|
594
|
+
callees = forward_graph.get(current, set())
|
|
595
|
+
if not callees:
|
|
596
|
+
for qualified_key in simple_to_qualified_graph.get(current_simple, []):
|
|
597
|
+
callees = forward_graph.get(qualified_key, set())
|
|
598
|
+
if callees:
|
|
599
|
+
break
|
|
600
|
+
|
|
601
|
+
for callee in callees:
|
|
602
|
+
expanded = expand_polymorphic_call(
|
|
603
|
+
callee,
|
|
604
|
+
model.exception_hierarchy,
|
|
605
|
+
name_to_qualified,
|
|
606
|
+
)
|
|
607
|
+
|
|
608
|
+
for impl in expanded:
|
|
609
|
+
if impl not in reachable:
|
|
610
|
+
worklist.append(impl)
|
|
611
|
+
impl_simple = (
|
|
612
|
+
impl.split("::")[-1].split(".")[-1] if "::" in impl else impl.split(".")[-1]
|
|
613
|
+
)
|
|
614
|
+
for qualified in name_to_qualified.get(impl_simple, []):
|
|
615
|
+
if qualified not in reachable:
|
|
616
|
+
worklist.append(qualified)
|
|
617
|
+
|
|
618
|
+
return reachable
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
def compute_exception_flow(
|
|
622
|
+
function_name: str,
|
|
623
|
+
model: ProgramModel,
|
|
624
|
+
propagation: PropagationResult,
|
|
625
|
+
detected_frameworks: set[str] | None = None,
|
|
626
|
+
get_framework_response: Callable[[str], str | None] | None = None,
|
|
627
|
+
) -> ExceptionFlow:
|
|
628
|
+
"""
|
|
629
|
+
Compute the exception flow for a specific function.
|
|
630
|
+
|
|
631
|
+
This is the core (framework-agnostic) version. For integration-aware
|
|
632
|
+
exception flow, use flow.integrations.queries._compute_exception_flow_for_integration.
|
|
633
|
+
|
|
634
|
+
Args:
|
|
635
|
+
function_name: Name of the function to analyze
|
|
636
|
+
model: The program model
|
|
637
|
+
propagation: Propagation analysis results
|
|
638
|
+
detected_frameworks: (Deprecated) Set of detected frameworks
|
|
639
|
+
get_framework_response: Optional callback to check framework-handled exceptions
|
|
640
|
+
|
|
641
|
+
Returns:
|
|
642
|
+
ExceptionFlow with exceptions categorized as:
|
|
643
|
+
- caught_by_global: Caught by global handlers
|
|
644
|
+
- framework_handled: Converted to HTTP response (if get_framework_response provided)
|
|
645
|
+
- uncaught: Will escape
|
|
646
|
+
"""
|
|
647
|
+
_ = detected_frameworks
|
|
648
|
+
|
|
649
|
+
flow = ExceptionFlow()
|
|
650
|
+
|
|
651
|
+
func_key = None
|
|
652
|
+
for key in propagation.propagated_raises:
|
|
653
|
+
if key.endswith(f"::{function_name}") or key.endswith(f".{function_name}"):
|
|
654
|
+
func_key = key
|
|
655
|
+
break
|
|
656
|
+
if "::" in key and key.split("::")[-1].split(".")[-1] == function_name:
|
|
657
|
+
func_key = key
|
|
658
|
+
break
|
|
659
|
+
|
|
660
|
+
if func_key is None:
|
|
661
|
+
return flow
|
|
662
|
+
|
|
663
|
+
reachable = compute_reachable_functions(func_key, model, propagation)
|
|
664
|
+
|
|
665
|
+
escaping_exceptions = propagation.propagated_raises.get(func_key, set())
|
|
666
|
+
func_evidence = propagation.propagated_with_evidence.get(func_key, {})
|
|
667
|
+
|
|
668
|
+
global_handler_types: dict[str, GlobalHandler] = {}
|
|
669
|
+
for handler in model.global_handlers:
|
|
670
|
+
global_handler_types[handler.handled_type] = handler
|
|
671
|
+
global_handler_types[handler.handled_type.split(".")[-1]] = handler
|
|
672
|
+
|
|
673
|
+
for exc_type in escaping_exceptions:
|
|
674
|
+
exc_simple = exc_type.split(".")[-1]
|
|
675
|
+
|
|
676
|
+
raise_sites = [
|
|
677
|
+
r
|
|
678
|
+
for r in model.raise_sites
|
|
679
|
+
if (r.exception_type == exc_type or r.exception_type.split(".")[-1] == exc_simple)
|
|
680
|
+
and (r.function in reachable or f"{r.file}::{r.function}" in reachable)
|
|
681
|
+
]
|
|
682
|
+
|
|
683
|
+
evidence_list: list[ExceptionEvidence] = []
|
|
684
|
+
for key, prop_raise in func_evidence.items():
|
|
685
|
+
if key[0] == exc_type:
|
|
686
|
+
evidence_list.append(
|
|
687
|
+
ExceptionEvidence(
|
|
688
|
+
raise_site=prop_raise.raise_site,
|
|
689
|
+
call_path=list(prop_raise.path),
|
|
690
|
+
confidence=compute_confidence(list(prop_raise.path)),
|
|
691
|
+
)
|
|
692
|
+
)
|
|
693
|
+
|
|
694
|
+
if evidence_list:
|
|
695
|
+
if exc_type not in flow.evidence:
|
|
696
|
+
flow.evidence[exc_type] = []
|
|
697
|
+
flow.evidence[exc_type].extend(evidence_list)
|
|
698
|
+
|
|
699
|
+
caught_by_handler = None
|
|
700
|
+
for handler_type, handler in global_handler_types.items():
|
|
701
|
+
handler_simple = handler_type.split(".")[-1]
|
|
702
|
+
if exc_simple == handler_simple:
|
|
703
|
+
caught_by_handler = handler
|
|
704
|
+
break
|
|
705
|
+
if model.exception_hierarchy.is_subclass_of(exc_simple, handler_simple):
|
|
706
|
+
caught_by_handler = handler
|
|
707
|
+
break
|
|
708
|
+
|
|
709
|
+
if caught_by_handler:
|
|
710
|
+
if exc_type not in flow.caught_by_global:
|
|
711
|
+
flow.caught_by_global[exc_type] = []
|
|
712
|
+
flow.caught_by_global[exc_type].extend(raise_sites)
|
|
713
|
+
continue
|
|
714
|
+
|
|
715
|
+
if get_framework_response:
|
|
716
|
+
framework_response = get_framework_response(exc_type)
|
|
717
|
+
if framework_response:
|
|
718
|
+
if exc_type not in flow.framework_handled:
|
|
719
|
+
flow.framework_handled[exc_type] = []
|
|
720
|
+
for rs in raise_sites:
|
|
721
|
+
flow.framework_handled[exc_type].append((rs, framework_response))
|
|
722
|
+
continue
|
|
723
|
+
|
|
724
|
+
if exc_type not in flow.uncaught:
|
|
725
|
+
flow.uncaught[exc_type] = []
|
|
726
|
+
flow.uncaught[exc_type].extend(raise_sites)
|
|
727
|
+
|
|
728
|
+
return flow
|
|
729
|
+
|
|
730
|
+
|
|
731
|
+
def get_exceptions_for_entrypoint(
|
|
732
|
+
entrypoint_function: str,
|
|
733
|
+
model: ProgramModel,
|
|
734
|
+
) -> ExceptionFlow:
|
|
735
|
+
"""Get the exception flow for an entrypoint (deprecated, use integrations instead)."""
|
|
736
|
+
propagation = propagate_exceptions(model)
|
|
737
|
+
return compute_exception_flow(entrypoint_function, model, propagation)
|