bubble-analysis 0.2.0__py3-none-any.whl → 0.3.4__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/propagation.py CHANGED
@@ -39,10 +39,20 @@ class PropagatedRaise:
39
39
 
40
40
  @dataclass
41
41
  class ExceptionFlow:
42
- """The computed exception flow for a function or entrypoint."""
42
+ """The computed exception flow for a function or entrypoint.
43
+
44
+ Categories:
45
+ - caught_locally: Caught by try/except in the function
46
+ - caught_by_global: Caught by a global handler in the same file as the entrypoint
47
+ - caught_by_remote_global: Caught by a global handler in a different file
48
+ - caught_by_generic: Caught by a generic handler (@errorhandler(Exception))
49
+ - uncaught: No handler found
50
+ - framework_handled: Converted to HTTP response by framework (e.g., HTTPException)
51
+ """
43
52
 
44
53
  caught_locally: dict[str, list[RaiseSite]] = field(default_factory=dict)
45
54
  caught_by_global: dict[str, list[RaiseSite]] = field(default_factory=dict)
55
+ caught_by_remote_global: dict[str, list[RaiseSite]] = field(default_factory=dict)
46
56
  caught_by_generic: dict[str, list[RaiseSite]] = field(default_factory=dict)
47
57
  uncaught: dict[str, list[RaiseSite]] = field(default_factory=dict)
48
58
  framework_handled: dict[str, list[tuple[RaiseSite, str]]] = field(default_factory=dict)
@@ -61,14 +71,79 @@ class PropagationResult:
61
71
  )
62
72
 
63
73
 
74
+ def _build_func_name_index(model: ProgramModel) -> dict[str, list[str]]:
75
+ """Build an index from function name suffixes to qualified keys."""
76
+ index: dict[str, list[str]] = {}
77
+ for key in model.functions:
78
+ if "::" in key:
79
+ func_name = key.split("::")[-1]
80
+ elif ":" in key:
81
+ func_name = key.split(":")[-1]
82
+ else:
83
+ func_name = key
84
+ if func_name not in index:
85
+ index[func_name] = []
86
+ index[func_name].append(key)
87
+ return index
88
+
89
+
90
+ _normalize_cache: dict[str, str] = {}
91
+
92
+
93
+ def _normalize_callee_to_file_format(
94
+ callee_qualified: str,
95
+ model: ProgramModel,
96
+ func_index: dict[str, list[str]] | None = None,
97
+ ) -> str:
98
+ """Normalize a module-dot callee to file::function format.
99
+
100
+ Converts: blueprints.orchestrators.CallbackOrchestrators.BalanceCallbackOrchestrator.run
101
+ To: blueprints/orchestrators/CallbackOrchestrators.py::BalanceCallbackOrchestrator.run
102
+ """
103
+ if "::" in callee_qualified:
104
+ return callee_qualified
105
+
106
+ if callee_qualified in _normalize_cache:
107
+ return _normalize_cache[callee_qualified]
108
+
109
+ parts = callee_qualified.split(".")
110
+ for i in range(len(parts) - 1, 0, -1):
111
+ module_path = "/".join(parts[:i]) + ".py"
112
+ func_name = ".".join(parts[i:])
113
+
114
+ for sep in ("::", ":"):
115
+ candidate_key = f"{module_path}{sep}{func_name}"
116
+ if candidate_key in model.functions:
117
+ normalized = f"{module_path}::{func_name}"
118
+ _normalize_cache[callee_qualified] = normalized
119
+ return normalized
120
+
121
+ if func_index is not None:
122
+ candidates = func_index.get(func_name, [])
123
+ module_normalized = module_path.replace("/", ".").replace(".py", "")
124
+ for key in candidates:
125
+ if module_normalized in key.replace("/", "."):
126
+ file_part = key.split(":")[0]
127
+ result = f"{file_part}::{func_name}"
128
+ _normalize_cache[callee_qualified] = result
129
+ return result
130
+
131
+ _normalize_cache[callee_qualified] = callee_qualified
132
+ return callee_qualified
133
+
134
+
64
135
  def build_forward_call_graph(model: ProgramModel) -> dict[str, set[str]]:
65
136
  """Build a map from caller to callees."""
66
137
  graph: dict[str, set[str]] = {}
138
+ func_index = _build_func_name_index(model)
67
139
 
68
140
  for call_site in model.call_sites:
69
141
  caller = call_site.caller_qualified or f"{call_site.file}::{call_site.caller_function}"
70
142
  callee = call_site.callee_qualified or call_site.callee_name
71
143
 
144
+ if call_site.callee_qualified and "::" not in call_site.callee_qualified:
145
+ callee = _normalize_callee_to_file_format(call_site.callee_qualified, model, func_index)
146
+
72
147
  if caller not in graph:
73
148
  graph[caller] = set()
74
149
  graph[caller].add(callee)
@@ -76,6 +151,59 @@ def build_forward_call_graph(model: ProgramModel) -> dict[str, set[str]]:
76
151
  return graph
77
152
 
78
153
 
154
+ def compute_forward_reachability(
155
+ start_func: str,
156
+ model: ProgramModel,
157
+ forward_graph: dict[str, set[str]] | None = None,
158
+ ) -> set[str]:
159
+ """Compute all functions reachable from a starting function via forward calls.
160
+
161
+ Unlike compute_reachable_functions, this doesn't require propagation results,
162
+ making it suitable for scoping propagation before it runs.
163
+
164
+ Returns both qualified names and simple names for efficient scope checking.
165
+ """
166
+ if forward_graph is None:
167
+ forward_graph = build_forward_call_graph(model)
168
+
169
+ simple_to_qualified: dict[str, list[str]] = {}
170
+ for key in forward_graph:
171
+ simple = key.split("::")[-1].split(".")[-1] if "::" in key else key.split(".")[-1]
172
+ if simple not in simple_to_qualified:
173
+ simple_to_qualified[simple] = []
174
+ simple_to_qualified[simple].append(key)
175
+
176
+ reachable: set[str] = set()
177
+ start_simple = start_func.split("::")[-1].split(".")[-1] if "::" in start_func else start_func.split(".")[-1]
178
+ worklist = [start_func] + simple_to_qualified.get(start_simple, [])
179
+
180
+ while worklist:
181
+ current = worklist.pop()
182
+ if current in reachable:
183
+ continue
184
+ reachable.add(current)
185
+
186
+ current_simple = (
187
+ current.split("::")[-1].split(".")[-1] if "::" in current else current.split(".")[-1]
188
+ )
189
+ reachable.add(current_simple)
190
+
191
+ callees = forward_graph.get(current, set())
192
+ if not callees:
193
+ for qualified_key in simple_to_qualified.get(current_simple, []):
194
+ if qualified_key not in reachable:
195
+ callees = forward_graph.get(qualified_key, set())
196
+ if callees:
197
+ reachable.add(qualified_key)
198
+ break
199
+
200
+ for callee in callees:
201
+ if callee not in reachable:
202
+ worklist.append(callee)
203
+
204
+ return reachable
205
+
206
+
79
207
  def build_name_to_qualified(propagation: PropagationResult) -> dict[str, list[str]]:
80
208
  """Build a map from simple function names to their qualified names."""
81
209
  name_to_qualified: dict[str, list[str]] = {}
@@ -218,6 +346,23 @@ def _build_call_site_lookup(
218
346
  return lookup
219
347
 
220
348
 
349
+ def _build_is_method_lookup(model: ProgramModel) -> dict[tuple[str, str], bool]:
350
+ """Build a lookup for is_method_call by (caller, callee) pairs.
351
+
352
+ This is always needed for correct fallback resolution, even when
353
+ skip_evidence=True. When a call is in the form obj.method(), we
354
+ should prefer matching methods over standalone functions.
355
+ """
356
+ lookup: dict[tuple[str, str], bool] = {}
357
+ for cs in model.call_sites:
358
+ caller = cs.caller_qualified or f"{cs.file}::{cs.caller_function}"
359
+ callee = cs.callee_qualified or cs.callee_name
360
+ key = (caller, callee)
361
+ if key not in lookup:
362
+ lookup[key] = cs.is_method_call
363
+ return lookup
364
+
365
+
221
366
  def _build_raise_site_lookup(
222
367
  model: ProgramModel,
223
368
  ) -> dict[tuple[str, str, int], RaiseSite]:
@@ -278,9 +423,7 @@ def _scoped_fallback_lookup(
278
423
  candidates = name_to_qualified.get(fallback_key, [])
279
424
 
280
425
  if not candidates:
281
- result = ([], "none")
282
- _fallback_cache[cache_key] = result
283
- return result
426
+ return ([], "none")
284
427
 
285
428
  same_file = [c for c in candidates if c.startswith(f"{caller_file}::")]
286
429
  if same_file:
@@ -314,6 +457,7 @@ def propagate_exceptions(
314
457
  resolution_mode: ResolutionMode = ResolutionMode.DEFAULT,
315
458
  stub_library: StubLibrary | None = None,
316
459
  skip_evidence: bool = False,
460
+ scope: set[str] | None = None,
317
461
  ) -> PropagationResult:
318
462
  """
319
463
  Propagate exceptions through the call graph.
@@ -324,6 +468,8 @@ def propagate_exceptions(
324
468
  Args:
325
469
  skip_evidence: If True, skip building evidence paths for faster propagation.
326
470
  Use for audit commands where only exception types matter.
471
+ scope: If provided, only propagate through functions in this set.
472
+ Use compute_forward_reachability() to get the scope for a single function.
327
473
 
328
474
  Resolution modes:
329
475
  - strict: Only follow resolved calls (no name_fallback or polymorphic)
@@ -332,21 +478,35 @@ def propagate_exceptions(
332
478
  """
333
479
  from bubble import timing
334
480
 
335
- cache_key = (
336
- id(model),
337
- resolution_mode,
338
- id(stub_library) if stub_library else None,
339
- skip_evidence,
340
- )
481
+ if scope is not None:
482
+ cache_key = None
483
+ else:
484
+ cache_key = (
485
+ id(model),
486
+ resolution_mode,
487
+ id(stub_library) if stub_library else None,
488
+ skip_evidence,
489
+ )
341
490
 
342
- if cache_key in _propagation_cache:
343
- return _propagation_cache[cache_key]
491
+ if cache_key in _propagation_cache:
492
+ return _propagation_cache[cache_key]
344
493
 
345
494
  with timing.timed("propagation_setup"):
346
495
  direct_raises = compute_direct_raises(model)
347
496
  catches_by_function = compute_catches_by_function(model)
348
- forward_graph = build_forward_call_graph(model)
497
+ full_forward_graph = build_forward_call_graph(model)
498
+
499
+ if scope is not None:
500
+ forward_graph = {}
501
+ for caller, callees in full_forward_graph.items():
502
+ caller_simple = caller.split("::")[-1].split(".")[-1] if "::" in caller else caller.split(".")[-1]
503
+ if caller in scope or caller_simple in scope:
504
+ forward_graph[caller] = callees
505
+ else:
506
+ forward_graph = full_forward_graph
507
+
349
508
  call_site_lookup = _build_call_site_lookup(model) if not skip_evidence else {}
509
+ is_method_lookup = _build_is_method_lookup(model)
350
510
 
351
511
  propagated: dict[str, set[str]] = {}
352
512
  propagated_evidence: dict[str, dict[tuple[str, str, int], PropagatedRaise]] = {}
@@ -418,7 +578,7 @@ def propagate_exceptions(
418
578
  if "::" in expanded_callee
419
579
  else expanded_callee.split(".")[-1]
420
580
  )
421
- is_method = call_site.is_method_call if call_site else False
581
+ is_method = is_method_lookup.get((caller, callee), False)
422
582
  caller_file = caller.split("::")[0] if "::" in caller else caller
423
583
  import_map = model.import_maps.get(caller_file, {})
424
584
 
@@ -533,7 +693,8 @@ def propagate_exceptions(
533
693
  propagated_with_evidence=propagated_evidence,
534
694
  )
535
695
 
536
- _propagation_cache[cache_key] = result
696
+ if cache_key is not None:
697
+ _propagation_cache[cache_key] = result
537
698
  return result
538
699
 
539
700
 
bubble/queries.py CHANGED
@@ -7,11 +7,12 @@ and returns a typed result dataclass. No formatting here.
7
7
  from difflib import get_close_matches
8
8
 
9
9
  from bubble.enums import EntrypointKind, Framework, ResolutionMode
10
- from bubble.models import Entrypoint, ProgramModel
10
+ from bubble.models import CatchSite, ClassHierarchy, Entrypoint, ProgramModel, RaiseSite
11
11
  from bubble.propagation import (
12
12
  build_forward_call_graph,
13
13
  build_reverse_call_graph,
14
14
  compute_exception_flow,
15
+ compute_forward_reachability,
15
16
  propagate_exceptions,
16
17
  )
17
18
  from bubble.results import (
@@ -309,7 +310,15 @@ def find_escapes(
309
310
  entrypoint = e
310
311
  break
311
312
 
312
- propagation = propagate_exceptions(model, resolution_mode=resolution_mode)
313
+ forward_graph = build_forward_call_graph(model)
314
+ scope = compute_forward_reachability(function_name, model, forward_graph)
315
+
316
+ propagation = propagate_exceptions(
317
+ model,
318
+ resolution_mode=resolution_mode,
319
+ skip_evidence=True,
320
+ scope=scope,
321
+ )
313
322
  flow = compute_exception_flow(function_name, model, propagation)
314
323
 
315
324
  return EscapesResult(
@@ -320,28 +329,110 @@ def find_escapes(
320
329
  )
321
330
 
322
331
 
332
+ def _compute_reverse_reachability(
333
+ raise_sites: list[RaiseSite],
334
+ qualified_graph: dict[str, set[str]],
335
+ name_graph: dict[str, set[str]],
336
+ ) -> set[str]:
337
+ """Compute all functions that can transitively call the raise site functions."""
338
+ reachable: set[str] = set()
339
+
340
+ for raise_site in raise_sites:
341
+ func_key = f"{raise_site.file}::{raise_site.function}"
342
+ worklist = [func_key]
343
+ visited: set[str] = set()
344
+
345
+ while worklist:
346
+ current = worklist.pop()
347
+ if current in visited:
348
+ continue
349
+ visited.add(current)
350
+ reachable.add(current)
351
+
352
+ simple_name = (
353
+ current.split("::")[-1].split(".")[-1]
354
+ if "::" in current
355
+ else current.split(".")[-1]
356
+ )
357
+ reachable.add(simple_name)
358
+
359
+ for caller in qualified_graph.get(current, set()):
360
+ if caller not in visited:
361
+ worklist.append(caller)
362
+
363
+ for caller in name_graph.get(simple_name, set()):
364
+ if caller not in visited:
365
+ worklist.append(caller)
366
+
367
+ return reachable
368
+
369
+
370
+ def _catch_site_catches_exception(
371
+ catch_site: CatchSite,
372
+ types_to_find: set[str],
373
+ hierarchy: ClassHierarchy,
374
+ ) -> bool:
375
+ """Check if a catch site would catch the given exception type."""
376
+ if catch_site.has_bare_except:
377
+ return True
378
+
379
+ for caught_type in catch_site.caught_types:
380
+ caught_simple = caught_type.split(".")[-1]
381
+
382
+ if caught_type in types_to_find or caught_simple in types_to_find:
383
+ return True
384
+
385
+ if caught_simple in ("Exception", "BaseException"):
386
+ return True
387
+
388
+ for t in types_to_find:
389
+ t_simple = t.split(".")[-1]
390
+ if hierarchy.is_subclass_of(t_simple, caught_simple):
391
+ return True
392
+
393
+ return False
394
+
395
+
323
396
  def find_catches(
324
397
  model: ProgramModel,
325
398
  exception_type: str,
326
399
  include_subclasses: bool = False,
327
400
  ) -> CatchesResult:
328
- """Find all places where an exception is caught."""
401
+ """Find catch sites that are in the call path from where the exception is raised."""
329
402
  types_to_find: set[str] = {exception_type}
330
403
  if include_subclasses:
331
404
  subclasses = model.exception_hierarchy.get_subclasses(exception_type)
332
405
  types_to_find.update(subclasses)
333
406
 
334
- matching_catches = []
407
+ raises_result = find_raises(model, exception_type, include_subclasses)
408
+
409
+ if not raises_result.matches:
410
+ return CatchesResult(
411
+ exception_type=exception_type,
412
+ include_subclasses=include_subclasses,
413
+ types_searched=types_to_find,
414
+ local_catches=[],
415
+ global_handlers=[],
416
+ raise_site_count=0,
417
+ )
418
+
419
+ qualified_graph, name_graph = build_reverse_call_graph(model)
420
+ reachable_functions = _compute_reverse_reachability(
421
+ raises_result.matches, qualified_graph, name_graph
422
+ )
423
+
424
+ matching_catches: list[CatchSite] = []
335
425
  for catch_site in model.catch_sites:
336
- for caught_type in catch_site.caught_types:
337
- caught_simple = caught_type.split(".")[-1]
338
- if caught_type in types_to_find or caught_simple in types_to_find:
339
- matching_catches.append(catch_site)
340
- break
341
- for t in types_to_find:
342
- if model.exception_hierarchy.is_subclass_of(t, caught_simple):
343
- matching_catches.append(catch_site)
344
- break
426
+ catch_func_key = f"{catch_site.file}::{catch_site.function}"
427
+ catch_func_simple = catch_site.function.split(".")[-1]
428
+
429
+ if catch_func_key not in reachable_functions and catch_func_simple not in reachable_functions:
430
+ continue
431
+
432
+ if _catch_site_catches_exception(
433
+ catch_site, types_to_find, model.exception_hierarchy
434
+ ):
435
+ matching_catches.append(catch_site)
345
436
 
346
437
  global_handlers = [
347
438
  h
@@ -360,6 +451,7 @@ def find_catches(
360
451
  types_searched=types_to_find,
361
452
  local_catches=matching_catches,
362
453
  global_handlers=global_handlers,
454
+ raise_site_count=len(raises_result.matches),
363
455
  )
364
456
 
365
457
 
bubble/results.py CHANGED
@@ -118,6 +118,7 @@ class CatchesResult:
118
118
  types_searched: set[str]
119
119
  local_catches: list[CatchSite]
120
120
  global_handlers: list[GlobalHandler]
121
+ raise_site_count: int = 0
121
122
 
122
123
 
123
124
  @dataclass
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: bubble-analysis
3
- Version: 0.2.0
3
+ Version: 0.3.4
4
4
  Summary: Static analysis tool for tracing exception flow through Python codebases
5
5
  Author-email: Ian McLaughlin <ianm199@github.com>
6
6
  License-Expression: MIT
@@ -13,13 +13,14 @@ Classifier: Environment :: Console
13
13
  Classifier: Intended Audience :: Developers
14
14
  Classifier: Operating System :: OS Independent
15
15
  Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.10
16
17
  Classifier: Programming Language :: Python :: 3.11
17
18
  Classifier: Programming Language :: Python :: 3.12
18
19
  Classifier: Programming Language :: Python :: 3.13
19
20
  Classifier: Topic :: Software Development :: Quality Assurance
20
21
  Classifier: Topic :: Software Development :: Testing
21
22
  Classifier: Typing :: Typed
22
- Requires-Python: >=3.11
23
+ Requires-Python: >=3.10
23
24
  Description-Content-Type: text/markdown
24
25
  License-File: LICENSE
25
26
  Requires-Dist: libcst>=1.1.0
@@ -33,11 +34,11 @@ Requires-Dist: pytest-cov>=4.0; extra == "dev"
33
34
  Requires-Dist: ruff>=0.1.0; extra == "dev"
34
35
  Dynamic: license-file
35
36
 
36
- # flow
37
+ # Bubble
37
38
 
38
39
  Static analysis tool for tracing exception flow through Python codebases.
39
40
 
40
- **What can escape from my API endpoints?** Flow answers this by parsing your code, building a call graph, and computing which exceptions propagate to each entrypoint.
41
+ **What can escape from my API endpoints?** Bubble answers this by parsing your code, building a call graph, and computing which exceptions propagate to each entrypoint.
41
42
 
42
43
  ## Quick Start
43
44
 
@@ -61,10 +62,10 @@ bubble trace create_user -d /path/to/project
61
62
 
62
63
  ## What It Does
63
64
 
64
- Flow finds your HTTP routes and CLI scripts, traces the call graph, and reports which exceptions can escape:
65
+ Bubble finds your HTTP routes and CLI scripts, traces the call graph, and reports which exceptions can escape:
65
66
 
66
67
  ```
67
- $ flow flask audit
68
+ $ bubble flask audit
68
69
 
69
70
  Scanning 23 flask entrypoints...
70
71
 
@@ -83,7 +84,7 @@ Scanning 23 flask entrypoints...
83
84
  For a specific endpoint, see the full picture:
84
85
 
85
86
  ```
86
- $ flow escapes create_user
87
+ $ bubble escapes create_user
87
88
 
88
89
  Exceptions that can escape from POST /users:
89
90
 
@@ -105,7 +106,7 @@ Exceptions that can escape from POST /users:
105
106
  Visualize as a tree:
106
107
 
107
108
  ```
108
- $ flow trace create_user
109
+ $ bubble trace create_user
109
110
 
110
111
  POST /users → escapes: ValidationError, ConnectionError
111
112
  ├── validate_input() → ValidationError
@@ -117,6 +118,7 @@ POST /users → escapes: ValidationError, ConnectionError
117
118
 
118
119
  ## Features
119
120
 
121
+ - **Extensible**: Adapt to any framework or custom pattern ([see extending guide](docs/EXTENDING.md))
120
122
  - **Entrypoint detection**: Flask routes, FastAPI routes, CLI scripts (`if __name__ == "__main__"`)
121
123
  - **Global handler awareness**: Understands `@errorhandler`, `add_exception_handler`
122
124
  - **Exception hierarchy**: Knows that catching `AppError` also catches `ValidationError` if it's a subclass
@@ -124,9 +126,10 @@ POST /users → escapes: ValidationError, ConnectionError
124
126
  - **Framework-handled exceptions**: Detects HTTPException, ValidationError → HTTP responses
125
127
  - **Confidence levels**: Shows high/medium/low confidence based on resolution quality
126
128
  - **Resolution modes**: `--strict` for precision, `--aggressive` for recall
127
- - **Exception stubs**: Declare what external libraries can raise (requests, sqlalchemy, etc.)
129
+ - **Exception stubs**: Built-in stubs for requests, sqlalchemy, httpx, redis, boto3
128
130
  - **JSON output**: All commands support `-f json` for CI/automation
129
131
  - **Caching**: SQLite-based caching for fast repeated analysis
132
+ - **Python 3.10+**: Supports Python 3.10, 3.11, 3.12, and 3.13
130
133
 
131
134
  ## Commands
132
135
 
@@ -179,39 +182,55 @@ The `escapes` command accepts additional flags:
179
182
  - Celery tasks
180
183
  - Scheduled jobs (APScheduler, etc.)
181
184
 
182
- Custom patterns can be added via `.flow/detectors/` (run `bubble init` to set up).
185
+ Custom patterns can be added via `.flow/detectors/`.
183
186
 
184
- ## Adding Custom Detectors
187
+ ## Extending Bubble
185
188
 
186
- Flow is designed to be extended with AI coding agents. The detector interface is intentionally simple: implement a protocol that returns entrypoints and handlers from parsed code.
189
+ > **Bubble is designed to be adapted to your codebase.** The core engine handles exception propagation—you just tell it where your entrypoints are.
187
190
 
188
- To add support for a new framework (Django, Celery, your internal RPC layer, etc.):
191
+ Every codebase has its own patterns: internal RPC frameworks, custom decorators, queue handlers, scheduled jobs. Bubble's extension system lets you teach it your patterns without modifying the core.
189
192
 
190
- 1. Run `bubble init` to create the `.flow/` directory structure
191
- 2. Point your AI agent at `flow/protocols.py` to see the `EntrypointDetector` interface
192
- 3. Ask it to implement a detector for your framework in `.flow/detectors/`
193
+ **Full guide: [docs/EXTENDING.md](docs/EXTENDING.md)**
193
194
 
194
- Example prompt for an AI agent:
195
+ ### What You Can Extend
195
196
 
196
- ```
197
- Read flow/protocols.py and flow/detectors.py to understand how entrypoint
198
- detection works. Then implement a detector for Django that finds:
199
- - Views decorated with @api_view
200
- - Class-based views inheriting from APIView
201
- - URL patterns in urls.py
197
+ | Pattern | Example | How to add |
198
+ |---------|---------|------------|
199
+ | Custom entrypoints | Celery tasks, Django views, gRPC handlers | `EntrypointDetector` |
200
+ | Custom error handlers | Middleware, decorators | `GlobalHandlerDetector` |
201
+ | Full framework | Django REST Framework, Airflow | `Integration` protocol |
202
+
203
+ ### Quick Example
204
+
205
+ Drop a detector in `.flow/detectors/` and bubble auto-loads it:
202
206
 
203
- Put the implementation in .flow/detectors/django.py
207
+ ```python
208
+ # .flow/detectors/celery.py
209
+ from bubble.protocols import EntrypointDetector
210
+ from bubble.integrations.base import Entrypoint
211
+ from bubble.enums import EntrypointKind
212
+
213
+ class CeleryTaskDetector(EntrypointDetector):
214
+ def detect(self, source: str, file_path: str) -> list[Entrypoint]:
215
+ # Find @app.task decorators, return Entrypoint objects
216
+ ...
204
217
  ```
205
218
 
206
- The detector just needs to implement:
207
- - `detect_entrypoints(functions, classes, ...)` → list of `Entrypoint`
208
- - `detect_global_handlers(...)` → list of `GlobalHandler`
219
+ ### AI-Friendly Design
209
220
 
210
- Flow will automatically load any `.py` files in `.flow/detectors/` and use them alongside the built-in Flask/FastAPI detectors.
221
+ The protocol is intentionally simple for LLMs. Give your agent:
222
+
223
+ ```
224
+ Read bubble/protocols.py and bubble/integrations/flask/detector.py.
225
+ Implement a detector for [YOUR FRAMEWORK] that finds [PATTERNS].
226
+ Put it in .flow/detectors/[framework].py
227
+ ```
228
+
229
+ See **[docs/EXTENDING.md](docs/EXTENDING.md)** for complete examples including Celery, Django REST Framework, and custom decorator patterns.
211
230
 
212
231
  ## Configuration
213
232
 
214
- Flow can be configured via `.flow/config.yaml`:
233
+ Bubble can be configured via `.flow/config.yaml`:
215
234
 
216
235
  ```yaml
217
236
  resolution_mode: default # "strict", "default", or "aggressive"
@@ -222,13 +241,15 @@ exclude:
222
241
 
223
242
  ### Exception Stubs
224
243
 
225
- Flow includes built-in stubs for common libraries (requests, sqlalchemy, httpx, redis, boto3). These declare what exceptions external library functions can raise.
244
+ Bubble includes built-in stubs for common libraries (requests, sqlalchemy, httpx, redis, boto3). These declare what exceptions external library functions can raise.
226
245
 
227
246
  Add custom stubs in `.flow/stubs/`:
228
247
 
229
248
  ```yaml
230
249
  # .flow/stubs/mylib.yaml
231
- mylib:
250
+ module: mylib
251
+
252
+ functions:
232
253
  do_thing:
233
254
  - MyLibError
234
255
  - TimeoutError
@@ -253,10 +274,10 @@ Manage stubs with `bubble stubs list` and `bubble stubs validate`.
253
274
  ## Development
254
275
 
255
276
  ```bash
256
- git clone https://github.com/ianm199/flow
257
- cd flow
258
- pip install -e ".[dev]"
259
- pytest
277
+ git clone https://github.com/ianm199/bubble-analysis
278
+ cd bubble-analysis
279
+ uv pip install -e ".[dev]"
280
+ uv run pytest
260
281
  ```
261
282
 
262
283
  ## License