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.
Files changed (46) hide show
  1. bubble/__init__.py +3 -0
  2. bubble/cache.py +207 -0
  3. bubble/cli.py +470 -0
  4. bubble/config.py +52 -0
  5. bubble/detectors.py +90 -0
  6. bubble/enums.py +65 -0
  7. bubble/extractor.py +829 -0
  8. bubble/formatters.py +887 -0
  9. bubble/integrations/__init__.py +92 -0
  10. bubble/integrations/base.py +98 -0
  11. bubble/integrations/cli_scripts/__init__.py +49 -0
  12. bubble/integrations/cli_scripts/cli.py +108 -0
  13. bubble/integrations/cli_scripts/detector.py +149 -0
  14. bubble/integrations/django/__init__.py +63 -0
  15. bubble/integrations/django/cli.py +111 -0
  16. bubble/integrations/django/detector.py +331 -0
  17. bubble/integrations/django/semantics.py +40 -0
  18. bubble/integrations/fastapi/__init__.py +57 -0
  19. bubble/integrations/fastapi/cli.py +110 -0
  20. bubble/integrations/fastapi/detector.py +176 -0
  21. bubble/integrations/fastapi/semantics.py +14 -0
  22. bubble/integrations/flask/__init__.py +57 -0
  23. bubble/integrations/flask/cli.py +110 -0
  24. bubble/integrations/flask/detector.py +191 -0
  25. bubble/integrations/flask/semantics.py +19 -0
  26. bubble/integrations/formatters.py +268 -0
  27. bubble/integrations/generic/__init__.py +13 -0
  28. bubble/integrations/generic/config.py +106 -0
  29. bubble/integrations/generic/detector.py +346 -0
  30. bubble/integrations/generic/frameworks.py +145 -0
  31. bubble/integrations/models.py +68 -0
  32. bubble/integrations/queries.py +481 -0
  33. bubble/loader.py +118 -0
  34. bubble/models.py +397 -0
  35. bubble/propagation.py +737 -0
  36. bubble/protocols.py +104 -0
  37. bubble/queries.py +627 -0
  38. bubble/results.py +211 -0
  39. bubble/stubs.py +89 -0
  40. bubble/timing.py +144 -0
  41. bubble_analysis-0.2.0.dist-info/METADATA +264 -0
  42. bubble_analysis-0.2.0.dist-info/RECORD +46 -0
  43. bubble_analysis-0.2.0.dist-info/WHEEL +5 -0
  44. bubble_analysis-0.2.0.dist-info/entry_points.txt +2 -0
  45. bubble_analysis-0.2.0.dist-info/licenses/LICENSE +21 -0
  46. 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)