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/queries.py
ADDED
|
@@ -0,0 +1,627 @@
|
|
|
1
|
+
"""Query functions for analyzing the program model.
|
|
2
|
+
|
|
3
|
+
Each function takes a ProgramModel and query parameters,
|
|
4
|
+
and returns a typed result dataclass. No formatting here.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from difflib import get_close_matches
|
|
8
|
+
|
|
9
|
+
from bubble.enums import EntrypointKind, Framework, ResolutionMode
|
|
10
|
+
from bubble.models import Entrypoint, ProgramModel
|
|
11
|
+
from bubble.propagation import (
|
|
12
|
+
build_forward_call_graph,
|
|
13
|
+
build_reverse_call_graph,
|
|
14
|
+
compute_exception_flow,
|
|
15
|
+
propagate_exceptions,
|
|
16
|
+
)
|
|
17
|
+
from bubble.results import (
|
|
18
|
+
AuditIssue,
|
|
19
|
+
AuditResult,
|
|
20
|
+
CallersResult,
|
|
21
|
+
CatchesResult,
|
|
22
|
+
EntrypointsResult,
|
|
23
|
+
EntrypointsToResult,
|
|
24
|
+
EntrypointTrace,
|
|
25
|
+
EscapesResult,
|
|
26
|
+
ExceptionClass,
|
|
27
|
+
ExceptionsResult,
|
|
28
|
+
InitResult,
|
|
29
|
+
PolymorphicNode,
|
|
30
|
+
RaisesResult,
|
|
31
|
+
StatsResult,
|
|
32
|
+
SubclassesResult,
|
|
33
|
+
SubclassInfo,
|
|
34
|
+
TraceNode,
|
|
35
|
+
TraceResult,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def find_similar_names(target: str, candidates: list[str], n: int = 3) -> list[str]:
|
|
40
|
+
"""Find similar names using fuzzy matching."""
|
|
41
|
+
return get_close_matches(target, candidates, n=n, cutoff=0.5)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def find_raises(
|
|
45
|
+
model: ProgramModel,
|
|
46
|
+
exception_type: str,
|
|
47
|
+
include_subclasses: bool = False,
|
|
48
|
+
) -> RaisesResult:
|
|
49
|
+
"""Find all raise sites matching an exception type."""
|
|
50
|
+
types_to_find: set[str] = {exception_type}
|
|
51
|
+
if include_subclasses:
|
|
52
|
+
subclasses = model.exception_hierarchy.get_subclasses(exception_type)
|
|
53
|
+
types_to_find.update(subclasses)
|
|
54
|
+
|
|
55
|
+
matching_raises = [
|
|
56
|
+
r
|
|
57
|
+
for r in model.raise_sites
|
|
58
|
+
if r.exception_type in types_to_find
|
|
59
|
+
or r.exception_type.endswith(f".{exception_type}")
|
|
60
|
+
or any(r.exception_type.endswith(f".{t}") for t in types_to_find)
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
for t in list(types_to_find):
|
|
64
|
+
for r in model.raise_sites:
|
|
65
|
+
simple_name = r.exception_type.split(".")[-1]
|
|
66
|
+
if simple_name == t and r not in matching_raises:
|
|
67
|
+
matching_raises.append(r)
|
|
68
|
+
|
|
69
|
+
return RaisesResult(
|
|
70
|
+
exception_type=exception_type,
|
|
71
|
+
include_subclasses=include_subclasses,
|
|
72
|
+
types_searched=types_to_find,
|
|
73
|
+
matches=matching_raises,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def find_exceptions(model: ProgramModel) -> ExceptionsResult:
|
|
78
|
+
"""List the exception hierarchy in the codebase."""
|
|
79
|
+
exception_bases = {"Exception", "BaseException"}
|
|
80
|
+
exception_classes: dict[str, ExceptionClass] = {}
|
|
81
|
+
|
|
82
|
+
for cls in model.exception_hierarchy.classes.values():
|
|
83
|
+
for base in cls.bases:
|
|
84
|
+
base_simple = base.split(".")[-1]
|
|
85
|
+
if (
|
|
86
|
+
base_simple in exception_bases
|
|
87
|
+
or base_simple in exception_classes
|
|
88
|
+
or "Exception" in base
|
|
89
|
+
or "Error" in base
|
|
90
|
+
):
|
|
91
|
+
exception_classes[cls.name] = ExceptionClass(
|
|
92
|
+
name=cls.name,
|
|
93
|
+
bases=cls.bases,
|
|
94
|
+
file=cls.file,
|
|
95
|
+
line=cls.line,
|
|
96
|
+
)
|
|
97
|
+
exception_bases.add(cls.name)
|
|
98
|
+
break
|
|
99
|
+
|
|
100
|
+
roots: set[str] = set()
|
|
101
|
+
for name, exc_class in exception_classes.items():
|
|
102
|
+
base_names = [b.split(".")[-1] for b in exc_class.bases]
|
|
103
|
+
has_parent_in_codebase = any(b in exception_classes for b in base_names)
|
|
104
|
+
if not has_parent_in_codebase:
|
|
105
|
+
roots.add(name)
|
|
106
|
+
|
|
107
|
+
return ExceptionsResult(
|
|
108
|
+
classes=exception_classes,
|
|
109
|
+
roots=roots,
|
|
110
|
+
hierarchy=model.exception_hierarchy,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def get_stats(model: ProgramModel) -> StatsResult:
|
|
115
|
+
"""Get codebase statistics."""
|
|
116
|
+
http_routes = [e for e in model.entrypoints if e.kind == EntrypointKind.HTTP_ROUTE]
|
|
117
|
+
cli_scripts = [e for e in model.entrypoints if e.kind == EntrypointKind.CLI_SCRIPT]
|
|
118
|
+
|
|
119
|
+
return StatsResult(
|
|
120
|
+
functions=len(model.functions),
|
|
121
|
+
classes=len(model.classes),
|
|
122
|
+
raise_sites=len(model.raise_sites),
|
|
123
|
+
catch_sites=len(model.catch_sites),
|
|
124
|
+
call_sites=len(model.call_sites),
|
|
125
|
+
entrypoints=len(model.entrypoints),
|
|
126
|
+
http_routes=len(http_routes),
|
|
127
|
+
cli_scripts=len(cli_scripts),
|
|
128
|
+
global_handlers=len(model.global_handlers),
|
|
129
|
+
imports=len(model.imports),
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def find_callers(model: ProgramModel, function_name: str) -> CallersResult:
|
|
134
|
+
"""Find all callers of a function."""
|
|
135
|
+
calls = model.get_callers(function_name)
|
|
136
|
+
|
|
137
|
+
suggestions: list[str] = []
|
|
138
|
+
if not calls:
|
|
139
|
+
all_functions = [f.name for f in model.functions.values()]
|
|
140
|
+
all_entrypoints = [e.function for e in model.entrypoints]
|
|
141
|
+
all_names = list(set(all_functions + all_entrypoints))
|
|
142
|
+
suggestions = find_similar_names(function_name, all_names)
|
|
143
|
+
|
|
144
|
+
return CallersResult(
|
|
145
|
+
function_name=function_name,
|
|
146
|
+
calls=calls,
|
|
147
|
+
suggestions=suggestions,
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def get_callers_from_graphs(
|
|
152
|
+
function_name: str,
|
|
153
|
+
qualified_graph: dict[str, set[str]],
|
|
154
|
+
name_graph: dict[str, set[str]],
|
|
155
|
+
) -> set[str]:
|
|
156
|
+
"""Get callers using qualified graph first, falling back to name graph."""
|
|
157
|
+
callers = qualified_graph.get(function_name, set())
|
|
158
|
+
if not callers:
|
|
159
|
+
simple_name = (
|
|
160
|
+
function_name.split("::")[-1].split(".")[-1] if "::" in function_name else function_name
|
|
161
|
+
)
|
|
162
|
+
callers = name_graph.get(simple_name, set())
|
|
163
|
+
return callers
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def trace_to_entrypoints(
|
|
167
|
+
function_name: str,
|
|
168
|
+
qualified_graph: dict[str, set[str]],
|
|
169
|
+
name_graph: dict[str, set[str]],
|
|
170
|
+
entrypoint_functions: set[str],
|
|
171
|
+
max_depth: int = 20,
|
|
172
|
+
) -> list[list[str]]:
|
|
173
|
+
"""Trace call paths from function to entrypoints."""
|
|
174
|
+
paths: list[list[str]] = []
|
|
175
|
+
|
|
176
|
+
def dfs(current: str, path: list[str], visited: set[str]) -> None:
|
|
177
|
+
if len(path) > max_depth:
|
|
178
|
+
return
|
|
179
|
+
if current in visited:
|
|
180
|
+
return
|
|
181
|
+
visited.add(current)
|
|
182
|
+
|
|
183
|
+
current_simple = current.split("::")[-1] if "::" in current else current
|
|
184
|
+
current_simple = current_simple.split(".")[-1]
|
|
185
|
+
|
|
186
|
+
if current in entrypoint_functions or current_simple in entrypoint_functions:
|
|
187
|
+
paths.append(list(path))
|
|
188
|
+
return
|
|
189
|
+
|
|
190
|
+
callers = get_callers_from_graphs(current, qualified_graph, name_graph)
|
|
191
|
+
for caller in callers:
|
|
192
|
+
dfs(caller, path + [caller], visited.copy())
|
|
193
|
+
|
|
194
|
+
dfs(function_name, [function_name], set())
|
|
195
|
+
return paths
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def trace_entrypoints_to(
|
|
199
|
+
model: ProgramModel,
|
|
200
|
+
exception_type: str,
|
|
201
|
+
include_subclasses: bool = False,
|
|
202
|
+
) -> EntrypointsToResult:
|
|
203
|
+
"""Trace which entrypoints can reach an exception."""
|
|
204
|
+
raises_result = find_raises(model, exception_type, include_subclasses)
|
|
205
|
+
|
|
206
|
+
qualified_graph, name_graph = build_reverse_call_graph(model)
|
|
207
|
+
entrypoint_functions = {e.function for e in model.entrypoints}
|
|
208
|
+
|
|
209
|
+
traces: list[EntrypointTrace] = []
|
|
210
|
+
for raise_site in raises_result.matches:
|
|
211
|
+
paths = trace_to_entrypoints(
|
|
212
|
+
raise_site.function,
|
|
213
|
+
qualified_graph,
|
|
214
|
+
name_graph,
|
|
215
|
+
entrypoint_functions,
|
|
216
|
+
)
|
|
217
|
+
entrypoints_reached: set[str] = set()
|
|
218
|
+
for path in paths:
|
|
219
|
+
if path:
|
|
220
|
+
endpoint = path[-1]
|
|
221
|
+
entrypoints_reached.add(endpoint)
|
|
222
|
+
if "::" in endpoint:
|
|
223
|
+
entrypoints_reached.add(endpoint.split("::")[-1].split(".")[-1])
|
|
224
|
+
|
|
225
|
+
matching_entrypoints = [e for e in model.entrypoints if e.function in entrypoints_reached]
|
|
226
|
+
|
|
227
|
+
traces.append(
|
|
228
|
+
EntrypointTrace(
|
|
229
|
+
raise_site=raise_site,
|
|
230
|
+
paths=paths,
|
|
231
|
+
entrypoints=matching_entrypoints,
|
|
232
|
+
)
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
return EntrypointsToResult(
|
|
236
|
+
exception_type=exception_type,
|
|
237
|
+
include_subclasses=include_subclasses,
|
|
238
|
+
types_searched=raises_result.types_searched,
|
|
239
|
+
traces=traces,
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def list_entrypoints(model: ProgramModel) -> EntrypointsResult:
|
|
244
|
+
"""List all entrypoints in the codebase."""
|
|
245
|
+
http_routes = [e for e in model.entrypoints if e.kind == EntrypointKind.HTTP_ROUTE]
|
|
246
|
+
cli_scripts = [e for e in model.entrypoints if e.kind == EntrypointKind.CLI_SCRIPT]
|
|
247
|
+
|
|
248
|
+
other: dict[str, list[Entrypoint]] = {}
|
|
249
|
+
for e in model.entrypoints:
|
|
250
|
+
if e.kind not in (EntrypointKind.HTTP_ROUTE, EntrypointKind.CLI_SCRIPT):
|
|
251
|
+
kind = e.kind or EntrypointKind.UNKNOWN
|
|
252
|
+
if kind not in other:
|
|
253
|
+
other[kind] = []
|
|
254
|
+
other[kind].append(e)
|
|
255
|
+
|
|
256
|
+
return EntrypointsResult(
|
|
257
|
+
http_routes=http_routes,
|
|
258
|
+
cli_scripts=cli_scripts,
|
|
259
|
+
other=other,
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def audit_entrypoints(model: ProgramModel) -> AuditResult:
|
|
264
|
+
"""Audit all entrypoints for escaping exceptions."""
|
|
265
|
+
if not model.entrypoints:
|
|
266
|
+
return AuditResult(total_entrypoints=0, issues=[], clean_count=0)
|
|
267
|
+
|
|
268
|
+
propagation = propagate_exceptions(model)
|
|
269
|
+
reraise_patterns = {"Unknown", "e", "ex", "err", "exc", "error", "exception"}
|
|
270
|
+
|
|
271
|
+
issues: list[AuditIssue] = []
|
|
272
|
+
clean_count = 0
|
|
273
|
+
|
|
274
|
+
for entrypoint in model.entrypoints:
|
|
275
|
+
flow = compute_exception_flow(entrypoint.function, model, propagation)
|
|
276
|
+
|
|
277
|
+
if flow.uncaught:
|
|
278
|
+
real_uncaught = {k: v for k, v in flow.uncaught.items() if k not in reraise_patterns}
|
|
279
|
+
|
|
280
|
+
if real_uncaught:
|
|
281
|
+
issues.append(
|
|
282
|
+
AuditIssue(
|
|
283
|
+
entrypoint=entrypoint,
|
|
284
|
+
uncaught=real_uncaught,
|
|
285
|
+
caught=flow.caught_by_global,
|
|
286
|
+
)
|
|
287
|
+
)
|
|
288
|
+
else:
|
|
289
|
+
clean_count += 1
|
|
290
|
+
else:
|
|
291
|
+
clean_count += 1
|
|
292
|
+
|
|
293
|
+
return AuditResult(
|
|
294
|
+
total_entrypoints=len(model.entrypoints),
|
|
295
|
+
issues=issues,
|
|
296
|
+
clean_count=clean_count,
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def find_escapes(
|
|
301
|
+
model: ProgramModel,
|
|
302
|
+
function_name: str,
|
|
303
|
+
resolution_mode: ResolutionMode = ResolutionMode.DEFAULT,
|
|
304
|
+
) -> EscapesResult:
|
|
305
|
+
"""Find exceptions that can escape from a function."""
|
|
306
|
+
entrypoint = None
|
|
307
|
+
for e in model.entrypoints:
|
|
308
|
+
if e.function == function_name:
|
|
309
|
+
entrypoint = e
|
|
310
|
+
break
|
|
311
|
+
|
|
312
|
+
propagation = propagate_exceptions(model, resolution_mode=resolution_mode)
|
|
313
|
+
flow = compute_exception_flow(function_name, model, propagation)
|
|
314
|
+
|
|
315
|
+
return EscapesResult(
|
|
316
|
+
function_name=function_name,
|
|
317
|
+
entrypoint=entrypoint,
|
|
318
|
+
flow=flow,
|
|
319
|
+
global_handlers=list(model.global_handlers),
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def find_catches(
|
|
324
|
+
model: ProgramModel,
|
|
325
|
+
exception_type: str,
|
|
326
|
+
include_subclasses: bool = False,
|
|
327
|
+
) -> CatchesResult:
|
|
328
|
+
"""Find all places where an exception is caught."""
|
|
329
|
+
types_to_find: set[str] = {exception_type}
|
|
330
|
+
if include_subclasses:
|
|
331
|
+
subclasses = model.exception_hierarchy.get_subclasses(exception_type)
|
|
332
|
+
types_to_find.update(subclasses)
|
|
333
|
+
|
|
334
|
+
matching_catches = []
|
|
335
|
+
for catch_site in model.catch_sites:
|
|
336
|
+
for caught_type in catch_site.caught_types:
|
|
337
|
+
caught_simple = caught_type.split(".")[-1]
|
|
338
|
+
if caught_type in types_to_find or caught_simple in types_to_find:
|
|
339
|
+
matching_catches.append(catch_site)
|
|
340
|
+
break
|
|
341
|
+
for t in types_to_find:
|
|
342
|
+
if model.exception_hierarchy.is_subclass_of(t, caught_simple):
|
|
343
|
+
matching_catches.append(catch_site)
|
|
344
|
+
break
|
|
345
|
+
|
|
346
|
+
global_handlers = [
|
|
347
|
+
h
|
|
348
|
+
for h in model.global_handlers
|
|
349
|
+
if h.handled_type in types_to_find
|
|
350
|
+
or h.handled_type.split(".")[-1] in types_to_find
|
|
351
|
+
or any(
|
|
352
|
+
model.exception_hierarchy.is_subclass_of(t, h.handled_type.split(".")[-1])
|
|
353
|
+
for t in types_to_find
|
|
354
|
+
)
|
|
355
|
+
]
|
|
356
|
+
|
|
357
|
+
return CatchesResult(
|
|
358
|
+
exception_type=exception_type,
|
|
359
|
+
include_subclasses=include_subclasses,
|
|
360
|
+
types_searched=types_to_find,
|
|
361
|
+
local_catches=matching_catches,
|
|
362
|
+
global_handlers=global_handlers,
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def find_function_key(
|
|
367
|
+
function_name: str,
|
|
368
|
+
propagated_raises: dict[str, set[str]],
|
|
369
|
+
model: ProgramModel,
|
|
370
|
+
) -> str | None:
|
|
371
|
+
"""Find the qualified key for a function name."""
|
|
372
|
+
for key in propagated_raises:
|
|
373
|
+
if key.endswith(f"::{function_name}") or key.endswith(f".{function_name}"):
|
|
374
|
+
return key
|
|
375
|
+
if "::" in key and key.split("::")[-1].split(".")[-1] == function_name:
|
|
376
|
+
return key
|
|
377
|
+
|
|
378
|
+
for call_site in model.call_sites:
|
|
379
|
+
if call_site.caller_function == function_name:
|
|
380
|
+
return call_site.caller_qualified or f"{call_site.file}::{call_site.caller_function}"
|
|
381
|
+
|
|
382
|
+
return None
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def get_direct_raises_for_key(
|
|
386
|
+
func_key: str,
|
|
387
|
+
direct_raises: dict[str, set[str]],
|
|
388
|
+
) -> set[str]:
|
|
389
|
+
"""Get direct raises for a function key."""
|
|
390
|
+
if func_key in direct_raises:
|
|
391
|
+
return direct_raises[func_key]
|
|
392
|
+
|
|
393
|
+
simple_name = (
|
|
394
|
+
func_key.split("::")[-1].split(".")[-1] if "::" in func_key else func_key.split(".")[-1]
|
|
395
|
+
)
|
|
396
|
+
for key, raises in direct_raises.items():
|
|
397
|
+
key_simple = key.split("::")[-1].split(".")[-1] if "::" in key else key.split(".")[-1]
|
|
398
|
+
if key_simple == simple_name:
|
|
399
|
+
return raises
|
|
400
|
+
|
|
401
|
+
return set()
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def expand_callee(callee: str, model: ProgramModel) -> list[str]:
|
|
405
|
+
"""Expand a callee to concrete implementations if polymorphic."""
|
|
406
|
+
if "." not in callee:
|
|
407
|
+
return [callee]
|
|
408
|
+
|
|
409
|
+
parts = callee.split("::")[-1].split(".") if "::" in callee else callee.split(".")
|
|
410
|
+
if len(parts) < 2:
|
|
411
|
+
return [callee]
|
|
412
|
+
|
|
413
|
+
method_name = parts[-1]
|
|
414
|
+
class_name = parts[-2]
|
|
415
|
+
|
|
416
|
+
hierarchy = model.exception_hierarchy
|
|
417
|
+
if not hierarchy.is_abstract_method(class_name, method_name):
|
|
418
|
+
return [callee]
|
|
419
|
+
|
|
420
|
+
implementations = hierarchy.get_concrete_implementations(class_name, method_name)
|
|
421
|
+
if not implementations:
|
|
422
|
+
return [callee]
|
|
423
|
+
|
|
424
|
+
result: list[str] = []
|
|
425
|
+
for impl_class, _ in implementations:
|
|
426
|
+
for func in model.functions.values():
|
|
427
|
+
if func.name == method_name and impl_class in func.qualified_name:
|
|
428
|
+
result.append(func.qualified_name)
|
|
429
|
+
break
|
|
430
|
+
else:
|
|
431
|
+
result.append(f"{impl_class}.{method_name}")
|
|
432
|
+
|
|
433
|
+
return result if result else [callee]
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def _build_trace_node(
|
|
437
|
+
func_key: str,
|
|
438
|
+
forward_graph: dict[str, set[str]],
|
|
439
|
+
direct_raises: dict[str, set[str]],
|
|
440
|
+
propagated_raises: dict[str, set[str]],
|
|
441
|
+
model: ProgramModel,
|
|
442
|
+
max_depth: int,
|
|
443
|
+
show_all: bool,
|
|
444
|
+
visited: set[str],
|
|
445
|
+
current_depth: int = 0,
|
|
446
|
+
) -> TraceNode | None:
|
|
447
|
+
"""Build a trace tree node recursively."""
|
|
448
|
+
if current_depth >= max_depth or func_key in visited:
|
|
449
|
+
return None
|
|
450
|
+
|
|
451
|
+
visited = visited | {func_key}
|
|
452
|
+
|
|
453
|
+
simple_name = (
|
|
454
|
+
func_key.split("::")[-1].split(".")[-1] if "::" in func_key else func_key.split(".")[-1]
|
|
455
|
+
)
|
|
456
|
+
this_direct = get_direct_raises_for_key(func_key, direct_raises)
|
|
457
|
+
this_propagated = propagated_raises.get(func_key, set())
|
|
458
|
+
|
|
459
|
+
callees = forward_graph.get(func_key, set())
|
|
460
|
+
if not callees:
|
|
461
|
+
for key in forward_graph:
|
|
462
|
+
key_simple = key.split("::")[-1].split(".")[-1] if "::" in key else key.split(".")[-1]
|
|
463
|
+
if key_simple == simple_name:
|
|
464
|
+
callees = forward_graph[key]
|
|
465
|
+
break
|
|
466
|
+
|
|
467
|
+
children: list[TraceNode | PolymorphicNode] = []
|
|
468
|
+
for callee in sorted(callees):
|
|
469
|
+
implementations = expand_callee(callee, model)
|
|
470
|
+
callee_propagated: set[str] = set()
|
|
471
|
+
for impl in implementations:
|
|
472
|
+
callee_propagated |= propagated_raises.get(impl, set())
|
|
473
|
+
|
|
474
|
+
if not show_all and not callee_propagated:
|
|
475
|
+
continue
|
|
476
|
+
|
|
477
|
+
if len(implementations) > 1:
|
|
478
|
+
impl_nodes: list[TraceNode] = []
|
|
479
|
+
for impl in implementations:
|
|
480
|
+
impl_node = _build_trace_node(
|
|
481
|
+
impl,
|
|
482
|
+
forward_graph,
|
|
483
|
+
direct_raises,
|
|
484
|
+
propagated_raises,
|
|
485
|
+
model,
|
|
486
|
+
max_depth,
|
|
487
|
+
show_all,
|
|
488
|
+
visited,
|
|
489
|
+
current_depth + 1,
|
|
490
|
+
)
|
|
491
|
+
if impl_node:
|
|
492
|
+
impl_nodes.append(impl_node)
|
|
493
|
+
|
|
494
|
+
if impl_nodes:
|
|
495
|
+
children.append(
|
|
496
|
+
PolymorphicNode(
|
|
497
|
+
function=callee,
|
|
498
|
+
implementations=impl_nodes,
|
|
499
|
+
raises=sorted(callee_propagated),
|
|
500
|
+
)
|
|
501
|
+
)
|
|
502
|
+
else:
|
|
503
|
+
child_node = _build_trace_node(
|
|
504
|
+
implementations[0] if implementations else callee,
|
|
505
|
+
forward_graph,
|
|
506
|
+
direct_raises,
|
|
507
|
+
propagated_raises,
|
|
508
|
+
model,
|
|
509
|
+
max_depth,
|
|
510
|
+
show_all,
|
|
511
|
+
visited,
|
|
512
|
+
current_depth + 1,
|
|
513
|
+
)
|
|
514
|
+
if child_node:
|
|
515
|
+
children.append(child_node)
|
|
516
|
+
|
|
517
|
+
return TraceNode(
|
|
518
|
+
function=simple_name,
|
|
519
|
+
qualified=func_key,
|
|
520
|
+
direct_raises=sorted(this_direct),
|
|
521
|
+
propagated_raises=sorted(this_propagated),
|
|
522
|
+
calls=children,
|
|
523
|
+
)
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def trace_function(
|
|
527
|
+
model: ProgramModel,
|
|
528
|
+
function_name: str,
|
|
529
|
+
max_depth: int = 10,
|
|
530
|
+
show_all: bool = False,
|
|
531
|
+
) -> TraceResult:
|
|
532
|
+
"""Trace exception flow from a function."""
|
|
533
|
+
entrypoint = None
|
|
534
|
+
for e in model.entrypoints:
|
|
535
|
+
if e.function == function_name:
|
|
536
|
+
entrypoint = e
|
|
537
|
+
break
|
|
538
|
+
|
|
539
|
+
propagation = propagate_exceptions(model)
|
|
540
|
+
forward_graph = build_forward_call_graph(model)
|
|
541
|
+
direct_raises = propagation.direct_raises
|
|
542
|
+
propagated_raises = propagation.propagated_raises
|
|
543
|
+
|
|
544
|
+
func_key = find_function_key(function_name, propagated_raises, model)
|
|
545
|
+
escaping = propagated_raises.get(func_key, set()) if func_key else set()
|
|
546
|
+
|
|
547
|
+
root = None
|
|
548
|
+
if func_key:
|
|
549
|
+
root = _build_trace_node(
|
|
550
|
+
func_key,
|
|
551
|
+
forward_graph,
|
|
552
|
+
direct_raises,
|
|
553
|
+
propagated_raises,
|
|
554
|
+
model,
|
|
555
|
+
max_depth,
|
|
556
|
+
show_all,
|
|
557
|
+
set(),
|
|
558
|
+
)
|
|
559
|
+
|
|
560
|
+
return TraceResult(
|
|
561
|
+
function_name=function_name,
|
|
562
|
+
entrypoint=entrypoint,
|
|
563
|
+
root=root,
|
|
564
|
+
escaping_exceptions=escaping,
|
|
565
|
+
)
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
def find_subclasses(model: ProgramModel, class_name: str) -> SubclassesResult:
|
|
569
|
+
"""Find all subclasses of a class."""
|
|
570
|
+
hierarchy = model.exception_hierarchy
|
|
571
|
+
|
|
572
|
+
base_class = hierarchy.classes.get(class_name)
|
|
573
|
+
if not base_class:
|
|
574
|
+
for name, cls in hierarchy.classes.items():
|
|
575
|
+
if name.endswith(class_name) or cls.qualified_name.endswith(class_name):
|
|
576
|
+
base_class = cls
|
|
577
|
+
class_name = name
|
|
578
|
+
break
|
|
579
|
+
|
|
580
|
+
subclasses: list[SubclassInfo] = []
|
|
581
|
+
if base_class:
|
|
582
|
+
for name in sorted(hierarchy.get_all_subclasses(class_name)):
|
|
583
|
+
cls = hierarchy.classes.get(name)
|
|
584
|
+
subclasses.append(
|
|
585
|
+
SubclassInfo(
|
|
586
|
+
name=name,
|
|
587
|
+
file=cls.file if cls else None,
|
|
588
|
+
line=cls.line if cls else None,
|
|
589
|
+
is_abstract=cls.is_abstract if cls else False,
|
|
590
|
+
)
|
|
591
|
+
)
|
|
592
|
+
|
|
593
|
+
return SubclassesResult(
|
|
594
|
+
class_name=class_name,
|
|
595
|
+
base_class_file=base_class.file if base_class else None,
|
|
596
|
+
base_class_line=base_class.line if base_class else None,
|
|
597
|
+
is_abstract=base_class.is_abstract if base_class else False,
|
|
598
|
+
abstract_methods=base_class.abstract_methods if base_class else set(),
|
|
599
|
+
subclasses=subclasses,
|
|
600
|
+
)
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
def get_init_info(model: ProgramModel, directory_name: str) -> InitResult:
|
|
604
|
+
"""Get info needed for init command."""
|
|
605
|
+
http_routes = [e for e in model.entrypoints if e.kind == EntrypointKind.HTTP_ROUTE]
|
|
606
|
+
cli_scripts = [e for e in model.entrypoints if e.kind == EntrypointKind.CLI_SCRIPT]
|
|
607
|
+
|
|
608
|
+
frameworks: list[str] = []
|
|
609
|
+
flask_routes = [e for e in http_routes if e.metadata.get("framework") == Framework.FLASK]
|
|
610
|
+
fastapi_routes = [e for e in http_routes if e.metadata.get("framework") == Framework.FASTAPI]
|
|
611
|
+
|
|
612
|
+
if flask_routes:
|
|
613
|
+
frameworks.append("Flask")
|
|
614
|
+
if fastapi_routes:
|
|
615
|
+
frameworks.append("FastAPI")
|
|
616
|
+
if cli_scripts:
|
|
617
|
+
frameworks.append("CLI scripts")
|
|
618
|
+
|
|
619
|
+
return InitResult(
|
|
620
|
+
flow_dir=f"{directory_name}/.flow",
|
|
621
|
+
functions_count=len(model.functions),
|
|
622
|
+
http_routes_count=len(http_routes),
|
|
623
|
+
cli_scripts_count=len(cli_scripts),
|
|
624
|
+
exception_classes_count=len(model.exception_hierarchy.classes),
|
|
625
|
+
global_handlers_count=len(model.global_handlers),
|
|
626
|
+
frameworks_detected=frameworks,
|
|
627
|
+
)
|