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