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/formatters.py ADDED
@@ -0,0 +1,887 @@
1
+ """Output formatters for CLI results.
2
+
3
+ Each formatter takes a result dataclass and renders it as text or JSON.
4
+ All Rich console output is contained here.
5
+ """
6
+
7
+ import json
8
+ from pathlib import Path
9
+
10
+ from rich.console import Console
11
+ from rich.tree import Tree
12
+
13
+ from bubble.enums import ConfidenceLevel, EntrypointKind, OutputFormat, ResolutionKind
14
+ from bubble.results import (
15
+ AuditResult,
16
+ CallersResult,
17
+ CatchesResult,
18
+ EntrypointsResult,
19
+ EntrypointsToResult,
20
+ EscapesResult,
21
+ ExceptionsResult,
22
+ InitResult,
23
+ PolymorphicNode,
24
+ RaisesResult,
25
+ StatsResult,
26
+ SubclassesResult,
27
+ TraceNode,
28
+ TraceResult,
29
+ )
30
+
31
+
32
+ def _rel_path(file: str, directory: Path) -> str:
33
+ """Get relative path for display."""
34
+ if file.startswith(str(directory)):
35
+ return str(Path(file).relative_to(directory))
36
+ return file
37
+
38
+
39
+ def raises(
40
+ result: RaisesResult,
41
+ output_format: OutputFormat,
42
+ directory: Path,
43
+ console: Console,
44
+ ) -> None:
45
+ """Format raises query result."""
46
+ if output_format == OutputFormat.JSON:
47
+ data = {
48
+ "query": "raises",
49
+ "exception": result.exception_type,
50
+ "include_subclasses": result.include_subclasses,
51
+ "types_searched": sorted(result.types_searched),
52
+ "results": [
53
+ {
54
+ "file": r.file,
55
+ "line": r.line,
56
+ "function": r.function,
57
+ "exception_type": r.exception_type,
58
+ "message": r.message_expr,
59
+ "code": r.code,
60
+ }
61
+ for r in result.matches
62
+ ],
63
+ }
64
+ console.print_json(json.dumps(data, indent=2))
65
+ return
66
+
67
+ if not result.matches:
68
+ console.print(f"[yellow]No raise statements found for {result.exception_type}[/yellow]")
69
+ return
70
+
71
+ subclass_note = " (and subclasses)" if result.include_subclasses else ""
72
+ console.print(
73
+ f"\n[bold]{result.exception_type}{subclass_note}[/bold] raised in {len(result.matches)} locations:\n"
74
+ )
75
+
76
+ for r in sorted(result.matches, key=lambda x: (x.file, x.line)):
77
+ rel = _rel_path(r.file, directory)
78
+ console.print(f" [cyan]{rel}:{r.line}[/cyan] in [green]{r.function}()[/green]")
79
+ if r.code:
80
+ console.print(f" [dim]{r.code}[/dim]")
81
+ if result.include_subclasses and r.exception_type != result.exception_type:
82
+ console.print(f" [yellow][{r.exception_type} is a subclass][/yellow]")
83
+ console.print()
84
+
85
+
86
+ def audit(
87
+ result: AuditResult,
88
+ output_format: OutputFormat,
89
+ directory: Path,
90
+ console: Console,
91
+ ) -> None:
92
+ """Format audit query result."""
93
+ if output_format == OutputFormat.JSON:
94
+ data = {
95
+ "query": "audit",
96
+ "total_entrypoints": result.total_entrypoints,
97
+ "with_issues": len(result.issues),
98
+ "clean": result.clean_count,
99
+ "issues": [
100
+ {
101
+ "function": issue.entrypoint.function,
102
+ "kind": issue.entrypoint.kind,
103
+ "http_method": issue.entrypoint.metadata.get("http_method"),
104
+ "http_path": issue.entrypoint.metadata.get("http_path"),
105
+ "uncaught": {
106
+ exc_type: [
107
+ {"file": r.file, "line": r.line, "function": r.function}
108
+ for r in raises_list
109
+ ]
110
+ for exc_type, raises_list in issue.uncaught.items()
111
+ },
112
+ }
113
+ for issue in result.issues
114
+ ],
115
+ }
116
+ console.print_json(json.dumps(data, indent=2))
117
+ return
118
+
119
+ if result.total_entrypoints == 0:
120
+ console.print("[yellow]No entrypoints found (HTTP routes or CLI scripts)[/yellow]")
121
+ console.print("[dim]Run 'flow init' to set up custom detectors if needed.[/dim]")
122
+ return
123
+
124
+ console.print(f"\n[bold]Scanning {result.total_entrypoints} entrypoints...[/bold]\n")
125
+
126
+ if result.issues:
127
+ console.print(
128
+ f"[red bold]✗ {len(result.issues)} entrypoints have uncaught exceptions:[/red bold]\n"
129
+ )
130
+
131
+ for issue in result.issues:
132
+ ep = issue.entrypoint
133
+ if ep.kind == EntrypointKind.HTTP_ROUTE:
134
+ method = ep.metadata.get("http_method", "?")
135
+ path = ep.metadata.get("http_path", "?")
136
+ label = f"[green]{method}[/green] [cyan]{path}[/cyan]"
137
+ else:
138
+ rel = _rel_path(ep.file, directory)
139
+ label = f"[magenta]{rel}[/magenta]:[bold]{ep.function}[/bold]"
140
+
141
+ console.print(f" {label}")
142
+ for exc_type, raise_sites in issue.uncaught.items():
143
+ exc_simple = exc_type.split(".")[-1]
144
+ for rs in raise_sites[:2]:
145
+ rel = _rel_path(rs.file, directory)
146
+ console.print(f" └─ [red]{exc_simple}[/red] [dim]({rel}:{rs.line})[/dim]")
147
+ if len(raise_sites) > 2:
148
+ console.print(f" └─ [dim]...and {len(raise_sites) - 2} more[/dim]")
149
+ console.print()
150
+ else:
151
+ console.print("[green bold]✓ All entrypoints have exception handlers[/green bold]\n")
152
+
153
+ if result.clean_count > 0:
154
+ console.print(
155
+ f"[green]✓ {result.clean_count} entrypoints fully covered by exception handlers[/green]\n"
156
+ )
157
+
158
+ if result.issues:
159
+ console.print("[dim]Run 'flow escapes <function>' for details on a specific route.[/dim]")
160
+
161
+
162
+ def exceptions(
163
+ result: ExceptionsResult,
164
+ output_format: OutputFormat,
165
+ directory: Path,
166
+ console: Console,
167
+ ) -> None:
168
+ """Format exceptions query result."""
169
+ if output_format == OutputFormat.JSON:
170
+ data = {
171
+ "query": "exceptions",
172
+ "classes": {
173
+ name: {
174
+ "bases": exc.bases,
175
+ "location": f"{exc.file}:{exc.line}" if exc.file else None,
176
+ }
177
+ for name, exc in result.classes.items()
178
+ },
179
+ }
180
+ console.print_json(json.dumps(data, indent=2))
181
+ return
182
+
183
+ if not result.classes:
184
+ console.print("[yellow]No exception classes found[/yellow]")
185
+ return
186
+
187
+ console.print("\n[bold]Exception hierarchy:[/bold]\n")
188
+
189
+ def build_tree(parent_name: str, tree: Tree) -> None:
190
+ children = [
191
+ name
192
+ for name, exc in result.classes.items()
193
+ if any(b.split(".")[-1] == parent_name for b in exc.bases)
194
+ ]
195
+ for child in sorted(children):
196
+ exc = result.classes[child]
197
+ if exc.file:
198
+ rel = _rel_path(exc.file, directory)
199
+ label = f"{child} ([dim]{rel}:{exc.line}[/dim])"
200
+ else:
201
+ label = child
202
+ subtree = tree.add(label)
203
+ build_tree(child, subtree)
204
+
205
+ for root in sorted(result.roots):
206
+ exc = result.classes.get(root)
207
+ if exc and exc.file:
208
+ rel = _rel_path(exc.file, directory)
209
+ label = f"[bold]{root}[/bold] ([dim]{rel}:{exc.line}[/dim])"
210
+ else:
211
+ label = f"[bold]{root}[/bold]"
212
+ tree = Tree(label)
213
+ build_tree(root, tree)
214
+ console.print(tree)
215
+ console.print()
216
+
217
+
218
+ def stats(
219
+ result: StatsResult,
220
+ output_format: OutputFormat,
221
+ console: Console,
222
+ ) -> None:
223
+ """Format stats query result."""
224
+ if output_format == OutputFormat.JSON:
225
+ data = {
226
+ "query": "stats",
227
+ "results": {
228
+ "functions": result.functions,
229
+ "classes": result.classes,
230
+ "raise_sites": result.raise_sites,
231
+ "catch_sites": result.catch_sites,
232
+ "call_sites": result.call_sites,
233
+ "entrypoints": result.entrypoints,
234
+ "http_routes": result.http_routes,
235
+ "cli_scripts": result.cli_scripts,
236
+ "global_handlers": result.global_handlers,
237
+ "imports": result.imports,
238
+ },
239
+ }
240
+ console.print_json(json.dumps(data, indent=2))
241
+ return
242
+
243
+ console.print("\n[bold]Codebase Statistics:[/bold]\n")
244
+ console.print(f" Functions: {result.functions:,}")
245
+ console.print(f" Classes: {result.classes:,}")
246
+ console.print(f" Raise sites: {result.raise_sites:,}")
247
+ console.print(f" Catch sites: {result.catch_sites:,}")
248
+ console.print(f" Call sites: {result.call_sites:,}")
249
+ console.print(
250
+ f" Entrypoints: {result.entrypoints:,} ({result.http_routes} HTTP, {result.cli_scripts} CLI)"
251
+ )
252
+ console.print(f" Global handlers: {result.global_handlers:,}")
253
+ console.print(f" Imports: {result.imports:,}")
254
+ console.print()
255
+
256
+
257
+ def callers(
258
+ result: CallersResult,
259
+ output_format: OutputFormat,
260
+ directory: Path,
261
+ console: Console,
262
+ show_resolution: bool = False,
263
+ ) -> None:
264
+ """Format callers query result."""
265
+ if output_format == OutputFormat.JSON:
266
+ data = {
267
+ "query": "callers",
268
+ "function": result.function_name,
269
+ "count": len(result.calls),
270
+ "results": [
271
+ {
272
+ "file": c.file,
273
+ "line": c.line,
274
+ "caller_function": c.caller_function,
275
+ "caller_qualified": c.caller_qualified,
276
+ "callee_qualified": c.callee_qualified,
277
+ "resolution_kind": c.resolution_kind,
278
+ "is_method_call": c.is_method_call,
279
+ }
280
+ for c in result.calls
281
+ ],
282
+ }
283
+ console.print_json(json.dumps(data, indent=2))
284
+ return
285
+
286
+ if not result.calls:
287
+ console.print(f"[yellow]No calls found to {result.function_name}[/yellow]")
288
+ if result.suggestions:
289
+ console.print("[yellow]Did you mean:[/yellow]")
290
+ for s in result.suggestions:
291
+ console.print(f" [dim]- {s}[/dim]")
292
+ console.print()
293
+ return
294
+
295
+ console.print(
296
+ f"\n[bold]{result.function_name}[/bold] called in {len(result.calls)} locations:\n"
297
+ )
298
+
299
+ for c in sorted(result.calls, key=lambda x: (x.file, x.line)):
300
+ rel = _rel_path(c.file, directory)
301
+ call_type = "method" if c.is_method_call else "function"
302
+ resolution = (
303
+ f" [dim]\\[{c.resolution_kind}][/dim]"
304
+ if show_resolution and c.resolution_kind != ResolutionKind.UNRESOLVED
305
+ else ""
306
+ )
307
+ console.print(
308
+ f" [cyan]{rel}:{c.line}[/cyan] in [green]{c.caller_function}()[/green] ({call_type} call){resolution}"
309
+ )
310
+
311
+ console.print()
312
+
313
+
314
+ def entrypoints_to(
315
+ result: EntrypointsToResult,
316
+ output_format: OutputFormat,
317
+ directory: Path,
318
+ console: Console,
319
+ ) -> None:
320
+ """Format entrypoints-to query result."""
321
+ if output_format == OutputFormat.JSON:
322
+ json_traces = []
323
+ for trace in result.traces:
324
+ json_traces.append(
325
+ {
326
+ "raise_site": {
327
+ "file": trace.raise_site.file,
328
+ "line": trace.raise_site.line,
329
+ "function": trace.raise_site.function,
330
+ "exception_type": trace.raise_site.exception_type,
331
+ },
332
+ "paths": trace.paths,
333
+ "entrypoints": [
334
+ {
335
+ "function": e.function,
336
+ "http_method": e.metadata.get("http_method"),
337
+ "http_path": e.metadata.get("http_path"),
338
+ }
339
+ for e in trace.entrypoints
340
+ ],
341
+ }
342
+ )
343
+ data = {
344
+ "query": "entrypoints-to",
345
+ "exception": result.exception_type,
346
+ "include_subclasses": result.include_subclasses,
347
+ "results": json_traces,
348
+ }
349
+ console.print_json(json.dumps(data, indent=2))
350
+ return
351
+
352
+ if not result.traces:
353
+ console.print(f"[yellow]No raise statements found for {result.exception_type}[/yellow]")
354
+ return
355
+
356
+ subclass_note = " (and subclasses)" if result.include_subclasses else ""
357
+ console.print(
358
+ f"\n[bold]Entrypoints that can reach {result.exception_type}{subclass_note}:[/bold]\n"
359
+ )
360
+
361
+ for trace in result.traces:
362
+ rel = _rel_path(trace.raise_site.file, directory)
363
+
364
+ if not trace.entrypoints:
365
+ console.print(
366
+ f" [cyan]{rel}:{trace.raise_site.line}[/cyan] in [green]{trace.raise_site.function}()[/green]"
367
+ )
368
+ console.print(" [dim]No HTTP entrypoints found in call chain[/dim]\n")
369
+ continue
370
+
371
+ console.print(
372
+ f" [cyan]{rel}:{trace.raise_site.line}[/cyan] in [green]{trace.raise_site.function}()[/green]"
373
+ )
374
+ for e in trace.entrypoints:
375
+ method = e.metadata.get("http_method", "?")
376
+ path = e.metadata.get("http_path", "?")
377
+ console.print(f" → [green]{method}[/green] [cyan]{path}[/cyan] ({e.function})")
378
+ console.print()
379
+
380
+
381
+ def entrypoints(
382
+ result: EntrypointsResult,
383
+ output_format: OutputFormat,
384
+ directory: Path,
385
+ console: Console,
386
+ ) -> None:
387
+ """Format entrypoints query result."""
388
+ total = (
389
+ len(result.http_routes)
390
+ + len(result.cli_scripts)
391
+ + sum(len(v) for v in result.other.values())
392
+ )
393
+
394
+ if output_format == OutputFormat.JSON:
395
+ data = {
396
+ "query": "entrypoints",
397
+ "count": total,
398
+ "http_routes": len(result.http_routes),
399
+ "cli_scripts": len(result.cli_scripts),
400
+ "results": [
401
+ {
402
+ "file": e.file,
403
+ "function": e.function,
404
+ "line": e.line,
405
+ "kind": e.kind,
406
+ "http_method": e.metadata.get("http_method"),
407
+ "http_path": e.metadata.get("http_path"),
408
+ "framework": e.metadata.get("framework"),
409
+ }
410
+ for e in result.http_routes + result.cli_scripts
411
+ ],
412
+ }
413
+ console.print_json(json.dumps(data, indent=2))
414
+ return
415
+
416
+ if total == 0:
417
+ console.print("[yellow]No entrypoints found[/yellow]")
418
+ console.print()
419
+ console.print("[dim]Detected entrypoint types:[/dim]")
420
+ console.print("[dim] - HTTP routes: Flask @route, FastAPI @router.get/post/etc[/dim]")
421
+ console.print("[dim] - CLI scripts: if __name__ == '__main__'[/dim]")
422
+ console.print()
423
+ return
424
+
425
+ if result.http_routes:
426
+ console.print(f"\n[bold]HTTP Routes ({len(result.http_routes)} total):[/bold]\n")
427
+ sorted_routes = sorted(
428
+ result.http_routes,
429
+ key=lambda e: (e.metadata.get("http_path", ""), e.metadata.get("http_method", "")),
430
+ )
431
+ for e in sorted_routes:
432
+ method = e.metadata.get("http_method", "?")
433
+ path = e.metadata.get("http_path", "?")
434
+ rel = _rel_path(e.file, directory)
435
+ console.print(
436
+ f" [green]{method:6}[/green] [cyan]{path:40}[/cyan] {rel}:[bold]{e.function}[/bold]"
437
+ )
438
+ console.print()
439
+
440
+ if result.cli_scripts:
441
+ console.print(f"\n[bold]CLI Scripts ({len(result.cli_scripts)} total):[/bold]\n")
442
+ sorted_scripts = sorted(result.cli_scripts, key=lambda e: e.file)
443
+ for e in sorted_scripts:
444
+ rel = _rel_path(e.file, directory)
445
+ if e.metadata.get("inline"):
446
+ console.print(
447
+ f" [magenta]{rel}[/magenta]:[bold]{e.line}[/bold] [dim](inline code)[/dim]"
448
+ )
449
+ else:
450
+ console.print(f" [magenta]{rel}[/magenta]:[bold]{e.function}[/bold]()")
451
+ console.print()
452
+
453
+ for kind, entries in sorted(result.other.items()):
454
+ kind_label = kind.replace("_", " ").title()
455
+ console.print(f"\n[bold]{kind_label} ({len(entries)} total):[/bold]\n")
456
+ for e in sorted(entries, key=lambda x: x.file):
457
+ rel = _rel_path(e.file, directory)
458
+ framework = e.metadata.get("framework", "")
459
+ framework_note = f" [dim]({framework})[/dim]" if framework else ""
460
+ console.print(f" [yellow]{rel}[/yellow]:[bold]{e.function}[/bold](){framework_note}")
461
+ console.print()
462
+
463
+
464
+ def _format_confidence(confidence: ConfidenceLevel) -> str:
465
+ """Format confidence level with color."""
466
+ if confidence == ConfidenceLevel.HIGH:
467
+ return "[green]high[/green]"
468
+ elif confidence == ConfidenceLevel.MEDIUM:
469
+ return "[yellow]medium[/yellow]"
470
+ else:
471
+ return "[red]low[/red]"
472
+
473
+
474
+ def _format_resolution_kind(kind: ResolutionKind) -> str:
475
+ """Format resolution kind with optional warning."""
476
+ heuristic_kinds = {ResolutionKind.NAME_FALLBACK, ResolutionKind.POLYMORPHIC}
477
+ if kind in heuristic_kinds:
478
+ return f"[yellow]{kind}[/yellow]"
479
+ return f"[dim]{kind}[/dim]"
480
+
481
+
482
+ def escapes(
483
+ result: EscapesResult,
484
+ output_format: OutputFormat,
485
+ directory: Path,
486
+ console: Console,
487
+ ) -> None:
488
+ """Format escapes query result."""
489
+ if output_format == OutputFormat.JSON:
490
+ entrypoint_info = None
491
+ if result.entrypoint:
492
+ entrypoint_info = {
493
+ "kind": result.entrypoint.kind,
494
+ "file": result.entrypoint.file,
495
+ "line": result.entrypoint.line,
496
+ }
497
+ if result.entrypoint.kind == EntrypointKind.HTTP_ROUTE:
498
+ entrypoint_info["http_method"] = result.entrypoint.metadata.get("http_method")
499
+ entrypoint_info["http_path"] = result.entrypoint.metadata.get("http_path")
500
+
501
+ evidence_json: dict[str, list[dict]] = {}
502
+ for exc_type, evidence_list in result.flow.evidence.items():
503
+ evidence_json[exc_type] = [
504
+ {
505
+ "raise_site": {
506
+ "file": ev.raise_site.file,
507
+ "line": ev.raise_site.line,
508
+ "function": ev.raise_site.function,
509
+ },
510
+ "confidence": ev.confidence,
511
+ "call_path": [
512
+ {
513
+ "caller": edge.caller,
514
+ "callee": edge.callee,
515
+ "resolution": edge.resolution_kind,
516
+ "heuristic": edge.is_heuristic,
517
+ }
518
+ for edge in ev.call_path
519
+ ],
520
+ }
521
+ for ev in evidence_list
522
+ ]
523
+
524
+ framework_handled_json: dict[str, list[dict]] = {}
525
+ for exc_type, handled_list in result.flow.framework_handled.items():
526
+ framework_handled_json[exc_type] = [
527
+ {
528
+ "raise_site": {"file": rs.file, "line": rs.line, "function": rs.function},
529
+ "http_response": response,
530
+ }
531
+ for rs, response in handled_list
532
+ ]
533
+
534
+ data = {
535
+ "query": "escapes",
536
+ "function": result.function_name,
537
+ "entrypoint": entrypoint_info,
538
+ "global_handlers": [
539
+ {"type": h.handled_type, "function": h.function, "file": h.file}
540
+ for h in result.global_handlers
541
+ ],
542
+ "caught_by_global": {
543
+ exc_type: [{"file": r.file, "line": r.line, "function": r.function} for r in raises]
544
+ for exc_type, raises in result.flow.caught_by_global.items()
545
+ },
546
+ "framework_handled": framework_handled_json,
547
+ "uncaught": {
548
+ exc_type: [{"file": r.file, "line": r.line, "function": r.function} for r in raises]
549
+ for exc_type, raises in result.flow.uncaught.items()
550
+ },
551
+ "evidence": evidence_json,
552
+ }
553
+ console.print_json(json.dumps(data, indent=2))
554
+ return
555
+
556
+ if result.entrypoint and result.entrypoint.kind == EntrypointKind.HTTP_ROUTE:
557
+ method = result.entrypoint.metadata.get("http_method", "?")
558
+ path = result.entrypoint.metadata.get("http_path", "?")
559
+ console.print(f"\n[bold]Exceptions that can escape from {method} {path}:[/bold]\n")
560
+ elif result.entrypoint and result.entrypoint.kind == EntrypointKind.CLI_SCRIPT:
561
+ rel = _rel_path(result.entrypoint.file, directory)
562
+ console.print(
563
+ f"\n[bold]Exceptions that can escape from CLI script {rel}:{result.function_name}():[/bold]\n"
564
+ )
565
+ else:
566
+ console.print(f"\n[bold]Exceptions that can escape from {result.function_name}():[/bold]\n")
567
+
568
+ has_content = (
569
+ result.flow.caught_by_global or result.flow.uncaught or result.flow.framework_handled
570
+ )
571
+ if not has_content:
572
+ console.print(" [dim]No escaping exceptions detected[/dim]\n")
573
+ return
574
+
575
+ if result.flow.caught_by_global:
576
+ console.print(" [green]CAUGHT BY GLOBAL HANDLER:[/green]")
577
+ for exc_type, raise_sites in result.flow.caught_by_global.items():
578
+ exc_simple = exc_type.split(".")[-1]
579
+ handler = next(
580
+ (
581
+ h
582
+ for h in result.global_handlers
583
+ if h.handled_type == exc_type or h.handled_type.split(".")[-1] == exc_simple
584
+ ),
585
+ None,
586
+ )
587
+ handler_info = f" (@errorhandler({handler.handled_type}))" if handler else ""
588
+ console.print(f" [cyan]{exc_type}[/cyan]{handler_info}")
589
+ for r in raise_sites[:3]:
590
+ rel = _rel_path(r.file, directory)
591
+ console.print(f" └─ raised in: [dim]{rel}:{r.line}[/dim] ({r.function})")
592
+ if len(raise_sites) > 3:
593
+ console.print(f" └─ [dim]...and {len(raise_sites) - 3} more[/dim]")
594
+ console.print()
595
+
596
+ if result.flow.framework_handled:
597
+ console.print(" [blue]FRAMEWORK-HANDLED (converted to HTTP response):[/blue]")
598
+ for exc_type, handled_list in result.flow.framework_handled.items():
599
+ exc_simple = exc_type.split(".")[-1]
600
+ response = handled_list[0][1] if handled_list else "HTTP ?"
601
+ console.print(f" [cyan]{exc_simple}[/cyan]")
602
+ console.print(f" └─ becomes: [green]{response}[/green]")
603
+ for rs, _ in handled_list[:3]:
604
+ rel = _rel_path(rs.file, directory)
605
+ console.print(f" └─ raised in: [dim]{rel}:{rs.line}[/dim] ({rs.function})")
606
+ if len(handled_list) > 3:
607
+ console.print(f" └─ [dim]...and {len(handled_list) - 3} more[/dim]")
608
+ console.print()
609
+
610
+ if result.flow.uncaught:
611
+ reraise_patterns = {"Unknown", "e", "ex", "err", "exc", "error", "exception"}
612
+ real_uncaught = {k: v for k, v in result.flow.uncaught.items() if k not in reraise_patterns}
613
+ reraises = {k: v for k, v in result.flow.uncaught.items() if k in reraise_patterns}
614
+
615
+ if real_uncaught:
616
+ console.print(" [red]UNCAUGHT (will propagate to caller):[/red]")
617
+ for exc_type, raise_sites in real_uncaught.items():
618
+ evidence_list = result.flow.evidence.get(exc_type, [])
619
+ console.print(f" [cyan]{exc_type}[/cyan]")
620
+ for r in raise_sites[:3]:
621
+ rel = _rel_path(r.file, directory)
622
+ matching_evidence = next(
623
+ (
624
+ ev
625
+ for ev in evidence_list
626
+ if ev.raise_site.file == r.file and ev.raise_site.line == r.line
627
+ ),
628
+ None,
629
+ )
630
+ if matching_evidence and matching_evidence.call_path:
631
+ confidence_label = _format_confidence(matching_evidence.confidence)
632
+ console.print(
633
+ f" └─ raised in: [dim]{rel}:{r.line}[/dim] ({r.function}) [{confidence_label} confidence]"
634
+ )
635
+ path_parts = [
636
+ _format_resolution_kind(e.resolution_kind)
637
+ for e in matching_evidence.call_path[:4]
638
+ ]
639
+ if path_parts:
640
+ console.print(f" call path: {' → '.join(path_parts)}")
641
+ else:
642
+ console.print(
643
+ f" └─ raised in: [dim]{rel}:{r.line}[/dim] ({r.function})"
644
+ )
645
+ if len(raise_sites) > 3:
646
+ console.print(f" └─ [dim]...and {len(raise_sites) - 3} more[/dim]")
647
+ console.print()
648
+
649
+ if reraises:
650
+ total_reraises = sum(len(v) for v in reraises.values())
651
+ console.print(
652
+ f" [dim]RE-RAISES ({total_reraises} locations - propagate caught exceptions):[/dim]"
653
+ )
654
+ for exc_type, raise_sites in reraises.items():
655
+ label = "bare raise" if exc_type == "Unknown" else f"raise {exc_type}"
656
+ console.print(f" [dim]{label} in {len(raise_sites)} locations[/dim]")
657
+ console.print()
658
+
659
+
660
+ def catches(
661
+ result: CatchesResult,
662
+ output_format: OutputFormat,
663
+ directory: Path,
664
+ console: Console,
665
+ ) -> None:
666
+ """Format catches query result."""
667
+ if output_format == OutputFormat.JSON:
668
+ data = {
669
+ "query": "catches",
670
+ "exception": result.exception_type,
671
+ "include_subclasses": result.include_subclasses,
672
+ "local_catches": [
673
+ {
674
+ "file": c.file,
675
+ "line": c.line,
676
+ "function": c.function,
677
+ "caught_types": c.caught_types,
678
+ "has_reraise": c.has_reraise,
679
+ }
680
+ for c in result.local_catches
681
+ ],
682
+ "global_handlers": [
683
+ {
684
+ "file": h.file,
685
+ "line": h.line,
686
+ "function": h.function,
687
+ "handled_type": h.handled_type,
688
+ }
689
+ for h in result.global_handlers
690
+ ],
691
+ }
692
+ console.print_json(json.dumps(data, indent=2))
693
+ return
694
+
695
+ total = len(result.local_catches) + len(result.global_handlers)
696
+ if total == 0:
697
+ console.print(f"[yellow]No catch sites found for {result.exception_type}[/yellow]")
698
+ return
699
+
700
+ subclass_note = " (and subclasses)" if result.include_subclasses else ""
701
+ console.print(
702
+ f"\n[bold]{result.exception_type}{subclass_note}[/bold] caught in {total} locations:\n"
703
+ )
704
+
705
+ if result.global_handlers:
706
+ console.print(" [green]GLOBAL HANDLERS:[/green]")
707
+ for h in result.global_handlers:
708
+ rel = _rel_path(h.file, directory)
709
+ console.print(
710
+ f" [cyan]{rel}:{h.line}[/cyan] @errorhandler({h.handled_type}) → [green]{h.function}()[/green]"
711
+ )
712
+ console.print()
713
+
714
+ if result.local_catches:
715
+ console.print(" [blue]LOCAL TRY/EXCEPT:[/blue]")
716
+ for c in sorted(result.local_catches, key=lambda x: (x.file, x.line)):
717
+ rel = _rel_path(c.file, directory)
718
+ reraise_note = " [yellow](re-raises)[/yellow]" if c.has_reraise else ""
719
+ caught = ", ".join(c.caught_types) if c.caught_types else "bare except"
720
+ console.print(f" [cyan]{rel}:{c.line}[/cyan] in [green]{c.function}()[/green]")
721
+ console.print(f" except {caught}{reraise_note}")
722
+ console.print()
723
+
724
+
725
+ def trace(
726
+ result: TraceResult,
727
+ output_format: OutputFormat,
728
+ directory: Path,
729
+ console: Console,
730
+ ) -> None:
731
+ """Format trace query result."""
732
+ if output_format == OutputFormat.JSON:
733
+
734
+ def node_to_dict(node: TraceNode | PolymorphicNode) -> dict:
735
+ if isinstance(node, PolymorphicNode):
736
+ return {
737
+ "function": node.function,
738
+ "polymorphic": True,
739
+ "implementations": [node_to_dict(n) for n in node.implementations],
740
+ "raises": node.raises,
741
+ }
742
+ return {
743
+ "function": node.function,
744
+ "qualified": node.qualified,
745
+ "direct_raises": node.direct_raises,
746
+ "propagated_raises": node.propagated_raises,
747
+ "calls": [node_to_dict(c) for c in node.calls],
748
+ }
749
+
750
+ entrypoint_info = None
751
+ if result.entrypoint:
752
+ entrypoint_info = {
753
+ "kind": result.entrypoint.kind,
754
+ "http_method": result.entrypoint.metadata.get("http_method"),
755
+ "http_path": result.entrypoint.metadata.get("http_path"),
756
+ }
757
+ data = {
758
+ "query": "trace",
759
+ "function": result.function_name,
760
+ "entrypoint": entrypoint_info,
761
+ "tree": node_to_dict(result.root) if result.root else None,
762
+ }
763
+ console.print_json(json.dumps(data, indent=2))
764
+ return
765
+
766
+ if result.entrypoint and result.entrypoint.kind == EntrypointKind.HTTP_ROUTE:
767
+ method = result.entrypoint.metadata.get("http_method", "?")
768
+ path = result.entrypoint.metadata.get("http_path", "?")
769
+ root_label = f"[bold green]{method}[/bold green] [bold cyan]{path}[/bold cyan]"
770
+ else:
771
+ root_label = f"[bold]{result.function_name}()[/bold]"
772
+
773
+ if result.escaping_exceptions:
774
+ exc_summary = ", ".join(sorted(e.split(".")[-1] for e in result.escaping_exceptions))
775
+ root_label += f" [dim]→ escapes: {exc_summary}[/dim]"
776
+
777
+ tree = Tree(root_label)
778
+
779
+ def build_tree(node: TraceNode | PolymorphicNode, parent: Tree) -> None:
780
+ if isinstance(node, PolymorphicNode):
781
+ poly_label = f"[yellow]{node.function.split('.')[-1]}()[/yellow] [dim]({len(node.implementations)} implementations)[/dim]"
782
+ if node.raises:
783
+ exc_list = ", ".join(sorted(e.split(".")[-1] for e in node.raises))
784
+ poly_label += f" [dim]→ {exc_list}[/dim]"
785
+ poly_branch = parent.add(poly_label)
786
+ for impl in node.implementations:
787
+ build_tree(impl, poly_branch)
788
+ return
789
+
790
+ for exc in node.direct_raises:
791
+ parent.add(f"[red]raises {exc.split('.')[-1]}[/red]")
792
+
793
+ for child in node.calls:
794
+ if isinstance(child, PolymorphicNode):
795
+ build_tree(child, parent)
796
+ else:
797
+ label = f"[cyan]{child.function}()[/cyan]"
798
+ if child.propagated_raises:
799
+ exc_list = ", ".join(sorted(e.split(".")[-1] for e in child.propagated_raises))
800
+ label += f" [dim]→ {exc_list}[/dim]"
801
+ branch = parent.add(label)
802
+ build_tree(child, branch)
803
+
804
+ if result.root:
805
+ build_tree(result.root, tree)
806
+
807
+ console.print()
808
+ console.print(tree)
809
+ console.print()
810
+
811
+
812
+ def subclasses(
813
+ result: SubclassesResult,
814
+ output_format: OutputFormat,
815
+ directory: Path,
816
+ console: Console,
817
+ ) -> None:
818
+ """Format subclasses query result."""
819
+ if output_format == OutputFormat.JSON:
820
+ data = {
821
+ "query": "subclasses",
822
+ "class": result.class_name,
823
+ "is_abstract": result.is_abstract,
824
+ "abstract_methods": sorted(result.abstract_methods),
825
+ "subclasses": [
826
+ {
827
+ "name": s.name,
828
+ "file": s.file,
829
+ "is_abstract": s.is_abstract,
830
+ }
831
+ for s in result.subclasses
832
+ ],
833
+ }
834
+ console.print_json(json.dumps(data, indent=2))
835
+ return
836
+
837
+ if not result.base_class_file:
838
+ console.print(f"[yellow]Class '{result.class_name}' not found[/yellow]")
839
+ return
840
+
841
+ console.print(f"\n[bold]{result.class_name}[/bold]")
842
+ if result.is_abstract:
843
+ console.print("[dim] (abstract class)[/dim]")
844
+ if result.abstract_methods:
845
+ console.print(
846
+ f"[dim] Abstract methods: {', '.join(sorted(result.abstract_methods))}[/dim]"
847
+ )
848
+ console.print()
849
+
850
+ if not result.subclasses:
851
+ console.print("[yellow]No subclasses found[/yellow]")
852
+ return
853
+
854
+ console.print(f"[bold]Subclasses ({len(result.subclasses)} total):[/bold]\n")
855
+
856
+ for s in result.subclasses:
857
+ abstract_note = " [dim](abstract)[/dim]" if s.is_abstract else ""
858
+ console.print(f" [cyan]{s.name}[/cyan]{abstract_note}")
859
+ if s.file:
860
+ rel = _rel_path(s.file, directory)
861
+ console.print(f" [dim]{rel}:{s.line}[/dim]")
862
+
863
+ console.print()
864
+
865
+
866
+ def init_result(
867
+ result: InitResult,
868
+ console: Console,
869
+ ) -> None:
870
+ """Format init info (patterns detected)."""
871
+ console.print("[bold]Detected patterns:[/bold]")
872
+ console.print(f" Functions: {result.functions_count:,}")
873
+ console.print(f" HTTP routes: {result.http_routes_count:,}")
874
+ console.print(f" CLI scripts: {result.cli_scripts_count:,}")
875
+ console.print(f" Exception classes: {result.exception_classes_count:,}")
876
+ console.print(f" Global handlers: {result.global_handlers_count:,}")
877
+ if result.frameworks_detected:
878
+ console.print(f" Frameworks: {', '.join(result.frameworks_detected)}")
879
+ console.print()
880
+
881
+
882
+ def cache_stats(file_count: int, size_bytes: int, console: Console) -> None:
883
+ """Format cache stats."""
884
+ console.print("\n[bold]Cache Statistics:[/bold]\n")
885
+ console.print(f" Cached files: {file_count:,}")
886
+ console.print(f" Cache size: {size_bytes / 1024:.1f} KB")
887
+ console.print()