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
@@ -0,0 +1,481 @@
1
+ """Query functions for integration-specific operations.
2
+
3
+ Shared audit/entrypoint logic that all integrations use.
4
+ """
5
+
6
+ from bubble.config import FlowConfig
7
+ from bubble.integrations.base import Entrypoint, GlobalHandler, Integration
8
+ from bubble.integrations.models import (
9
+ AuditIssue,
10
+ AuditResult,
11
+ EntrypointsResult,
12
+ EntrypointTrace,
13
+ RoutesToResult,
14
+ )
15
+ from bubble.models import ProgramModel, RaiseSite
16
+ from bubble.propagation import (
17
+ ExceptionFlow,
18
+ PropagationResult,
19
+ build_forward_call_graph,
20
+ build_name_to_qualified,
21
+ build_reverse_call_graph,
22
+ compute_reachable_functions,
23
+ propagate_exceptions,
24
+ )
25
+
26
+
27
+ def _filter_async_boundaries(
28
+ forward_graph: dict[str, set[str]], config: FlowConfig
29
+ ) -> dict[str, set[str]]:
30
+ """Remove calls that match async boundary patterns from the call graph.
31
+
32
+ Async boundaries (like Celery's .apply_async() or .delay()) spawn background
33
+ tasks where exceptions don't propagate back to the caller.
34
+ """
35
+ filtered: dict[str, set[str]] = {}
36
+ for caller, callees in forward_graph.items():
37
+ filtered_callees = {
38
+ callee for callee in callees if not config.is_async_boundary(callee)
39
+ }
40
+ if filtered_callees:
41
+ filtered[caller] = filtered_callees
42
+ return filtered
43
+
44
+
45
+ def _compute_exception_flow_for_integration(
46
+ function_name: str,
47
+ model: ProgramModel,
48
+ propagation: PropagationResult,
49
+ integration: Integration,
50
+ global_handlers: list[GlobalHandler],
51
+ forward_graph: dict[str, set[str]] | None = None,
52
+ name_to_qualified: dict[str, list[str]] | None = None,
53
+ config: FlowConfig | None = None,
54
+ ) -> ExceptionFlow:
55
+ """Compute exception flow for a function with integration-specific handling.
56
+
57
+ Categorizes exceptions as:
58
+ - caught_locally: By try/except in the function
59
+ - caught_by_global: By global handler
60
+ - framework_handled: Converted to HTTP response (or handled by handled_base_classes)
61
+ - uncaught: Will escape
62
+
63
+ For better performance when calling repeatedly, pre-compute forward_graph and
64
+ name_to_qualified using build_forward_call_graph() and build_name_to_qualified().
65
+ """
66
+ from bubble.models import ExceptionEvidence, compute_confidence
67
+
68
+ flow = ExceptionFlow()
69
+ handled_base_classes = config.handled_base_classes if config else []
70
+
71
+ func_key = None
72
+ for key in propagation.propagated_raises:
73
+ if key.endswith(f"::{function_name}") or key.endswith(f".{function_name}"):
74
+ func_key = key
75
+ break
76
+ if "::" in key and key.split("::")[-1].split(".")[-1] == function_name:
77
+ func_key = key
78
+ break
79
+
80
+ if func_key is None:
81
+ return flow
82
+
83
+ reachable = compute_reachable_functions(
84
+ func_key, model, propagation, forward_graph, name_to_qualified
85
+ )
86
+
87
+ escaping_exceptions = propagation.propagated_raises.get(func_key, set())
88
+ func_evidence = propagation.propagated_with_evidence.get(func_key, {})
89
+
90
+ global_handler_types: dict[str, GlobalHandler] = {}
91
+ for handler in global_handlers:
92
+ global_handler_types[handler.handled_type] = handler
93
+ global_handler_types[handler.handled_type.split(".")[-1]] = handler
94
+
95
+ for exc_type in escaping_exceptions:
96
+ exc_simple = exc_type.split(".")[-1]
97
+
98
+ raise_sites = [
99
+ r
100
+ for r in model.raise_sites
101
+ if (r.exception_type == exc_type or r.exception_type.split(".")[-1] == exc_simple)
102
+ and (r.function in reachable or f"{r.file}::{r.function}" in reachable)
103
+ ]
104
+
105
+ evidence_list: list[ExceptionEvidence] = []
106
+ for key, prop_raise in func_evidence.items():
107
+ if key[0] == exc_type:
108
+ evidence_list.append(
109
+ ExceptionEvidence(
110
+ raise_site=prop_raise.raise_site,
111
+ call_path=list(prop_raise.path),
112
+ confidence=compute_confidence(list(prop_raise.path)),
113
+ )
114
+ )
115
+
116
+ if evidence_list:
117
+ if exc_type not in flow.evidence:
118
+ flow.evidence[exc_type] = []
119
+ flow.evidence[exc_type].extend(evidence_list)
120
+
121
+ caught_by_handler = None
122
+ for handler_type, handler in global_handler_types.items():
123
+ handler_simple = handler_type.split(".")[-1]
124
+ if exc_simple == handler_simple:
125
+ caught_by_handler = handler
126
+ break
127
+ if model.exception_hierarchy.is_subclass_of(exc_simple, handler_simple):
128
+ caught_by_handler = handler
129
+ break
130
+
131
+ if caught_by_handler:
132
+ if caught_by_handler.is_generic:
133
+ if exc_type not in flow.caught_by_generic:
134
+ flow.caught_by_generic[exc_type] = []
135
+ flow.caught_by_generic[exc_type].extend(raise_sites)
136
+ else:
137
+ if exc_type not in flow.caught_by_global:
138
+ flow.caught_by_global[exc_type] = []
139
+ flow.caught_by_global[exc_type].extend(raise_sites)
140
+ continue
141
+
142
+ framework_response = integration.get_exception_response(exc_type)
143
+ if framework_response:
144
+ if exc_type not in flow.framework_handled:
145
+ flow.framework_handled[exc_type] = []
146
+ for rs in raise_sites:
147
+ flow.framework_handled[exc_type].append((rs, framework_response))
148
+ continue
149
+
150
+ is_handled_by_config = False
151
+ for base_class in handled_base_classes:
152
+ base_simple = base_class.split(".")[-1]
153
+ if exc_simple == base_simple or exc_type == base_class:
154
+ is_handled_by_config = True
155
+ break
156
+ if model.exception_hierarchy.is_subclass_of(exc_simple, base_simple):
157
+ is_handled_by_config = True
158
+ break
159
+ if model.exception_hierarchy.is_subclass_of(exc_type, base_class):
160
+ is_handled_by_config = True
161
+ break
162
+
163
+ if is_handled_by_config:
164
+ if exc_type not in flow.framework_handled:
165
+ flow.framework_handled[exc_type] = []
166
+ for rs in raise_sites:
167
+ flow.framework_handled[exc_type].append((rs, "handled by config"))
168
+ continue
169
+
170
+ if exc_type not in flow.uncaught:
171
+ flow.uncaught[exc_type] = []
172
+ flow.uncaught[exc_type].extend(raise_sites)
173
+
174
+ return flow
175
+
176
+
177
+ def audit_integration(
178
+ model: ProgramModel,
179
+ integration: Integration,
180
+ entrypoints: list[Entrypoint],
181
+ global_handlers: list[GlobalHandler],
182
+ skip_evidence: bool = True,
183
+ config: FlowConfig | None = None,
184
+ ) -> AuditResult:
185
+ """Audit entrypoints for a specific integration.
186
+
187
+ Args:
188
+ skip_evidence: Skip building evidence paths for faster auditing.
189
+ Set to False if you need path details.
190
+ config: Optional FlowConfig with handled_base_classes and async_boundaries.
191
+ """
192
+ if not entrypoints:
193
+ return AuditResult(
194
+ integration_name=integration.name,
195
+ total_entrypoints=0,
196
+ issues=[],
197
+ clean_count=0,
198
+ )
199
+
200
+ propagation = propagate_exceptions(model, skip_evidence=skip_evidence)
201
+ reraise_patterns = {"Unknown", "e", "ex", "err", "exc", "error", "exception"}
202
+
203
+ forward_graph = build_forward_call_graph(model)
204
+ if config and config.async_boundaries:
205
+ forward_graph = _filter_async_boundaries(forward_graph, config)
206
+ name_to_qualified = build_name_to_qualified(propagation)
207
+
208
+ issues: list[AuditIssue] = []
209
+ clean_count = 0
210
+
211
+ for entrypoint in entrypoints:
212
+ flow = _compute_exception_flow_for_integration(
213
+ entrypoint.function,
214
+ model,
215
+ propagation,
216
+ integration,
217
+ global_handlers,
218
+ forward_graph,
219
+ name_to_qualified,
220
+ config,
221
+ )
222
+
223
+ real_uncaught = {k: v for k, v in flow.uncaught.items() if k not in reraise_patterns}
224
+ real_generic = {
225
+ k: v for k, v in flow.caught_by_generic.items() if k not in reraise_patterns
226
+ }
227
+
228
+ if real_uncaught or real_generic:
229
+ issues.append(
230
+ AuditIssue(
231
+ entrypoint=entrypoint,
232
+ uncaught=real_uncaught,
233
+ caught_by_generic=real_generic,
234
+ caught=flow.caught_by_global,
235
+ )
236
+ )
237
+ else:
238
+ clean_count += 1
239
+
240
+ return AuditResult(
241
+ integration_name=integration.name,
242
+ total_entrypoints=len(entrypoints),
243
+ issues=issues,
244
+ clean_count=clean_count,
245
+ )
246
+
247
+
248
+ def list_integration_entrypoints(
249
+ integration: Integration,
250
+ entrypoints: list[Entrypoint],
251
+ ) -> EntrypointsResult:
252
+ """List entrypoints for a specific integration."""
253
+ return EntrypointsResult(
254
+ integration_name=integration.name,
255
+ entrypoints=entrypoints,
256
+ )
257
+
258
+
259
+ def _find_raises(
260
+ model: ProgramModel,
261
+ exception_type: str,
262
+ include_subclasses: bool,
263
+ ) -> tuple[set[str], list[RaiseSite]]:
264
+ """Find raise sites matching an exception type. Returns (types_searched, matches)."""
265
+ types_to_find: set[str] = {exception_type}
266
+ if include_subclasses:
267
+ subclasses = model.exception_hierarchy.get_subclasses(exception_type)
268
+ types_to_find.update(subclasses)
269
+
270
+ matching_raises = [
271
+ r
272
+ for r in model.raise_sites
273
+ if r.exception_type in types_to_find
274
+ or r.exception_type.endswith(f".{exception_type}")
275
+ or any(r.exception_type.endswith(f".{t}") for t in types_to_find)
276
+ ]
277
+
278
+ for t in list(types_to_find):
279
+ for r in model.raise_sites:
280
+ simple_name = r.exception_type.split(".")[-1]
281
+ if simple_name == t and r not in matching_raises:
282
+ matching_raises.append(r)
283
+
284
+ return types_to_find, matching_raises
285
+
286
+
287
+ def _get_callers_from_graphs(
288
+ function_name: str,
289
+ qualified_graph: dict[str, set[str]],
290
+ name_graph: dict[str, set[str]],
291
+ ) -> set[str]:
292
+ """Get callers using qualified graph first, falling back to name graph."""
293
+ callers = qualified_graph.get(function_name, set())
294
+ if not callers:
295
+ simple_name = (
296
+ function_name.split("::")[-1].split(".")[-1] if "::" in function_name else function_name
297
+ )
298
+ callers = name_graph.get(simple_name, set())
299
+ return callers
300
+
301
+
302
+ def _compute_entrypoint_reachability(
303
+ qualified_graph: dict[str, set[str]],
304
+ name_graph: dict[str, set[str]],
305
+ entrypoint_functions: set[str],
306
+ ) -> tuple[set[str], dict[str, str]]:
307
+ """
308
+ Compute which functions are reachable from entrypoints via forward BFS.
309
+
310
+ A function is reachable if an entrypoint calls it (directly or transitively).
311
+ This is used to prune the search space when tracing from raise sites back
312
+ to entrypoints - we only explore functions that could possibly connect.
313
+
314
+ Returns (reachable_set, function_to_entrypoint_map).
315
+ """
316
+ reachable: set[str] = set()
317
+ func_to_entrypoint: dict[str, str] = {}
318
+
319
+ for ep in entrypoint_functions:
320
+ reachable.add(ep)
321
+ func_to_entrypoint[ep] = ep
322
+
323
+ forward_graph: dict[str, set[str]] = {}
324
+ for callee, callers in qualified_graph.items():
325
+ for caller in callers:
326
+ if caller not in forward_graph:
327
+ forward_graph[caller] = set()
328
+ forward_graph[caller].add(callee)
329
+ for callee, callers in name_graph.items():
330
+ for caller in callers:
331
+ if caller not in forward_graph:
332
+ forward_graph[caller] = set()
333
+ forward_graph[caller].add(callee)
334
+
335
+ simple_to_qualified: dict[str, list[str]] = {}
336
+ for key in forward_graph:
337
+ simple = key.split("::")[-1].split(".")[-1] if "::" in key else key.split(".")[-1]
338
+ if simple not in simple_to_qualified:
339
+ simple_to_qualified[simple] = []
340
+ simple_to_qualified[simple].append(key)
341
+
342
+ worklist = list(entrypoint_functions)
343
+ iterations = 0
344
+ max_iterations = 10000
345
+
346
+ while worklist and iterations < max_iterations:
347
+ iterations += 1
348
+ func = worklist.pop()
349
+
350
+ func_simple = func.split("::")[-1].split(".")[-1] if "::" in func else func.split(".")[-1]
351
+
352
+ callees: set[str] = set()
353
+ callees.update(forward_graph.get(func, set()))
354
+ for qualified_key in simple_to_qualified.get(func_simple, []):
355
+ callees.update(forward_graph.get(qualified_key, set()))
356
+
357
+ for callee in callees:
358
+ callee_simple = (
359
+ callee.split("::")[-1].split(".")[-1] if "::" in callee else callee.split(".")[-1]
360
+ )
361
+
362
+ if callee not in reachable:
363
+ reachable.add(callee)
364
+ reachable.add(callee_simple)
365
+ if func in func_to_entrypoint:
366
+ func_to_entrypoint[callee] = func_to_entrypoint[func]
367
+ worklist.append(callee)
368
+
369
+ if callee_simple not in reachable:
370
+ reachable.add(callee_simple)
371
+ worklist.append(callee_simple)
372
+
373
+ return reachable, func_to_entrypoint
374
+
375
+
376
+ def _trace_to_entrypoints(
377
+ function_name: str,
378
+ qualified_graph: dict[str, set[str]],
379
+ name_graph: dict[str, set[str]],
380
+ entrypoint_functions: set[str],
381
+ reachable_from_entrypoints: set[str] | None = None,
382
+ max_depth: int = 20,
383
+ max_paths: int = 150,
384
+ ) -> list[list[str]]:
385
+ """
386
+ Trace call paths from function to entrypoints.
387
+
388
+ Uses reachability pruning: only explores branches that can reach entrypoints.
389
+ Limits to max_paths to avoid exponential blowup.
390
+ """
391
+ paths: list[list[str]] = []
392
+
393
+ def dfs(current: str, path: list[str], visited: set[str]) -> None:
394
+ if len(paths) >= max_paths:
395
+ return
396
+ if len(path) > max_depth:
397
+ return
398
+ if current in visited:
399
+ return
400
+ visited.add(current)
401
+
402
+ current_simple = current.split("::")[-1] if "::" in current else current
403
+ current_simple = current_simple.split(".")[-1]
404
+
405
+ if current in entrypoint_functions or current_simple in entrypoint_functions:
406
+ paths.append(list(path))
407
+ return
408
+
409
+ callers = _get_callers_from_graphs(current, qualified_graph, name_graph)
410
+ for caller in callers:
411
+ if len(paths) >= max_paths:
412
+ return
413
+ if reachable_from_entrypoints is not None:
414
+ caller_simple = (
415
+ caller.split("::")[-1].split(".")[-1]
416
+ if "::" in caller
417
+ else caller.split(".")[-1]
418
+ )
419
+ if (
420
+ caller not in reachable_from_entrypoints
421
+ and caller_simple not in reachable_from_entrypoints
422
+ ):
423
+ continue
424
+ dfs(caller, path + [caller], visited.copy())
425
+
426
+ dfs(function_name, [function_name], set())
427
+ return paths
428
+
429
+
430
+ def trace_routes_to_exception(
431
+ model: ProgramModel,
432
+ integration: Integration,
433
+ entrypoints: list[Entrypoint],
434
+ exception_type: str,
435
+ include_subclasses: bool = False,
436
+ ) -> RoutesToResult:
437
+ """Trace which routes can reach an exception for a specific integration."""
438
+ types_searched, raise_sites = _find_raises(model, exception_type, include_subclasses)
439
+
440
+ qualified_graph, name_graph = build_reverse_call_graph(model)
441
+ entrypoint_functions = {e.function for e in entrypoints}
442
+
443
+ reachable, _ = _compute_entrypoint_reachability(
444
+ qualified_graph, name_graph, entrypoint_functions
445
+ )
446
+
447
+ traces: list[EntrypointTrace] = []
448
+ for raise_site in raise_sites:
449
+ qualified_function = f"{raise_site.file}::{raise_site.function}"
450
+ paths = _trace_to_entrypoints(
451
+ qualified_function,
452
+ qualified_graph,
453
+ name_graph,
454
+ entrypoint_functions,
455
+ reachable_from_entrypoints=reachable,
456
+ )
457
+ entrypoints_reached: set[str] = set()
458
+ for path in paths:
459
+ if path:
460
+ endpoint = path[-1]
461
+ entrypoints_reached.add(endpoint)
462
+ if "::" in endpoint:
463
+ entrypoints_reached.add(endpoint.split("::")[-1].split(".")[-1])
464
+
465
+ matching_entrypoints = [e for e in entrypoints if e.function in entrypoints_reached]
466
+
467
+ traces.append(
468
+ EntrypointTrace(
469
+ raise_site=raise_site,
470
+ paths=paths,
471
+ entrypoints=matching_entrypoints,
472
+ )
473
+ )
474
+
475
+ return RoutesToResult(
476
+ integration_name=integration.name,
477
+ exception_type=exception_type,
478
+ include_subclasses=include_subclasses,
479
+ types_searched=types_searched,
480
+ traces=traces,
481
+ )
bubble/loader.py ADDED
@@ -0,0 +1,118 @@
1
+ """Loader for custom detectors from .flow/detectors/."""
2
+
3
+ import importlib.util
4
+ import sys
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from bubble.models import Entrypoint, GlobalHandler
9
+ from bubble.protocols import EntrypointDetector, GlobalHandlerDetector
10
+
11
+
12
+ class DetectorRegistry:
13
+ """Registry of custom detectors loaded from .flow/detectors/."""
14
+
15
+ def __init__(self) -> None:
16
+ self.entrypoint_detectors: list[EntrypointDetector] = []
17
+ self.global_handler_detectors: list[GlobalHandlerDetector] = []
18
+ self._loaded_modules: dict[str, Any] = {}
19
+
20
+ def load_from_directory(self, flow_dir: Path) -> None:
21
+ """Load all detectors from a .flow/detectors/ directory."""
22
+ detectors_dir = flow_dir / "detectors"
23
+ if not detectors_dir.exists():
24
+ return
25
+
26
+ for py_file in detectors_dir.glob("*.py"):
27
+ if py_file.name.startswith("_"):
28
+ continue
29
+ self._load_detector_file(py_file)
30
+
31
+ def _load_detector_file(self, file_path: Path) -> None:
32
+ """Load detectors from a single Python file."""
33
+ module_name = f"flow_custom_detectors.{file_path.stem}"
34
+
35
+ if module_name in self._loaded_modules:
36
+ return
37
+
38
+ spec = importlib.util.spec_from_file_location(module_name, file_path)
39
+ if spec is None or spec.loader is None:
40
+ return
41
+
42
+ module = importlib.util.module_from_spec(spec)
43
+ sys.modules[module_name] = module
44
+
45
+ try:
46
+ spec.loader.exec_module(module)
47
+ except Exception as e:
48
+ print(f"Warning: Failed to load detector {file_path}: {e}")
49
+ return
50
+
51
+ self._loaded_modules[module_name] = module
52
+
53
+ for name in dir(module):
54
+ if name.startswith("_"):
55
+ continue
56
+
57
+ obj = getattr(module, name)
58
+ if not isinstance(obj, type):
59
+ continue
60
+
61
+ if self._is_entrypoint_detector(obj):
62
+ try:
63
+ instance = obj()
64
+ self.entrypoint_detectors.append(instance)
65
+ except Exception:
66
+ pass
67
+
68
+ if self._is_global_handler_detector(obj):
69
+ try:
70
+ instance = obj()
71
+ self.global_handler_detectors.append(instance)
72
+ except Exception:
73
+ pass
74
+
75
+ def _is_entrypoint_detector(self, cls: type) -> bool:
76
+ """Check if a class implements EntrypointDetector protocol."""
77
+ if cls.__name__ in ("EntrypointDetector", "GlobalHandlerDetector"):
78
+ return False
79
+ return hasattr(cls, "detect") and callable(cls.detect)
80
+
81
+ def _is_global_handler_detector(self, cls: type) -> bool:
82
+ """Check if a class implements GlobalHandlerDetector protocol."""
83
+ if cls.__name__ in ("EntrypointDetector", "GlobalHandlerDetector"):
84
+ return False
85
+ return hasattr(cls, "detect") and callable(cls.detect)
86
+
87
+ def detect_entrypoints(self, source: str, file_path: str) -> list[Entrypoint]:
88
+ """Run all custom entrypoint detectors on a source file."""
89
+ results: list[Entrypoint] = []
90
+ for detector in self.entrypoint_detectors:
91
+ try:
92
+ detected = detector.detect(source, file_path)
93
+ if detected:
94
+ results.extend(detected)
95
+ except Exception:
96
+ pass
97
+ return results
98
+
99
+ def detect_global_handlers(self, source: str, file_path: str) -> list[GlobalHandler]:
100
+ """Run all custom global handler detectors on a source file."""
101
+ results: list[GlobalHandler] = []
102
+ for detector in self.global_handler_detectors:
103
+ try:
104
+ detected = detector.detect(source, file_path)
105
+ if detected:
106
+ results.extend(detected)
107
+ except Exception:
108
+ pass
109
+ return results
110
+
111
+
112
+ def load_detectors(directory: Path) -> DetectorRegistry:
113
+ """Load custom detectors from a project's .flow/ directory."""
114
+ registry = DetectorRegistry()
115
+ flow_dir = directory / ".flow"
116
+ if flow_dir.exists():
117
+ registry.load_from_directory(flow_dir)
118
+ return registry