Repository: bokeh/bokeh

Issue:
BAD_EXTRA_RANGE_NAME validation can be incorrect
The following will produce an incorrect BAD_EXTRA_RANGE_NAME
```
a = figure()
b = figure()
b.extra_x_range = {'myx': Range1d(start = 0, end = 1)}
b.circle(..., x_range_name = 'myx')

check_integrity(a) #--> ok
check_integrity(b) #--> ok

c = CustomJS(code = "", args = {"b": b})
a.x_range.callback  = c

check_integrity(a) # --> ko
```
The reason is that in the last check_integrity:

1. the circle glyph belonging to "b" is known to "a" through its "a.x_range.callback". 
2. the _check_bad_extra_range_names uses the extra ranges from "a" rather than "b".

It seems that in _check_bad_extra_range_names, one should only iterate over the layout's renderers.


Discussion:
@poldavezac Can you provide a complete real example of how this situation could arise in actual practice? i.e. without calling `check_integrity` manually yourself? Bokeh serializes entire documents at a time, not individual models, so I'd like to understand how this could affect real-world usage before expending effort on changing it. 
The manual calling was purely for facilitating testing. The problem occurs automatically when the document is loaded. In my real-world case, I could not load a document comprising 2 figures (a time series and it's histogram), both sharing their y-axis range and with an extra y-axis slaved to the others through a callback. 

As far as investigations go, I do think I pinpointed the problem : the check iterates over all references rather than only the figure's layout. It ends up looking for extra ranges belonging to another figure. I personally monkeypatched bokeh that way and it works. I could be missing something though.

@poldavezac would you like to contribute your local solution as a PR? That would greatly accelerate getting a change into the project. 

Resolving patch:
diff --git a/bokeh/model.py b/bokeh/model.py
--- a/bokeh/model.py
+++ b/bokeh/model.py
@@ -59,20 +59,26 @@
 # Dev API
 #-----------------------------------------------------------------------------
 
-def collect_models(*input_values):
+def collect_filtered_models(discard, *input_values):
     ''' Collect a duplicate-free list of all other Bokeh models referred to by
-    this model, or by any of its references, etc.
+    this model, or by any of its references, etc, unless filtered-out by the
+    provided callable.
 
     Iterate over ``input_values`` and descend through their structure
-    collecting all nested ``Models`` on the go. The resulting list is
-    duplicate-free based on objects' identifiers.
+    collecting all nested ``Models`` on the go.
 
     Args:
+        *discard (Callable[[Model], bool])
+            a callable which accepts a *Model* instance as its single argument
+            and returns a boolean stating whether to discard the instance. The
+            latter means that the instance will not be added to collected
+            models nor will its references be explored.
+
         *input_values (Model)
             Bokeh models to collect other models from
 
     Returns:
-        list[Model] : all models reachable from this one.
+        None
 
     '''
 
@@ -81,7 +87,7 @@ def collect_models(*input_values):
     queued = []
 
     def queue_one(obj):
-        if obj.id not in ids:
+        if obj.id not in ids and not (callable(discard) and discard(obj)):
             queued.append(obj)
 
     for value in input_values:
@@ -96,6 +102,24 @@ def queue_one(obj):
 
     return collected
 
+def collect_models(*input_values):
+    ''' Collect a duplicate-free list of all other Bokeh models referred to by
+    this model, or by any of its references, etc.
+
+    Iterate over ``input_values`` and descend through their structure
+    collecting all nested ``Models`` on the go. The resulting list is
+    duplicate-free based on objects' identifiers.
+
+    Args:
+        *input_values (Model)
+            Bokeh models to collect other models from
+
+    Returns:
+        list[Model] : all models reachable from this one.
+
+    '''
+    return collect_filtered_models(None, *input_values)
+
 def get_class(view_model_name):
     ''' Look up a Bokeh model class, given its view model name.
 
diff --git a/bokeh/models/plots.py b/bokeh/models/plots.py
--- a/bokeh/models/plots.py
+++ b/bokeh/models/plots.py
@@ -34,7 +34,7 @@
 from ..core.validation import error, warning
 from ..core.validation.errors import BAD_EXTRA_RANGE_NAME, REQUIRED_RANGE, REQUIRED_SCALE, INCOMPATIBLE_SCALE_AND_RANGE
 from ..core.validation.warnings import MISSING_RENDERERS, FIXED_SIZING_MODE, FIXED_WIDTH_POLICY, FIXED_HEIGHT_POLICY
-from ..model import Model
+from ..model import Model, collect_filtered_models
 from ..util.deprecation import deprecated
 from ..util.string import nice_join
 
@@ -368,8 +368,9 @@ def _check_missing_renderers(self):
 
     @error(BAD_EXTRA_RANGE_NAME)
     def _check_bad_extra_range_name(self):
-        msg = ""
-        for ref in self.references():
+        msg  = ""
+        filt = lambda x: x is not self and isinstance(x, Plot)
+        for ref in collect_filtered_models(filt, self):
             prop_names = ref.properties()
             bad = []
             if 'x_range_name' in prop_names and 'y_range_name' in prop_names:


Test patch:
diff --git a/bokeh/models/tests/test_plots.py b/bokeh/models/tests/test_plots.py
--- a/bokeh/models/tests/test_plots.py
+++ b/bokeh/models/tests/test_plots.py
@@ -26,7 +26,7 @@
 from bokeh.core.validation import check_integrity
 from bokeh.plotting import figure
 
-from bokeh.models import GlyphRenderer, Label, Plot, LinearAxis
+from bokeh.models import GlyphRenderer, Label, Plot, LinearAxis, CustomJS
 from bokeh.models.ranges import FactorRange, DataRange1d, Range1d
 from bokeh.models.scales import CategoricalScale, LinearScale, LogScale
 from bokeh.models.tools import PanTool
@@ -183,6 +183,17 @@ def test_bad_extra_range_name(self):
         )
         assert mock_logger.error.call_args[0][0].count("Grid") == 2
 
+        # test whether adding a figure (*and* it's extra ranges)
+        # to another's references doesn't create a false positive
+        p, dep = figure(), figure()
+        dep.extra_x_ranges['foo'] = Range1d()
+        dep.grid.x_range_name="foo"
+        p.x_range.callback = CustomJS(code = "", args = {"dep": dep})
+        assert dep in p.references()
+        with mock.patch('bokeh.core.validation.check.log') as mock_logger:
+            check_integrity([p])
+        assert mock_logger.error.call_count == 0
+
 def test_plot_add_layout_raises_error_if_not_render():
     plot = figure()
     with pytest.raises(ValueError):