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/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
+ )