# Zarr chunks ignored when reading arrays with `StringDType`

### What happened?

When opening a Zarr array created with a NumPy 2 `StringDType` the Xarray DataArray doesn't have the same chunks as the underlying Zarr array.

### What did you expect to happen?

The Xarray chunking should be the same as the Zarr chunking. In the MVCE below, the chunking is the same if using a fixed-length unicode dtype (`dtype="U"`).

### Minimal Complete Verifiable Example

```Python
# /// script
# requires-python = ">=3.11"
# dependencies = [
#   "xarray[complete]@git+https://github.com/pydata/xarray.git@main",
#   "zarr",
# ]
# ///
#
# This script automatically imports the development branch of xarray to check for issues.
# Please delete this header if you have _not_ tested this script with `uv run`!

import numpy as np
import xarray as xr
import zarr
xr.show_versions()

# create chunked string array using zarr
root = zarr.create_group(store="g")
data = np.array([
    ["a", "b", "c", "d"],
    ["e", "f", "g", "h"],
    ["i", "j", "k", "l"],
    ["m", "n", "o", "p"],
], dtype="T")
a = root.create_array(name="a", data=data, chunks=(2, 2), dimension_names=["x", "y"])

# open with xarray and check chunk sizes
x = xr.open_zarr("g", consolidated=False)
assert x["a"].chunks == ((2, 2), (2, 2))  # fails
```

### Steps to reproduce

_No response_

### MVCE confirmation

- [x] Minimal example — the example is as focused as reasonably possible to demonstrate the underlying issue in xarray.
- [x] Complete example — the example is self-contained, including all data and the text of any traceback.
- [x] Verifiable example — the example copy & pastes into an IPython prompt or [Binder notebook](https://mybinder.org/v2/gh/pydata/xarray/main?urlpath=lab/tree/doc/examples/blank_template.ipynb), returning the result.
- [x] New issue — a search of GitHub Issues suggests this is not a duplicate.
- [x] Recent environment — the issue occurs with the latest version of xarray and its dependencies.

### Relevant log output

```Python

```

### Anything else we need to know?

_No response_

### Environment

<details>

</details>

diff --git a/xarray/tests/test_backends.py b/xarray/tests/test_backends.py
index ed86979c73e..3d694d7791a 100644
--- a/xarray/tests/test_backends.py
+++ b/xarray/tests/test_backends.py
@@ -58,6 +58,7 @@
 from xarray.coding.cftime_offsets import date_range
 from xarray.coding.strings import check_vlen_dtype, create_vlen_dtype
 from xarray.coding.variables import SerializationWarning
+from xarray.compat.npcompat import HAS_STRING_DTYPE
 from xarray.conventions import encode_dataset_coordinates
 from xarray.core import indexing
 from xarray.core.common import _contains_cftime_datetimes
@@ -1088,8 +1089,9 @@ def test_roundtrip_empty_vlen_string_array(self) -> None:
                 # eg. NETCDF3 based backends do not roundtrip metadata
                 if actual["a"].dtype.metadata is not None:
                     assert check_vlen_dtype(actual["a"].dtype) is str
+            elif HAS_STRING_DTYPE:
+                assert np.issubdtype(actual["a"].dtype, np.dtypes.StringDType())
             else:
-                # zarr v3 sends back "<U1"
                 assert np.issubdtype(actual["a"].dtype, np.dtype("=U1"))

     @pytest.mark.parametrize(
diff --git a/xarray/tests/test_duck_array_ops.py b/xarray/tests/test_duck_array_ops.py
index 645a975a15a..a9a2d377449 100644
--- a/xarray/tests/test_duck_array_ops.py
+++ b/xarray/tests/test_duck_array_ops.py
@@ -13,6 +13,7 @@

 from xarray import DataArray, Dataset, concat, date_range
 from xarray.coding.times import _NS_PER_TIME_DELTA
+from xarray.compat.npcompat import HAS_STRING_DTYPE
 from xarray.core import dtypes, duck_array_ops
 from xarray.core.duck_array_ops import (
     array_notnull_equiv,
@@ -763,6 +764,43 @@ def test_isnull_with_dask():
     assert_equal(da.isnull().load(), da.load().isnull())

+@pytest.mark.skipif(not HAS_STRING_DTYPE, reason="requires StringDType to exist")
+@pytest.mark.parametrize(
+    ["input", "na_object", "expected"],
+    [
+        (
+            ["a", None, "c"],
+            None,
+            np.array([False, True, False]),
+        ),
+        (
+            ["a", "", "c"],
+            "",
+            np.array([False, True, False]),
+        ),
+        (
+            ["a", np.nan, "c"],
+            np.nan,
+            np.array([False, True, False]),
+        ),
+    ],
+)
+def test_isnull_with_different_StringDType_na_objects(input, na_object, expected):
+    dtype = np.dtypes.StringDType(na_object=na_object)
+    array = np.array(input, dtype=dtype)
+    actual = duck_array_ops.isnull(array)
+    np.testing.assert_equal(actual, expected)
+
+
+@pytest.mark.skipif(not HAS_STRING_DTYPE, reason="requires StringDType to exist")
+def test_isnull_with_default_StringDType():
+    dtype = np.dtypes.StringDType()
+    array = np.array(["a", np.nan, "c"], dtype=dtype)
+    expected = np.array([False, False, False])
+    actual = duck_array_ops.isnull(array)
+    np.testing.assert_equal(actual, expected)
+
+
 @pytest.mark.skipif(not has_dask, reason="This is for dask.")
 @pytest.mark.parametrize("axis", [0, -1, 1])
 @pytest.mark.parametrize("edge_order", [1, 2])

diff --git a/xarray/backends/zarr.py b/xarray/backends/zarr.py
index 8fcc968ca67..af2aad34d5e 100644
--- a/xarray/backends/zarr.py
+++ b/xarray/backends/zarr.py
@@ -206,6 +206,10 @@ def __init__(self, zarr_array):
             not _zarr_v3()
             and self._array.filters is not None
             and any(filt.codec_id == "vlen-utf8" for filt in self._array.filters)
+        ) or (
+            _zarr_v3()
+            and self._array.serializer
+            and self._array.serializer.to_dict()["name"] == "vlen-utf8"
         ):
             dtype = coding.strings.create_vlen_dtype(str)
         else:
diff --git a/xarray/core/duck_array_ops.py b/xarray/core/duck_array_ops.py
index 6739f2118ee..2377e6b01d0 100644
--- a/xarray/core/duck_array_ops.py
+++ b/xarray/core/duck_array_ops.py
@@ -26,6 +26,7 @@

 from xarray.compat import dask_array_compat, dask_array_ops
 from xarray.compat.array_api_compat import get_array_namespace
+from xarray.compat.npcompat import HAS_STRING_DTYPE
 from xarray.core import dtypes, nputils
 from xarray.core.extension_array import (
     PandasExtensionArray,
@@ -175,9 +176,15 @@ def isnull(data):
         # note: must check timedelta64 before integers, because currently
         # timedelta64 inherits from np.integer
         return isnat(data)
+    elif HAS_STRING_DTYPE and isinstance(scalar_type, np.dtypes.StringDType):
+        # na is settable, but it defaults to an empty string
+        na_object = getattr(scalar_type, "na_object", "")
+        if isna(na_object):
+            return xp.isnan(data)
+        else:
+            return data == na_object
     elif dtypes.isdtype(scalar_type, ("real floating", "complex floating"), xp=xp):
         # float types use NaN for null
-        xp = get_array_namespace(data)
         return xp.isnan(data)
     elif dtypes.isdtype(scalar_type, ("bool", "integral"), xp=xp) or (
         isinstance(scalar_type, np.dtype)


# Ordering of Coords in Repr

### What is your issue?

## Problem
This preferentially placing dimension coordinates first in the order. Which makes a lot of sense. But has a negative side effect for the visualization non-dimension coords that share a dimension wiht a dim coord. e..g here word coord and the other coords sharing the word dim are disconnected:

<img width="704" height="270" alt="Image" src="https://github.com/user-attachments/assets/4977b203-b0d0-494e-aec5-dfacde5d3add" />

Or a simple example:

```python
import numpy as np

ds = xr.Dataset(
    {"data": (("a", "b", "c"), np.random.rand(3, 3, 3))},
    coords={"a": [0, 1, 2], "b": [0, 1, 2], "c": [3, 2, 1]},
)

ds = ds.assign_coords({"a_1":('a', [5,6,7])})
ds = ds.assign_coords({"b_1":('b', [5,6,7])})
ds = ds.assign_coords({"c_1":('c', [5,6,7])})
print(ds)
```

```
<xarray.Dataset> Size: 360B
Dimensions:  (a: 3, b: 3, c: 3)
Coordinates:
  * a        (a) int64 24B 0 1 2
  * b        (b) int64 24B 0 1 2
  * c        (c) int64 24B 3 2 1
    a_1      (a) int64 24B 5 6 7
    b_1      (b) int64 24B 5 6 7
    c_1      (c) int64 24B 5 6 7
Data variables:
    data     (a, b, c) float64 216B 0.2621 0.7528 0.7416 ... 0.3515 0.1 0.2662
```

## Solution

The order of coords in the repr is currently defined here:

https://github.com/pydata/xarray/blob/c3747542521b438a94d495b3e85f1658df6a69f7/xarray/core/formatting.py#L450-L452

We could (possibly optionally) change the sort key to something like this:

```python
def _coord_sort_key(name, var, dims):
    """Sort key for coordinate ordering.

        Orders by:
        1. Primary: index of the first matching dimension in dataset dims
        2. Secondary: dimension coordinates (name == dim) come before non-dimension coordinates

        This groups non-dimension coordinates right after their associated dimension
    coordinate.
    """
    # Dimension coordinates sort by their position in dims, come first (0)
    if name in dims:
        return (dims.index(name), 0)

    # Non-dimension coordinates sort by their first dim, come second (1)
    for d in var.dims:
        if d in dims:
            return (dims.index(d), 1)

    # Scalar coords or coords with dims not in dataset dims go at end
    return (len(dims), 1)
```

which gives
```
<xarray.Dataset> Size: 360B
Dimensions:  (a: 3, b: 3, c: 3)
Coordinates:
  * a        (a) int64 24B 0 1 2
    a_1      (a) int64 24B 5 6 7
  * b        (b) int64 24B 0 1 2
    b_1      (b) int64 24B 5 6 7
  * c        (c) int64 24B 3 2 1
    c_1      (c) int64 24B 5 6 7
Data variables:
    data     (a, b, c) float64 216B 0.0534 0.7124 0.705 ... 0.5102 0.563 0.1605
```

or potentially going even farther and indenting the non-dimension coords

diff --git a/xarray/tests/test_dataset.py b/xarray/tests/test_dataset.py
index 656ce9575de..2848ae5a7be 100644
--- a/xarray/tests/test_dataset.py
+++ b/xarray/tests/test_dataset.py
@@ -294,8 +294,8 @@ def test_repr(self) -> None:
             Coordinates:
               * dim2     (dim2) float64 72B 0.0 0.5 1.0 1.5 2.0 2.5 3.0 3.5 4.0
               * dim3     (dim3) {data["dim3"].dtype} 40B 'a' 'b' 'c' 'd' 'e' 'f' 'g' 'h' 'i' 'j'
-              * time     (time) datetime64[ns] 160B 2000-01-01 2000-01-02 ... 2000-01-20
                 numbers  (dim3) int64 80B 0 1 2 0 0 1 1 2 2 3
+              * time     (time) datetime64[ns] 160B 2000-01-01 2000-01-02 ... 2000-01-20
             Dimensions without coordinates: dim1
             Data variables:
                 var1     (dim1, dim2) float64 576B -0.9891 -0.3678 1.288 ... -0.2116 0.364
@@ -875,8 +875,8 @@ def test_coords_properties(self) -> None:
             """\
         Coordinates:
           * x        (x) int64 16B -1 -2
-          * y        (y) int64 24B 0 1 2
             a        (x) int64 16B 4 5
+          * y        (y) int64 24B 0 1 2
             b        int64 8B -10"""
         )
         actual = repr(coords)
diff --git a/xarray/tests/test_datatree.py b/xarray/tests/test_datatree.py
index cb9e81da97a..9384270a0a6 100644
--- a/xarray/tests/test_datatree.py
+++ b/xarray/tests/test_datatree.py
@@ -644,8 +644,8 @@ def test_properties(self) -> None:
             """\
         Coordinates:
           * x        (x) int64 16B -1 -2
-          * y        (y) int64 24B 0 1 2
             a        (x) int64 16B 4 5
+          * y        (y) int64 24B 0 1 2
             b        int64 8B -10"""
         )
         actual = repr(coords)

diff --git a/xarray/core/common.py b/xarray/core/common.py
index 123d74dec72..80cbae822bc 100644
--- a/xarray/core/common.py
+++ b/xarray/core/common.py
@@ -619,9 +619,9 @@ def assign_coords(
         <xarray.Dataset> Size: 360B
         Dimensions:         (x: 2, y: 2, time: 4)
         Coordinates:
-          * time            (time) datetime64[ns] 32B 2014-09-06 ... 2014-09-09
             lon             (x, y) float64 32B 260.2 260.7 260.2 260.8
             lat             (x, y) float64 32B 42.25 42.21 42.63 42.59
+          * time            (time) datetime64[ns] 32B 2014-09-06 ... 2014-09-09
             reference_time  datetime64[ns] 8B 2014-09-05
         Dimensions without coordinates: x, y
         Data variables:
@@ -633,9 +633,9 @@ def assign_coords(
         <xarray.Dataset> Size: 360B
         Dimensions:         (x: 2, y: 2, time: 4)
         Coordinates:
-          * time            (time) datetime64[ns] 32B 2014-09-06 ... 2014-09-09
             lon             (x, y) float64 32B -99.83 -99.32 -99.79 -99.23
             lat             (x, y) float64 32B 42.25 42.21 42.63 42.59
+          * time            (time) datetime64[ns] 32B 2014-09-06 ... 2014-09-09
             reference_time  datetime64[ns] 8B 2014-09-05
         Dimensions without coordinates: x, y
         Data variables:
diff --git a/xarray/core/dataarray.py b/xarray/core/dataarray.py
index 706c35e5459..a6a7f1f80ae 100644
--- a/xarray/core/dataarray.py
+++ b/xarray/core/dataarray.py
@@ -369,9 +369,9 @@ class DataArray(
            [[22.60070734, 13.78914233, 14.17424919],
             [18.28478802, 16.15234857, 26.63418806]]])
     Coordinates:
-      * time            (time) datetime64[ns] 24B 2014-09-06 2014-09-07 2014-09-08
         lon             (x, y) float64 32B -99.83 -99.32 -99.79 -99.23
         lat             (x, y) float64 32B 42.25 42.21 42.63 42.59
+      * time            (time) datetime64[ns] 24B 2014-09-06 2014-09-07 2014-09-08
         reference_time  datetime64[ns] 8B 2014-09-05
     Dimensions without coordinates: x, y
     Attributes:
@@ -2807,8 +2807,8 @@ def set_index(
                [1., 1., 1.]])
         Coordinates:
           * x        (x) int64 16B 0 1
-          * y        (y) int64 24B 0 1 2
             a        (x) int64 16B 3 4
+          * y        (y) int64 24B 0 1 2
         >>> arr.set_index(x="a")
         <xarray.DataArray (x: 2, y: 3)> Size: 48B
         array([[1., 1., 1.],
@@ -5964,8 +5964,8 @@ def pad(
                [nan, nan, nan, nan]])
         Coordinates:
           * x        (x) float64 32B nan 0.0 1.0 nan
-          * y        (y) int64 32B 10 20 30 40
             z        (x) float64 32B nan 100.0 200.0 nan
+          * y        (y) int64 32B 10 20 30 40

         Careful, ``constant_values`` are coerced to the data type of the array which may
         lead to a loss of precision:
@@ -5978,8 +5978,8 @@ def pad(
                [ 1,  1,  1,  1]])
         Coordinates:
           * x        (x) float64 32B nan 0.0 1.0 nan
-          * y        (y) int64 32B 10 20 30 40
             z        (x) float64 32B nan 100.0 200.0 nan
+          * y        (y) int64 32B 10 20 30 40
         """
         ds = self._to_temp_dataset().pad(
             pad_width=pad_width,
diff --git a/xarray/core/dataset.py b/xarray/core/dataset.py
index 8513f69c445..fc82e89d021 100644
--- a/xarray/core/dataset.py
+++ b/xarray/core/dataset.py
@@ -322,10 +322,10 @@ class Dataset(
     <xarray.Dataset> Size: 552B
     Dimensions:         (loc: 2, instrument: 3, time: 4)
     Coordinates:
-      * instrument      (instrument) <U8 96B 'manufac1' 'manufac2' 'manufac3'
-      * time            (time) datetime64[ns] 32B 2014-09-06 ... 2014-09-09
         lon             (loc) float64 16B -99.83 -99.32
         lat             (loc) float64 16B 42.25 42.21
+      * instrument      (instrument) <U8 96B 'manufac1' 'manufac2' 'manufac3'
+      * time            (time) datetime64[ns] 32B 2014-09-06 ... 2014-09-09
         reference_time  datetime64[ns] 8B 2014-09-05
     Dimensions without coordinates: loc
     Data variables:
@@ -4733,8 +4733,8 @@ def set_index(
         Dimensions:  (x: 2, y: 3)
         Coordinates:
           * x        (x) int64 16B 0 1
-          * y        (y) int64 24B 0 1 2
             a        (x) int64 16B 3 4
+          * y        (y) int64 24B 0 1 2
         Data variables:
             v        (x, y) float64 48B 1.0 1.0 1.0 1.0 1.0 1.0
         >>> ds.set_index(x="a")
@@ -8713,9 +8713,9 @@ def filter_by_attrs(self, **kwargs) -> Self:
         <xarray.Dataset> Size: 192B
         Dimensions:         (x: 2, y: 2, time: 3)
         Coordinates:
-          * time            (time) datetime64[ns] 24B 2014-09-06 2014-09-07 2014-09-08
             lon             (x, y) float64 32B -99.83 -99.32 -99.79 -99.23
             lat             (x, y) float64 32B 42.25 42.21 42.63 42.59
+          * time            (time) datetime64[ns] 24B 2014-09-06 2014-09-07 2014-09-08
             reference_time  datetime64[ns] 8B 2014-09-05
         Dimensions without coordinates: x, y
         Data variables:
@@ -8728,9 +8728,9 @@ def filter_by_attrs(self, **kwargs) -> Self:
         <xarray.Dataset> Size: 288B
         Dimensions:         (x: 2, y: 2, time: 3)
         Coordinates:
-          * time            (time) datetime64[ns] 24B 2014-09-06 2014-09-07 2014-09-08
             lon             (x, y) float64 32B -99.83 -99.32 -99.79 -99.23
             lat             (x, y) float64 32B 42.25 42.21 42.63 42.59
+          * time            (time) datetime64[ns] 24B 2014-09-06 2014-09-07 2014-09-08
             reference_time  datetime64[ns] 8B 2014-09-05
         Dimensions without coordinates: x, y
         Data variables:
diff --git a/xarray/core/formatting.py b/xarray/core/formatting.py
index 25437be0990..b6a6bd2c4b4 100644
--- a/xarray/core/formatting.py
+++ b/xarray/core/formatting.py
@@ -443,12 +443,38 @@ def _mapping_repr(
 )

+def _coord_sort_key(coord, dims):
+    """Sort key for coordinate ordering.
+
+        Orders by:
+        1. Primary: index of the matching dimension in dataset dims.
+        2. Secondary: dimension coordinates (name == dim) come before non-dimension coordinates
+
+        This groups non-dimension coordinates right after their associated dimension
+    coordinate.
+    """
+    name, var = coord
+
+    # Dimension coordinates come first within their dim section
+    if name in dims:
+        return (dims.index(name), 0)
+
+    # Non-dimension coordinates come second within their dim section
+    # Check the var.dims list in backwards order to put (x, y) after (x) and (y)
+    for d in var.dims[::-1]:
+        if d in dims:
+            return (dims.index(d), 1)
+
+    # Scalar coords or coords with dims not in dataset dims go at the end
+    return (len(dims), 1)
+
+
 def coords_repr(coords: AbstractCoordinates, col_width=None, max_rows=None):
     if col_width is None:
         col_width = _calculate_col_width(coords)
     dims = tuple(coords._data.dims)
     dim_ordered_coords = sorted(
-        coords.items(), key=lambda x: dims.index(x[0]) if x[0] in dims else len(dims)
+        coords.items(), key=functools.partial(_coord_sort_key, dims=dims)
     )
     return _mapping_repr(
         dict(dim_ordered_coords),
diff --git a/xarray/core/formatting_html.py b/xarray/core/formatting_html.py
index 3f7dbc3d4b9..cfe5753904b 100644
--- a/xarray/core/formatting_html.py
+++ b/xarray/core/formatting_html.py
@@ -11,6 +11,7 @@
 from typing import TYPE_CHECKING

 from xarray.core.formatting import (
+    _coord_sort_key,
     filter_nondefault_indexes,
     inherited_vars,
     inline_index_repr,
@@ -120,7 +121,7 @@ def summarize_coords(variables) -> str:
     li_items = []
     dims = tuple(variables._data.dims)
     dim_ordered_coords = sorted(
-        variables.items(), key=lambda x: dims.index(x[0]) if x[0] in dims else len(dims)
+        variables.items(), key=partial(_coord_sort_key, dims=dims)
     )
     for k, v in dim_ordered_coords:
         li_content = summarize_variable(k, v, is_index=k in variables.xindexes)
diff --git a/xarray/structure/concat.py b/xarray/structure/concat.py
index 69b05880e3d..4ed4d3e3641 100644
--- a/xarray/structure/concat.py
+++ b/xarray/structure/concat.py
@@ -239,8 +239,8 @@ def concat(
     array([[0, 1, 2],
            [3, 4, 5]])
     Coordinates:
-      * y        (y) int64 24B 10 20 30
         x        (new_dim) <U1 8B 'a' 'b'
+      * y        (y) int64 24B 10 20 30
     Dimensions without coordinates: new_dim

     >>> xr.concat(
@@ -253,8 +253,8 @@ def concat(
            [3, 4, 5]])
     Coordinates:
       * new_dim  (new_dim) int64 16B -90 -100
-      * y        (y) int64 24B 10 20 30
         x        (new_dim) <U1 8B 'a' 'b'
+      * y        (y) int64 24B 10 20 30

     # Concatenate a scalar variable along a new dimension of the same name with and without creating a new index


# Better support for symbolic (e.g. jax tracer) values in non-index coordinates

### Is your feature request related to a problem?

Hello! [xarray_jax](https://github.com/google-deepmind/xarray_jax) maintainer here.

I am trying to decide whether it is feasible to rely on using symbolic arrays (such as jax tracers used during JIT compilation) as coordinate data in xarray.

For index coordinates this doesn't work at all, because xarray requires dynamic access to the data in the index to do alignment. So our approach in xarray_jax is to treat index coordinates as static data. I think this is acceptable: even if we could do index alignment with tracers, the result would be variable-shape which jax.jit can't handle without re-tracing anyway.

For non-index coordinates though, it is possible to use jax tracers! However these coordinates have a habit of disappearing due to failed alignment checks, for example in arithmetic operations:

```python
import xarray as xr
import jax
import jax.numpy as jnp

def foo(coord):
   a = xr.DataArray(data=jnp.ones(3), dims=['x'], coords={'foo': (['x'], coord)})
   b = xr.DataArray(data=jnp.ones(3), dims=['x'], coords={'foo': (['x'], coord)})
   print((a + b).coords)

foo(jnp.arange(3))
# prints:
# Coordinates:
#    foo      (x) int32 12B ...

jax.jit(foo)(jnp.arange(3))
# prints:
# Coordinates:
#    *empty*
```

The coordinate is (correctly) preserved when not JIT-ing, because xarray is able to do an equality comparison to check that the coordinate is compatible.

When JITing however, the coordinate array will be a jax Tracer, and the equality comparison will fail with a `jax.errors.TracerBoolConversionError` (or similar). xarray catches this under the hood [here](https://github.com/pydata/xarray/blob/main/xarray/core/variable.py#L1880) since `TracerBoolConversionError` subclasses `TypeError`. xarray then treats the failed comparison as not-equal resulting in the coordinate being dropped.

This isn't entirely consistent with behaviour elsewhere in xarray.

For example when doing an explicit merge it is possible to set `compat='override'` which ensures that coordinates aren't tested for equality and so jax tracers survive. And I believe `compat='override'` is to become the default soon.

`xr.align` also lets jax Tracers survive:

```python
def foo(coord):
   a = xr.DataArray(data=jnp.ones(3), dims=['x'], coords={'foo': (['x'], coord)})
   b = xr.DataArray(data=jnp.ones(3), dims=['x'], coords={'foo': (['x'], coord)})
   a, b = xr.align(a, b)
   print(a.coords)
   print(b.coords)

jax.jit(foo)(jnp.arange(3))
# prints:
# Coordinates:
#     foo      (x) int32 12B ...
# Coordinates:
#     foo      (x) int32 12B ...
```

Overall, while I can see why some might want them, these compatibility checks on non-index coordinates seem a bit of an awkward niche behaviour which is configurable in some places but not others, and (at least after the compat='override' default takes over) will have different defaults in different places too IIUC, which is not ideal. And of course they throw a bit of a spanner in the works of using jax here.

### Describe the solution you'd like

Three suggestions:

1. Add a `xarray.set_options(arithmetic_compat='override')` setting which controls this, and make 'override' the default for it at the same time `compat='override'` becomes the default more widely. Note there is already an `arithmetic_join` setting which controls how index coordinates are joined/aligned, but this doesn't give any control over these compatibility checks on non-index coordinates.
One might also consider exposing the `compat` option on `xr.align` too and any other places where compatibility checks are happening or for consistency ought to happen, to give control over this.

2. Allow `jax.errors.TracerBoolConversionError` to bubble up from generic equality-testing code (or perhaps catch and reraise it as something less jax-specific like `xr.SymbolicComparisonError`) rather than catching it and treating it as unequal. Then in coordinate compatibility-checking code, catch this error and fall back on `compat='override'` behavior in this specific case. This has the advantage that non-index-coord compatibility checks don't have to be disabled across the board, only for jax Tracers. It would require some jax-specific special case in xarray though.

3. Just to get rid of alignment checks on non-index coordinates entirely, if noone feels strongly about them. They are already somewhat inconsistent both in configurability and in default behaviour. In many cases it's a safe assumption that any non-index coordinates are a fixed function of the index coordinates and so it's sufficient to check for alignment of the index coordinates. And perhaps other cases it would be OK to make any checking the user's responsibility. I realise this would be backwards-incompatible though.

### Describe alternatives you've considered

See above.

### Additional context

_No response_

diff --git a/xarray/tests/test_dataarray.py b/xarray/tests/test_dataarray.py
index d1aacba6aaa..71fd7ec6cee 100644
--- a/xarray/tests/test_dataarray.py
+++ b/xarray/tests/test_dataarray.py
@@ -25,6 +25,7 @@
     DataArray,
     Dataset,
     IndexVariable,
+    MergeError,
     Variable,
     align,
     broadcast,
@@ -2626,6 +2627,43 @@ def test_math_with_coords(self) -> None:
         actual = alt + orig
         assert_identical(expected, actual)

+    def test_math_with_arithmetic_compat_options(self) -> None:
+        # Setting up a clash of non-index coordinate 'foo':
+        a = xr.DataArray(
+            data=[0, 0, 0],
+            dims=["x"],
+            coords={
+                "x": [1, 2, 3],
+                "foo": (["x"], [1.0, 2.0, np.nan]),
+            },
+        )
+        b = xr.DataArray(
+            data=[0, 0, 0],
+            dims=["x"],
+            coords={
+                "x": [1, 2, 3],
+                "foo": (["x"], [np.nan, 2.0, 3.0]),
+            },
+        )
+
+        with xr.set_options(arithmetic_compat="minimal"):
+            assert_equal(a + b, a.drop_vars("foo"))
+
+        with xr.set_options(arithmetic_compat="override"):
+            assert_equal(a + b, a)
+            assert_equal(b + a, b)
+
+        with xr.set_options(arithmetic_compat="no_conflicts"):
+            expected = a.assign_coords(foo=(["x"], [1.0, 2.0, 3.0]))
+            assert_equal(a + b, expected)
+            assert_equal(b + a, expected)
+
+        with xr.set_options(arithmetic_compat="equals"):
+            with pytest.raises(MergeError):
+                a + b
+            with pytest.raises(MergeError):
+                b + a
+
     def test_index_math(self) -> None:
         orig = DataArray(range(3), dims="x", name="x")
         actual = orig + 1
diff --git a/xarray/tests/test_dataset.py b/xarray/tests/test_dataset.py
index 4835c562d5a..a11bf55b28f 100644
--- a/xarray/tests/test_dataset.py
+++ b/xarray/tests/test_dataset.py
@@ -6990,6 +6990,41 @@ def test_binary_op_join_setting(self) -> None:
             actual = ds1 + ds2
             assert_equal(actual, expected)

+    def test_binary_op_compat_setting(self) -> None:
+        # Setting up a clash of non-index coordinate 'foo':
+        a = xr.Dataset(
+            data_vars={"var": (["x"], [0, 0, 0])},
+            coords={
+                "x": [1, 2, 3],
+                "foo": (["x"], [1.0, 2.0, np.nan]),
+            },
+        )
+        b = xr.Dataset(
+            data_vars={"var": (["x"], [0, 0, 0])},
+            coords={
+                "x": [1, 2, 3],
+                "foo": (["x"], [np.nan, 2.0, 3.0]),
+            },
+        )
+
+        with xr.set_options(arithmetic_compat="minimal"):
+            assert_equal(a + b, a.drop_vars("foo"))
+
+        with xr.set_options(arithmetic_compat="override"):
+            assert_equal(a + b, a)
+            assert_equal(b + a, b)
+
+        with xr.set_options(arithmetic_compat="no_conflicts"):
+            expected = a.assign_coords(foo=(["x"], [1.0, 2.0, 3.0]))
+            assert_equal(a + b, expected)
+            assert_equal(b + a, expected)
+
+        with xr.set_options(arithmetic_compat="equals"):
+            with pytest.raises(MergeError):
+                a + b
+            with pytest.raises(MergeError):
+                b + a
+
     @pytest.mark.parametrize(
         ["keep_attrs", "expected"],
         (
diff --git a/xarray/tests/test_datatree.py b/xarray/tests/test_datatree.py
index 0cd888f5782..cb9e81da97a 100644
--- a/xarray/tests/test_datatree.py
+++ b/xarray/tests/test_datatree.py
@@ -2423,6 +2423,46 @@ def test_arithmetic_inherited_coords(self) -> None:
         expected["/foo/bar"].data = np.array([8, 10, 12])
         assert_identical(actual, expected)

+    def test_binary_op_compat_setting(self) -> None:
+        # Setting up a clash of non-index coordinate 'foo':
+        a = DataTree(
+            xr.Dataset(
+                data_vars={"var": (["x"], [0, 0, 0])},
+                coords={
+                    "x": [1, 2, 3],
+                    "foo": (["x"], [1.0, 2.0, np.nan]),
+                },
+            )
+        )
+        b = DataTree(
+            xr.Dataset(
+                data_vars={"var": (["x"], [0, 0, 0])},
+                coords={
+                    "x": [1, 2, 3],
+                    "foo": (["x"], [np.nan, 2.0, 3.0]),
+                },
+            )
+        )
+
+        with xr.set_options(arithmetic_compat="minimal"):
+            expected = DataTree(a.dataset.drop_vars("foo"))
+            assert_equal(a + b, expected)
+
+        with xr.set_options(arithmetic_compat="override"):
+            assert_equal(a + b, a)
+            assert_equal(b + a, b)
+
+        with xr.set_options(arithmetic_compat="no_conflicts"):
+            expected = DataTree(a.dataset.assign_coords(foo=(["x"], [1.0, 2.0, 3.0])))
+            assert_equal(a + b, expected)
+            assert_equal(b + a, expected)
+
+        with xr.set_options(arithmetic_compat="equals"):
+            with pytest.raises(xr.MergeError):
+                a + b
+            with pytest.raises(xr.MergeError):
+                b + a
+
     def test_binary_op_commutativity_with_dataset(self) -> None:
         # regression test for #9365

diff --git a/xarray/core/coordinates.py b/xarray/core/coordinates.py
index 501c58657cf..85a9d97abeb 100644
--- a/xarray/core/coordinates.py
+++ b/xarray/core/coordinates.py
@@ -21,7 +21,14 @@
     assert_no_index_corrupted,
     create_default_index_implicit,
 )
-from xarray.core.types import DataVars, ErrorOptions, Self, T_DataArray, T_Xarray
+from xarray.core.types import (
+    CompatOptions,
+    DataVars,
+    ErrorOptions,
+    Self,
+    T_DataArray,
+    T_Xarray,
+)
 from xarray.core.utils import (
     Frozen,
     ReprObject,
@@ -31,6 +38,7 @@
 from xarray.core.variable import Variable, as_variable, calculate_dimensions
 from xarray.structure.alignment import Aligner
 from xarray.structure.merge import merge_coordinates_without_align, merge_coords
+from xarray.util.deprecation_helpers import CombineKwargDefault

 if TYPE_CHECKING:
     from xarray.core.common import DataWithCoords
@@ -500,18 +508,20 @@ def _drop_coords(self, coord_names):
         # redirect to DatasetCoordinates._drop_coords
         self._data.coords._drop_coords(coord_names)

-    def _merge_raw(self, other, reflexive):
+    def _merge_raw(self, other, reflexive, compat: CompatOptions | CombineKwargDefault):
         """For use with binary arithmetic."""
         if other is None:
             variables = dict(self.variables)
             indexes = dict(self.xindexes)
         else:
             coord_list = [self, other] if not reflexive else [other, self]
-            variables, indexes = merge_coordinates_without_align(coord_list)
+            variables, indexes = merge_coordinates_without_align(
+                coord_list, compat=compat
+            )
         return variables, indexes

     @contextmanager
-    def _merge_inplace(self, other):
+    def _merge_inplace(self, other, compat: CompatOptions | CombineKwargDefault):
         """For use with in-place binary arithmetic."""
         if other is None:
             yield
@@ -524,12 +534,17 @@ def _merge_inplace(self, other):
                 if k not in self.xindexes
             }
             variables, indexes = merge_coordinates_without_align(
-                [self, other], prioritized
+                [self, other], prioritized, compat=compat
             )
             yield
             self._update_coords(variables, indexes)

-    def merge(self, other: Mapping[Any, Any] | None) -> Dataset:
+    def merge(
+        self,
+        other: Mapping[Any, Any] | None,
+        *,
+        compat: CompatOptions | CombineKwargDefault = "minimal",
+    ) -> Dataset:
         """Merge two sets of coordinates to create a new Dataset

         The method implements the logic used for joining coordinates in the
@@ -546,6 +561,8 @@ def merge(self, other: Mapping[Any, Any] | None) -> Dataset:
         other : dict-like, optional
             A :py:class:`Coordinates` object or any mapping that can be turned
             into coordinates.
+        compat : {"identical", "equals", "broadcast_equals", "no_conflicts", "override", "minimal"}, default: "minimal"
+            Compatibility checks to use between coordinate variables.

         Returns
         -------
@@ -560,7 +577,7 @@ def merge(self, other: Mapping[Any, Any] | None) -> Dataset:
         if not isinstance(other, Coordinates):
             other = Dataset(coords=other).coords

-        coords, indexes = merge_coordinates_without_align([self, other])
+        coords, indexes = merge_coordinates_without_align([self, other], compat=compat)
         coord_names = set(coords)
         return Dataset._construct_direct(
             variables=coords, coord_names=coord_names, indexes=indexes
diff --git a/xarray/core/dataarray.py b/xarray/core/dataarray.py
index fcfa0317131..706c35e5459 100644
--- a/xarray/core/dataarray.py
+++ b/xarray/core/dataarray.py
@@ -4906,7 +4906,9 @@ def _binary_op(
             if not reflexive
             else f(other_variable_or_arraylike, self.variable)
         )
-        coords, indexes = self.coords._merge_raw(other_coords, reflexive)
+        coords, indexes = self.coords._merge_raw(
+            other_coords, reflexive, compat=OPTIONS["arithmetic_compat"]
+        )
         name = result_name([self, other])

         return self._replace(variable, coords, name, indexes=indexes)
@@ -4926,7 +4928,9 @@ def _inplace_binary_op(self, other: DaCompatible, f: Callable) -> Self:
         other_coords = getattr(other, "coords", None)
         other_variable = getattr(other, "variable", other)
         try:
-            with self.coords._merge_inplace(other_coords):
+            with self.coords._merge_inplace(
+                other_coords, compat=OPTIONS["arithmetic_compat"]
+            ):
                 f(self.variable, other_variable)
         except MergeError as exc:
             raise MergeError(
diff --git a/xarray/core/dataset.py b/xarray/core/dataset.py
index 84a67d95412..e15f1077639 100644
--- a/xarray/core/dataset.py
+++ b/xarray/core/dataset.py
@@ -7763,7 +7763,7 @@ def apply_over_both(lhs_data_vars, rhs_data_vars, lhs_vars, rhs_vars):
             return type(self)(new_data_vars)

         other_coords: Coordinates | None = getattr(other, "coords", None)
-        ds = self.coords.merge(other_coords)
+        ds = self.coords.merge(other_coords, compat=OPTIONS["arithmetic_compat"])

         if isinstance(other, Dataset):
             new_vars = apply_over_both(
diff --git a/xarray/core/options.py b/xarray/core/options.py
index 451070ce7b4..9af6117d237 100644
--- a/xarray/core/options.py
+++ b/xarray/core/options.py
@@ -2,14 +2,16 @@

 import warnings
 from collections.abc import Sequence
-from typing import TYPE_CHECKING, Any, Literal, TypedDict
+from typing import TYPE_CHECKING, Any, Literal, TypedDict, get_args

+from xarray.core.types import CompatOptions
 from xarray.core.utils import FrozenDict

 if TYPE_CHECKING:
     from matplotlib.colors import Colormap

     Options = Literal[
+        "arithmetic_compat",
         "arithmetic_join",
         "chunk_manager",
         "cmap_divergent",
@@ -40,6 +42,7 @@

     class T_Options(TypedDict):
         arithmetic_broadcast: bool
+        arithmetic_compat: CompatOptions
         arithmetic_join: Literal["inner", "outer", "left", "right", "exact"]
         chunk_manager: str
         cmap_divergent: str | Colormap
@@ -70,6 +73,7 @@ class T_Options(TypedDict):

 OPTIONS: T_Options = {
     "arithmetic_broadcast": True,
+    "arithmetic_compat": "minimal",
     "arithmetic_join": "inner",
     "chunk_manager": "dask",
     "cmap_divergent": "RdBu_r",
@@ -109,6 +113,7 @@ def _positive_integer(value: Any) -> bool:

 _VALIDATORS = {
     "arithmetic_broadcast": lambda value: isinstance(value, bool),
+    "arithmetic_compat": get_args(CompatOptions).__contains__,
     "arithmetic_join": _JOIN_OPTIONS.__contains__,
     "display_max_children": _positive_integer,
     "display_max_rows": _positive_integer,
@@ -178,8 +183,27 @@ class set_options:

     Parameters
     ----------
+    arithmetic_broadcast : bool, default: True
+        Whether to perform automatic broadcasting in binary operations.
+    arithmetic_compat: {"identical", "equals", "broadcast_equals", "no_conflicts", "override", "minimal"}, default: "minimal"
+        How to compare non-index coordinates of the same name for potential
+        conflicts when performing binary operations. (For the alignment of index
+        coordinates in binary operations, see `arithmetic_join`.)
+
+        - "identical": all values, dimensions and attributes of the coordinates
+          must be the same.
+        - "equals": all values and dimensions of the coordinates must be the
+          same.
+        - "broadcast_equals": all values of the coordinates must be equal after
+          broadcasting to ensure common dimensions.
+        - "no_conflicts": only values which are not null in both coordinates
+          must be equal. The returned coordinate then contains the combination
+          of all non-null values.
+        - "override": skip comparing and take the coordinates from the first
+          operand.
+        - "minimal": drop conflicting coordinates.
     arithmetic_join : {"inner", "outer", "left", "right", "exact"}, default: "inner"
-        DataArray/Dataset alignment in binary operations:
+        DataArray/Dataset index alignment in binary operations:

         - "outer": use the union of object indexes
         - "inner": use the intersection of object indexes
@@ -187,9 +211,6 @@ class set_options:
         - "right": use indexes from the last object with each dimension
         - "exact": instead of aligning, raise `ValueError` when indexes to be
           aligned are not equal
-        - "override": if indexes are of same size, rewrite indexes to be
-          those of the first object with that dimension. Indexes for the same
-          dimension must have the same size in all objects.
     chunk_manager : str, default: "dask"
         Chunk manager to use for chunked array computations when multiple
         options are installed.
diff --git a/xarray/structure/merge.py b/xarray/structure/merge.py
index d398a37c8ef..d4790421070 100644
--- a/xarray/structure/merge.py
+++ b/xarray/structure/merge.py
@@ -438,6 +438,7 @@ def merge_coordinates_without_align(
     prioritized: Mapping[Any, MergeElement] | None = None,
     exclude_dims: AbstractSet = frozenset(),
     combine_attrs: CombineAttrsOptions = "override",
+    compat: CompatOptions | CombineKwargDefault = "minimal",
 ) -> tuple[dict[Hashable, Variable], dict[Hashable, Index]]:
     """Merge variables/indexes from coordinates without automatic alignments.

@@ -462,7 +463,7 @@ def merge_coordinates_without_align(
     # TODO: indexes should probably be filtered in collected elements
     # before merging them
     merged_coords, merged_indexes = merge_collected(
-        filtered, prioritized, combine_attrs=combine_attrs
+        filtered, prioritized, compat=compat, combine_attrs=combine_attrs
     )
     merged_indexes = filter_indexes_from_coords(merged_indexes, set(merged_coords))


# Can not use map when grouping by multiple variables

### What happened?

Exception when using group by and map when grouping by multiple variables.

### What did you expect to happen?

_No response_

### Minimal Complete Verifiable Example

```Python
# /// script
# requires-python = ">=3.11"
# dependencies = [
#   "xarray[complete]@git+https://github.com/pydata/xarray.git@main",
# ]
# ///
#
# This script automatically imports the development branch of xarray to check for issues.
# Please delete this header if you have _not_ tested this script with `uv run`!

import xarray as xr

xr.show_versions()
d = xr.DataArray(
    [[0, 1], [2, 3]],
    coords={
        "lon": (["ny", "nx"], [[30, 40], [40, 50]]),
        "lat": (["ny", "nx"], [[10, 10], [20, 20]]),
    },
    dims=["ny", "nx"],
)

d.groupby(('lon', 'lat')).mean()  # works
d.groupby('lon').map(lambda x: x)  # works
d.groupby(('lon', 'lat')).map(lambda x: x)  # fails
#   File "xarray/core/nputils.py", line 93, in inverse_permutation
#     inverse_permutation[indices] = np.arange(len(indices), dtype=np.intp)
#     ~~~~~~~~~~~~~~~~~~~^^^^^^^^^
# IndexError: arrays used as indices must be of integer (or boolean) type
```

### Steps to reproduce

_No response_

### MVCE confirmation

- [x] Minimal example — the example is as focused as reasonably possible to demonstrate the underlying issue in xarray.
- [x] Complete example — the example is self-contained, including all data and the text of any traceback.
- [x] Verifiable example — the example copy & pastes into an IPython prompt or [Binder notebook](https://mybinder.org/v2/gh/pydata/xarray/main?urlpath=lab/tree/doc/examples/blank_template.ipynb), returning the result.
- [x] New issue — a search of GitHub Issues suggests this is not a duplicate.
- [x] Recent environment — the issue occurs with the latest version of xarray and its dependencies.

### Relevant log output

```Python

```

### Anything else we need to know?

_No response_

### Environment

<details>

INSTALLED VERSIONS
------------------
commit: None
python: 3.12.11 | packaged by conda-forge | (main, Jun  4 2025, 14:38:53) [Clang 18.1.8 ]
python-bits: 64
OS: Darwin
OS-release: 25.1.0
machine: arm64
processor: arm
byteorder: little
LC_ALL: None
LANG: C.UTF-8
LOCALE: ('C', 'UTF-8')
libhdf5: 1.14.6
libnetcdf: None
xarray: 2025.12.0
pandas: 2.3.3
numpy: 1.26.4
scipy: 1.16.1
netCDF4: None
pydap: None
h5netcdf: None
h5py: 3.14.0
zarr: 2.18.7
cftime: None
nc_time_axis: None
iris: None
bottleneck: None
dask: 2025.11.0
distributed: 2025.11.0
matplotlib: 3.10.3
cartopy: None
seaborn: 0.13.2
numbagg: None
fsspec: 2025.7.0
cupy: None
pint: 0.24.4
sparse: 0.17.0
flox: 0.10.7
numpy_groupies: 0.11.3
setuptools: 80.9.0
pip: 25.3
conda: None
pytest: 8.4.2
mypy: None
IPython: 9.6.0
sphinx: 8.1.3

</details>

diff --git a/xarray/tests/test_groupby.py b/xarray/tests/test_groupby.py
index c320931098a..547c3de4144 100644
--- a/xarray/tests/test_groupby.py
+++ b/xarray/tests/test_groupby.py
@@ -3847,6 +3847,20 @@ def test_groupby_bins_mean_time_series():
     assert ds_agged.time.dtype == np.dtype("datetime64[ns]")

+def test_groupby_multi_map():
+    # https://github.com/pydata/xarray/issues/11004
+    d = xr.DataArray(
+        [[0, 1], [2, 3]],
+        coords={
+            "lon": (["ny", "nx"], [[30, 40], [40, 50]]),
+            "lat": (["ny", "nx"], [[10, 10], [20, 20]]),
+        },
+        dims=["ny", "nx"],
+    )
+    xr.testing.assert_equal(d, d.groupby("lon").map(lambda x: x))
+    xr.testing.assert_equal(d, d.groupby(("lon", "lat")).map(lambda x: x))
+
+
 # TODO: Possible property tests to add to this module
 # 1. lambda x: x
 # 2. grouped-reduce on unique coords is identical to array

diff --git a/xarray/core/groupby.py b/xarray/core/groupby.py
index 827c0a3588f..77ea1b341c9 100644
--- a/xarray/core/groupby.py
+++ b/xarray/core/groupby.py
@@ -173,11 +173,12 @@ def _inverse_permutation_indices(positions, N: int | None = None) -> np.ndarray

     if isinstance(positions[0], slice):
         positions = _consolidate_slices(positions)
-        if positions == slice(None):
+        if positions == [slice(None)] or positions == [slice(0, None)]:
             return None
         positions = [np.arange(sl.start, sl.stop, sl.step) for sl in positions]
-
-    newpositions = nputils.inverse_permutation(np.concatenate(positions), N)
+    newpositions = nputils.inverse_permutation(
+        np.concatenate(tuple(p for p in positions if len(p) > 0)), N
+    )
     return newpositions[newpositions != -1]

@@ -990,7 +991,7 @@ def _maybe_reindex(self, combined):
             if (has_missing_groups and index is not None) or (
                 len(self.groupers) > 1
                 and not isinstance(grouper.full_index, pd.RangeIndex)
-                and not index.index.equals(grouper.full_index)
+                and not (index is not None and index.index.equals(grouper.full_index))
             ):
                 indexers[grouper.name] = grouper.full_index
         if indexers:


# Don't run checkers just for their reports when all messages are disabled

## Type of Changes

<!-- Leave the corresponding lines for the applicable type of change: -->

|     | Type                   |
| --- | ---------------------- |
| ✓   | :bug: Bug fix          |

## Description

Previously, prepare_checkers() would include a checker if any of its reports were enabled, even when all its messages were disabled. This meant checkers like `similarities` would still run their expensive computations (e.g. O(n²) duplicate-code detection) just to populate a report the user didn't ask for.

Now, enabled reports only cause a checker to be included if it defines no messages at all (i.e. pure report-only checkers like RawMetrics).

Closes #3443

diff --git a/tests/lint/unittest_lint.py b/tests/lint/unittest_lint.py
index 7ba8879e9a..f98cb05d84 100644
--- a/tests/lint/unittest_lint.py
+++ b/tests/lint/unittest_lint.py
@@ -445,6 +445,20 @@ def test_disable_similar(initialized_linter: PyLinter) -> None:
     assert "similarities" not in [c.name for c in linter.prepare_checkers()]

+def test_disable_similar_with_reports(initialized_linter: PyLinter) -> None:
+    """Disabling R0801 should exclude the similarities checker even with reports enabled.
+
+    Regression test for https://github.com/pylint-dev/pylint/issues/3443
+    """
+    linter = initialized_linter
+    linter.set_option("reports", True)
+    linter.set_option("disable", "R0801")
+    checker_names = [c.name for c in linter.prepare_checkers()]
+    assert "similarities" not in checker_names
+    # Report-only checkers (no messages) should still be included
+    assert "metrics" in checker_names
+
+
 def test_disable_alot(linter: PyLinter) -> None:
     """Check that we disabled a lot of checkers."""
     linter.set_option("reports", False)

diff --git a/pylint/lint/pylinter.py b/pylint/lint/pylinter.py
index 4f214f88b9..24388a382e 100644
--- a/pylint/lint/pylinter.py
+++ b/pylint/lint/pylinter.py
@@ -593,7 +593,10 @@ def prepare_checkers(self) -> list[BaseChecker]:
         needed_checkers: list[BaseChecker] = [self]
         for checker in self.get_checkers()[1:]:
             messages = {msg for msg in checker.msgs if self.is_message_enabled(msg)}
-            if messages or any(self.report_is_enabled(r[0]) for r in checker.reports):
+            if messages or (
+                not checker.msgs
+                and any(self.report_is_enabled(r[0]) for r in checker.reports)
+            ):
                 needed_checkers.append(checker)
         return needed_checkers


# ``duplicate-code`` takes over an hour for a large project even when disabled, if ``reports=yes``

<!--
  Hi there! Thank you for discovering and submitting an issue.

  Before you submit this, make sure that the issue doesn't already exist
  or if it is not closed.

  Is your issue fixed on the preview release?: pip install pylint astroid --pre -U

-->

In a directory with 500000 LOC of python files, we noticed pylint would hang for the entire directory. After investigating, it actually was just taking **forever** (60+ minutes) to compute similarities. We have similarities disabled for our project, and it was doing this regardless.
After removing pylint/checkers/similarities.py, it took about 3 minutes to do all of the checks we wanted.

### Steps to reproduce
1. Find some project with 500000 lines
2. Run pylint
3. Wait an hour

### Current behavior
It takes an hour to run similarities on a project, despite similarities being disabled.

### Expected behavior
Similarities are not checked and do not consume so much time. Or they take a non-noticeable amount of time.

### pylint --version output
```
$ pylint --version
Starting at 18:07:52
pylint3 2.3.1
astroid 2.2.5
Python 3.7.3 (default, Aug  8 2019, 00:00:00)
[GCC 7.3.1 20180303 (Red Hat 7.3.1-5)]
(datarobot-6.0) Completed at 18:07:53
```

diff --git a/tests/lint/unittest_lint.py b/tests/lint/unittest_lint.py
index 49d02f7e77..c037c3636c 100644
--- a/tests/lint/unittest_lint.py
+++ b/tests/lint/unittest_lint.py
@@ -445,6 +445,20 @@ def test_disable_similar(initialized_linter: PyLinter) -> None:
     assert "similarities" not in [c.name for c in linter.prepare_checkers()]

+def test_disable_similar_with_reports(initialized_linter: PyLinter) -> None:
+    """Disabling R0801 should exclude the similarities checker even with reports enabled.
+
+    Regression test for https://github.com/pylint-dev/pylint/issues/3443
+    """
+    linter = initialized_linter
+    linter.set_option("reports", True)
+    linter.set_option("disable", "R0801")
+    checker_names = [c.name for c in linter.prepare_checkers()]
+    assert "similarities" not in checker_names
+    # Report-only checkers (no messages) should still be included
+    assert "metrics" in checker_names
+
+
 def test_disable_alot(linter: PyLinter) -> None:
     """Check that we disabled a lot of checkers."""
     linter.set_option("reports", False)

diff --git a/pylint/lint/pylinter.py b/pylint/lint/pylinter.py
index 90fb0a3360..088e9c15c0 100644
--- a/pylint/lint/pylinter.py
+++ b/pylint/lint/pylinter.py
@@ -617,7 +617,10 @@ def prepare_checkers(self) -> list[BaseChecker]:
         needed_checkers: list[BaseChecker] = [self]
         for checker in self.get_checkers()[1:]:
             messages = {msg for msg in checker.msgs if self.is_message_enabled(msg)}
-            if messages or any(self.report_is_enabled(r[0]) for r in checker.reports):
+            if messages or (
+                not checker.msgs
+                and any(self.report_is_enabled(r[0]) for r in checker.reports)
+            ):
                 needed_checkers.append(checker)
         return needed_checkers


# Fix undefined-variable false positive with metaclass in nested class

## Description

Fixes #10823
Closes #10823

When an imported name is used as a `metaclass=` argument inside a nested class within a method, pylint incorrectly flags subsequent uses of that name at module level as `undefined-variable`.

### Reproducer

```python
import abc

class Test:
    def test1(self):
        class A(metaclass=abc.ABCMeta):
            pass

abc.ABCMeta  # E0602: Undefined variable 'abc' (undefined-variable)
```

### Root cause

`_check_classdef_metaclasses()` walks enclosing scopes to find the name used as a metaclass and collects `(scope_locals_dict, name)` tuples. Then `_check_metaclasses()` calls `scope_locals.pop(name, None)` — removing the name entirely from `to_consume` in the enclosing scope.

This was intended to prevent unused-import/unused-variable false positives: the metaclass usage should "count" as using the name. But `pop()` removes the name without moving it to the consumer's `consumed` dict. Later references to the same name can't find it in either `to_consume` or `consumed`, so pylint reports `undefined-variable`.

### Fix

Replace the raw `scope_locals.pop()` with `NamesConsumer.mark_as_consumed()`. This properly moves the name from `to_consume` to `consumed`, preserving both behaviors:

1. The import is not flagged as unused (moved out of `to_consume`)
2. Later references can find the name in `consumed` (no `undefined-variable`)

The function signatures change to pass the `NamesConsumer` and `found_nodes` needed by `mark_as_consumed()`.

### Test

Added `tests/functional/u/undefined/undefined_variable_metaclass_nested.py` — reproducer from the issue.

diff --git a/tests/functional/u/undefined/undefined_variable_metaclass_nested.py b/tests/functional/u/undefined/undefined_variable_metaclass_nested.py
new file mode 100644
index 0000000000..b1a29d0c8e
--- /dev/null
+++ b/tests/functional/u/undefined/undefined_variable_metaclass_nested.py
@@ -0,0 +1,20 @@
+"""Tests for undefined-variable false positive when a name is used as a metaclass
+in a nested class inside a method, and then used again at module level.
+
+https://github.com/pylint-dev/pylint/issues/10823
+"""
+# pylint: disable=too-few-public-methods,missing-class-docstring,missing-function-docstring
+# pylint: disable=unused-variable,pointless-statement
+
+import abc
+
+
+class Test:
+    def test1(self):
+        class A(metaclass=abc.ABCMeta):
+            pass
+
+
+# This should NOT trigger undefined-variable — abc was imported at module level
+# and consumed by the metaclass usage, but it should still be resolvable.
+abc.ABCMeta

diff --git a/pylint/checkers/variables.py b/pylint/checkers/variables.py
index 5add0732c0..fbb7f69072 100644
--- a/pylint/checkers/variables.py
+++ b/pylint/checkers/variables.py
@@ -3387,27 +3387,29 @@ def _check_imports(self, not_consumed: Consumption) -> None:

     def _check_metaclasses(self, node: nodes.Module | nodes.FunctionDef) -> None:
         """Update consumption analysis for metaclasses."""
-        consumed: list[tuple[Consumption, str]] = []
+        consumed: list[tuple[NamesConsumer, str, list[nodes.NodeNG]]] = []

         for child_node in node.get_children():
             if isinstance(child_node, nodes.ClassDef):
                 consumed.extend(self._check_classdef_metaclasses(child_node, node))

-        # Pop the consumed items, in order to avoid having
-        # unused-import and unused-variable false positives
-        for scope_locals, name in consumed:
-            scope_locals.pop(name, None)
+        # Mark the consumed items properly so they move from to_consume
+        # to consumed, avoiding unused-import/unused-variable false positives
+        # while still allowing subsequent references to resolve.
+        for consumer, name, found_nodes in consumed:
+            if name in consumer.to_consume:
+                consumer.mark_as_consumed(name, found_nodes)

     def _check_classdef_metaclasses(
         self,
         klass: nodes.ClassDef,
         parent_node: nodes.Module | nodes.FunctionDef,
-    ) -> list[tuple[Consumption, str]]:
+    ) -> list[tuple[NamesConsumer, str, list[nodes.NodeNG]]]:
         if not klass._metaclass:
             # Skip if this class doesn't use explicitly a metaclass, but inherits it from ancestors
             return []

-        consumed: list[tuple[Consumption, str]] = []
+        consumed: list[tuple[NamesConsumer, str, list[nodes.NodeNG]]] = []
         metaclass = klass.metaclass()
         name = ""
         match klass._metaclass:
@@ -3433,7 +3435,7 @@ def _check_classdef_metaclasses(
                 found_nodes = scope_locals.get(name, [])
                 for found_node in found_nodes:
                     if found_node.lineno <= klass.lineno:
-                        consumed.append((scope_locals, name))
+                        consumed.append((to_consume, name, found_nodes))
                         found = True
                         break
             # Check parent scope


# Wrong undefined-variable detection after object is used as metaclass

### Bug description

```python
from foo import bar

class Test:

    def test1(self):

        class A(metaclass=bar.Meta):
            pass

bar.Meta  # Triggers E0602: Undefined variable 'bar' (undefined-variable)
```

What seems to be happening: `bar` is passed as `metaclass` parameter of a class defined inside of a method of another class. This somehow makes it undefined. Because of that using `bar` anywhere else later in the module will trigger an `undefined-variable` error.

### Configuration

```ini

```

### Command used

```shell
pylint -E undefined_var.py
```

### Pylint output

```python
************* Module undefined_var
undefined_var.py:12:0: E0602: Undefined variable 'bar' (undefined-variable)
```

### Expected behavior

`bar` is a well defined object. `undefined-variable` shouldn't be raised.

### Pylint version

```shell
pylint 4.0.4
astroid 4.0.3
Python 3.13

(Also reproducible with earlier versions.)
```

### OS / Environment

Not important.

### Additional dependencies

```python

```


# Fix FP for `invalid-name` with `typing.Final` on dataclass fields

## Type of Changes
|     | Type                   |
| --- | ---------------------- |
| ✓   | :bug: Bug fix          |
## Description
Before, dataclass fields annotated with `Final` were treated like all other class variables annotated with `Final` and required to be uppercase. But that's not how dataclasses work -- you still want lower cased fields even if marked with `Final`. An exception would be `ClassVar[Final]`, which doesn't give you a field.

Closes #10790

diff --git a/tests/functional/i/invalid/invalid_name/invalid_name_with_final_typing.py b/tests/functional/i/invalid/invalid_name/invalid_name_with_final_typing.py
index 3d32d8a2b4..f0f28687f2 100644
--- a/tests/functional/i/invalid/invalid_name/invalid_name_with_final_typing.py
+++ b/tests/functional/i/invalid/invalid_name/invalid_name_with_final_typing.py
@@ -3,7 +3,7 @@
 # pylint: disable=missing-class-docstring, missing-function-docstring

 from dataclasses import dataclass
-from typing import Final
+from typing import ClassVar, Final

 module_snake_case_constant: Final[int] = 42  # [invalid-name]
 MODULE_UPPER_CASE_CONSTANT: Final[int] = 42
@@ -17,8 +17,11 @@ def function() -> None:

 @dataclass
 class Class:
-    class_snake_case_constant: Final[int] = 42  # [invalid-name]
-    CLASS_UPPER_CASE_CONSTANT: Final[int] = 42
+    class_snake_case_constant: ClassVar[Final[int]] = 42  # [invalid-name]
+    CLASS_UPPER_CASE_CONSTANT: ClassVar[Final[int]] = 42
+
+    field_annotated_with_final: Final[int] = 42
+    FIELD_ANNOTATED_WITH_FINAL: Final[int] = 42

     def method(self) -> None:
         method_snake_case_constant: Final[int] = 42

diff --git a/pylint/checkers/base/name_checker/checker.py b/pylint/checkers/base/name_checker/checker.py
index 9b3ed0ed1f..41ccc674a1 100644
--- a/pylint/checkers/base/name_checker/checker.py
+++ b/pylint/checkers/base/name_checker/checker.py
@@ -552,9 +552,14 @@ def visit_assignname(  # pylint: disable=too-many-branches,too-many-statements
         elif isinstance(frame, nodes.ClassDef) and not any(
             frame.local_attr_ancestors(node.name)
         ):
-            if utils.is_enum_member(node) or utils.is_assign_name_annotated_with(
-                node, "Final"
-            ):
+            if utils.is_assign_name_annotated_with_class_var_typing_name(node, "Final"):
+                self._check_name("class_const", node.name, node)
+            elif utils.is_assign_name_annotated_with(node, "Final"):
+                if frame.is_dataclass:
+                    self._check_name("class_attribute", node.name, node)
+                else:
+                    self._check_name("class_const", node.name, node)
+            elif utils.is_enum_member(node):
                 self._check_name("class_const", node.name, node)
             else:
                 self._check_name("class_attribute", node.name, node)
diff --git a/pylint/checkers/utils.py b/pylint/checkers/utils.py
index 7536658209..90c63922f6 100644
--- a/pylint/checkers/utils.py
+++ b/pylint/checkers/utils.py
@@ -1762,6 +1762,22 @@ def is_assign_name_annotated_with(node: nodes.AssignName, typing_name: str) -> b
     return False

+def is_assign_name_annotated_with_class_var_typing_name(
+    node: nodes.AssignName, typing_name: str
+) -> bool:
+    if not is_assign_name_annotated_with(node, "ClassVar"):
+        return False
+    annotation = node.parent.annotation
+    if isinstance(annotation, nodes.Subscript):
+        annotation = annotation.slice
+        if isinstance(annotation, nodes.Subscript):
+            annotation = annotation.value
+    match annotation:
+        case nodes.Name(name=n) | nodes.Attribute(attrname=n) if n == typing_name:
+            return True
+    return False
+
+
 def get_iterating_dictionary_name(node: nodes.For | nodes.Comprehension) -> str | None:
     """Get the name of the dictionary which keys are being iterated over on
     a ``nodes.For`` or ``nodes.Comprehension`` node.


# Pylint C0103 (invalid-name) for dataclass Final field

### Bug description

A special case of https://github.com/pylint-dev/pylint/issues/10711.
According to https://github.com/python/typing/pull/1669, and Python 3.13 `Final can now be nested in ClassVar and vice versa.`

```python
from dataclasses import dataclass, field
from typing import ClassVar, Final

@dataclass
class MyClass:
    CLASS_CONSTANT: ClassVar[Final] = 42

    instance_constant: Final[int]  # pylint treats as class constant
    instance_constant_with_default: Final[int] = 42  # pylint treats as class constant
    instance_constant_with_field: Final[int] = field(default=42)  # pylint treats as class constant

```

pyright behaves differently from pylint. [pyright](https://pyright-play.net/?strict=true&enableExperimentalFeatures=true&code=GYJw9gtgBAJghgFzgYwDZwM4YKYagSwgAcwQFZEV0sAaKYfbVGAKFEigQE8j8A7AOYFipcgGFqGAGpwQdAGL84qFqoAC8JGkwYW2rFACyXCToBcLKFahiAMgEEAyo4D6YgPIA5RwBV7nnzMbSRkQAG1FPmUAXSgAXigAFgAmS2sGJhgXfAwXPjAEF31c5DA%2BDAQgyOUw-gRYhJSoAGIoIi4QfAEAC3IEEGxEPEwKLUl6RmZVawJypD5kbCKyirg%2BSqhq1Fr12Ja2rlQ6zgGhqBHiqFK5tYQ0q35VhaXrp8KAd3wEbpcYbGA4ABXVAbLY7erxJLJKytdpHdYnQYIYZ4S6veZ3GaPeaLZY3dYuT7fFwZZhVJTbOoNCaZAAUfwBwIQcRSAEoYQd4X1TsjzqjxujbqoWEA)

### Configuration

```ini

```

### Command used

```shell
pylint a.py
```

### Pylint output

```python
************* Module a
a.py:9:4: C0103: Class constant name "instance_constant" doesn't conform to UPPER_CASE naming style (invalid-name)
a.py:10:4: C0103: Class constant name "instance_constant_with_default" doesn't conform to UPPER_CASE naming style (invalid-name)
a.py:11:4: C0103: Class constant name "instance_constant_with_field" doesn't conform to UPPER_CASE naming style (invalid-name)
```

### Expected behavior

For dataclass, only `ClassVar[Final] ` variable should be treat as Class constant.

### Pylint version

```shell
pylint 4.0.4
astroid 4.0.2
Python 3.13.7 (main, Sep 18 2025, 19:43:45) [MSC v.1944 64 bit (AMD64)]
```

### OS / Environment

_No response_

### Additional dependencies

```python

```

diff --git a/pylint/checkers/base/name_checker/checker.py b/pylint/checkers/base/name_checker/checker.py
index 9b3ed0ed1f..41ccc674a1 100644
--- a/pylint/checkers/base/name_checker/checker.py
+++ b/pylint/checkers/base/name_checker/checker.py
@@ -552,9 +552,14 @@ def visit_assignname(  # pylint: disable=too-many-branches,too-many-statements
         elif isinstance(frame, nodes.ClassDef) and not any(
             frame.local_attr_ancestors(node.name)
         ):
-            if utils.is_enum_member(node) or utils.is_assign_name_annotated_with(
-                node, "Final"
-            ):
+            if utils.is_assign_name_annotated_with_class_var_typing_name(node, "Final"):
+                self._check_name("class_const", node.name, node)
+            elif utils.is_assign_name_annotated_with(node, "Final"):
+                if frame.is_dataclass:
+                    self._check_name("class_attribute", node.name, node)
+                else:
+                    self._check_name("class_const", node.name, node)
+            elif utils.is_enum_member(node):
                 self._check_name("class_const", node.name, node)
             else:
                 self._check_name("class_attribute", node.name, node)
diff --git a/pylint/checkers/utils.py b/pylint/checkers/utils.py
index 5c44a8256e..4a17d53525 100644
--- a/pylint/checkers/utils.py
+++ b/pylint/checkers/utils.py
@@ -1762,6 +1762,22 @@ def is_assign_name_annotated_with(node: nodes.AssignName, typing_name: str) -> b
     return False

+def is_assign_name_annotated_with_class_var_typing_name(
+    node: nodes.AssignName, typing_name: str
+) -> bool:
+    if not is_assign_name_annotated_with(node, "ClassVar"):
+        return False
+    annotation = node.parent.annotation
+    if isinstance(annotation, nodes.Subscript):
+        annotation = annotation.slice
+        if isinstance(annotation, nodes.Subscript):
+            annotation = annotation.value
+    match annotation:
+        case nodes.Name(name=n) | nodes.Attribute(attrname=n) if n == typing_name:
+            return True
+    return False
+
+
 def get_iterating_dictionary_name(node: nodes.For | nodes.Comprehension) -> str | None:
     """Get the name of the dictionary which keys are being iterated over on
     a ``nodes.For`` or ``nodes.Comprehension`` node.


# Fix setting options for import order checker

## Type of Changes

|     | Type                   |
| --- | ---------------------- |
| ✓   | :bug: Bug fix          |

## Description

Fix regression in 6ce60320a:
In `__init__` the options are not parsed yet and hence will always be the defaults.

Closes #10801

I was looking for a way to avoid this in general. However that requires a bit more refactoring to either:
- Don't set `PyLinter.options` on `__init__.py` to detect access to it before being set (from config)
- (Preferred): Don't init plugins until options are parsed. Registering would register the classes instead of the instances

diff --git a/tests/functional/w/wrong_import_order2.py b/tests/functional/w/wrong_import_order2.py
index 6f68906738..be24704a49 100644
--- a/tests/functional/w/wrong_import_order2.py
+++ b/tests/functional/w/wrong_import_order2.py
@@ -6,12 +6,16 @@
 import os
 from sys import argv

-# external imports
+# external/third-party imports
 import isort
 import datetime  # std import that should be treated as third-party

+# First-party imports
 from six import moves

+import pylint
+import re  # std import that should be treated as first-party
+
 # local_imports
 from . import my_package
 from .my_package import myClass

diff --git a/pylint/checkers/imports.py b/pylint/checkers/imports.py
index b045f74c21..039fd7cc9f 100644
--- a/pylint/checkers/imports.py
+++ b/pylint/checkers/imports.py
@@ -318,6 +318,7 @@ def _make_graph(

 DEFAULT_STANDARD_LIBRARY = ()
+DEFAULT_KNOWN_FIRST_PARTY = ()
 DEFAULT_KNOWN_THIRD_PARTY = ("enchant",)
 DEFAULT_PREFERRED_MODULES = ()

@@ -401,6 +402,16 @@ class ImportsChecker(DeprecatedMixin, BaseChecker):
                 "the standard compatibility libraries.",
             },
         ),
+        (
+            "known-first-party",
+            {
+                "default": DEFAULT_KNOWN_FIRST_PARTY,
+                "type": "csv",
+                "metavar": "<modules>",
+                "help": "Force import order to recognize a module as part of "
+                "a first party library.",
+            },
+        ),
         (
             "known-third-party",
             {
@@ -758,6 +769,7 @@ def _isort_config(self) -> isort.Config:
             # KNOWN_standard_library for ages in pylint, and we
             # don't want to break compatibility.
             extra_standard_library=self.linter.config.known_standard_library,
+            known_first_party=self.linter.config.known_first_party,
             known_third_party=self.linter.config.known_third_party,
         )


# Options for wrong-import order ignored

### Bug description

```python
import six
import re  # Cause wrong-import-order
```

### Configuration

```ini

```

### Command used

```shell
pylint --disable=all --enable=C0411 --known-third-party=re /tmp/test.py
```

### Pylint output

```python
/tmp/test.py:2:0: C0411: standard import "re" should be placed before third party import "six" (wrong-import-order)
```

### Expected behavior

No error

### Pylint version

```shell
pylint 4.1.0-dev0
astroid 4.0.2
Python 3.10.13
```

### OS / Environment

_No response_

### Additional dependencies

```python

```

diff --git a/tests/functional/w/wrong_import_order2.py b/tests/functional/w/wrong_import_order2.py
index 7157512ddc..6f68906738 100644
--- a/tests/functional/w/wrong_import_order2.py
+++ b/tests/functional/w/wrong_import_order2.py
@@ -8,6 +8,7 @@

 # external imports
 import isort
+import datetime  # std import that should be treated as third-party

 from six import moves

diff --git a/pylint/checkers/imports.py b/pylint/checkers/imports.py
index b069a7a0c6..b045f74c21 100644
--- a/pylint/checkers/imports.py
+++ b/pylint/checkers/imports.py
@@ -322,7 +322,6 @@ def _make_graph(
 DEFAULT_PREFERRED_MODULES = ()

-# pylint: disable-next = too-many-instance-attributes
 class ImportsChecker(DeprecatedMixin, BaseChecker):
     """BaseChecker for import statements.

@@ -459,15 +458,6 @@ def __init__(self, linter: PyLinter) -> None:
         )
         self._excluded_edges: defaultdict[str, set[str]] = defaultdict(set)

-        self._isort_config = isort.Config(
-            # There is no typo here. EXTRA_standard_library is
-            # what most users want. The option has been named
-            # KNOWN_standard_library for ages in pylint, and we
-            # don't want to break compatibility.
-            extra_standard_library=linter.config.known_standard_library,
-            known_third_party=linter.config.known_third_party,
-        )
-
     def open(self) -> None:
         """Called before visiting project (i.e set of modules)."""
         self.linter.stats.dependencies = {}
@@ -756,6 +746,21 @@ def _is_fallback_import(
         imports = [import_node for (import_node, _) in imports]
         return any(astroid.are_exclusive(import_node, node) for import_node in imports)

+    @property
+    def _isort_config(self) -> isort.Config:
+        """Get the config for use with isort.
+
+        Only valid after CLI parsing finished, i.e. not in __init__
+        """
+        return isort.Config(
+            # There is no typo here. EXTRA_standard_library is
+            # what most users want. The option has been named
+            # KNOWN_standard_library for ages in pylint, and we
+            # don't want to break compatibility.
+            extra_standard_library=self.linter.config.known_standard_library,
+            known_third_party=self.linter.config.known_third_party,
+        )
+
     def _check_imports_order(self, _module_node: nodes.Module) -> tuple[
         list[tuple[ImportNode, str]],
         list[tuple[ImportNode, str]],
@@ -763,7 +768,7 @@ def _check_imports_order(self, _module_node: nodes.Module) -> tuple[
     ]:
         """Checks imports of module `node` are grouped by category.

-        Imports must follow this order: standard, 3rd party, local
+        Imports must follow this order: standard, 3rd party, 1st party, local
         """
         std_imports: list[tuple[ImportNode, str]] = []
         third_party_imports: list[tuple[ImportNode, str]] = []
diff --git a/pylint/checkers/utils.py b/pylint/checkers/utils.py
index 7a45b472bd..5c44a8256e 100644
--- a/pylint/checkers/utils.py
+++ b/pylint/checkers/utils.py
@@ -6,7 +6,7 @@

 from __future__ import annotations

-import _string  # pylint: disable=wrong-import-order # Ruff and Isort disagree about the order here
+import _string
 import builtins
 import fnmatch
 import itertools


diff --git a/pylint/checkers/imports.py b/pylint/checkers/imports.py
index b069a7a0c6..b045f74c21 100644
--- a/pylint/checkers/imports.py
+++ b/pylint/checkers/imports.py
@@ -322,7 +322,6 @@ def _make_graph(
 DEFAULT_PREFERRED_MODULES = ()

-# pylint: disable-next = too-many-instance-attributes
 class ImportsChecker(DeprecatedMixin, BaseChecker):
     """BaseChecker for import statements.

@@ -459,15 +458,6 @@ def __init__(self, linter: PyLinter) -> None:
         )
         self._excluded_edges: defaultdict[str, set[str]] = defaultdict(set)

-        self._isort_config = isort.Config(
-            # There is no typo here. EXTRA_standard_library is
-            # what most users want. The option has been named
-            # KNOWN_standard_library for ages in pylint, and we
-            # don't want to break compatibility.
-            extra_standard_library=linter.config.known_standard_library,
-            known_third_party=linter.config.known_third_party,
-        )
-
     def open(self) -> None:
         """Called before visiting project (i.e set of modules)."""
         self.linter.stats.dependencies = {}
@@ -756,6 +746,21 @@ def _is_fallback_import(
         imports = [import_node for (import_node, _) in imports]
         return any(astroid.are_exclusive(import_node, node) for import_node in imports)

+    @property
+    def _isort_config(self) -> isort.Config:
+        """Get the config for use with isort.
+
+        Only valid after CLI parsing finished, i.e. not in __init__
+        """
+        return isort.Config(
+            # There is no typo here. EXTRA_standard_library is
+            # what most users want. The option has been named
+            # KNOWN_standard_library for ages in pylint, and we
+            # don't want to break compatibility.
+            extra_standard_library=self.linter.config.known_standard_library,
+            known_third_party=self.linter.config.known_third_party,
+        )
+
     def _check_imports_order(self, _module_node: nodes.Module) -> tuple[
         list[tuple[ImportNode, str]],
         list[tuple[ImportNode, str]],
@@ -763,7 +768,7 @@ def _check_imports_order(self, _module_node: nodes.Module) -> tuple[
     ]:
         """Checks imports of module `node` are grouped by category.

-        Imports must follow this order: standard, 3rd party, local
+        Imports must follow this order: standard, 3rd party, 1st party, local
         """
         std_imports: list[tuple[ImportNode, str]] = []
         third_party_imports: list[tuple[ImportNode, str]] = []
diff --git a/pylint/checkers/utils.py b/pylint/checkers/utils.py
index 9e8a3906e9..7536658209 100644
--- a/pylint/checkers/utils.py
+++ b/pylint/checkers/utils.py
@@ -6,7 +6,7 @@

 from __future__ import annotations

-import _string  # pylint: disable=wrong-import-order # Ruff and Isort disagree about the order here
+import _string
 import builtins
 import fnmatch
 import itertools


# False positive unreachable error when overload + NoReturn is used

### Bug description

Below code should be valid but pylint finds an issue. It seems to be caused by the NoReturn

```python
# x.py
from y import create_client as mart_create_client
client = mart_create_client(version=2)
x = 1

# y.py
from typing import Literal, NoReturn, overload

@overload
def create_client(version: int = ...) -> NoReturn: ...

@overload
def create_client(version: Literal[2] = ...) -> int: ...

def create_client(version: int = 2) -> int:
    return 1
```

### Command used

```shell
pylint x.py
```

### Pylint output

```python
x.py(6): [W0101(unreachable)] Unreachable code
```

### Expected behavior

no error

### Pylint version

```shell
pylint 4.0.4
astroid 4.0.2
Python 3.14.0 (main, Nov 10 2025, 09:33:32) [GCC 7.5.0]
```

### OS / Environment

SUSE Linux

diff --git a/tests/checkers/unittest_utils.py b/tests/checkers/unittest_utils.py
index d0279d45a4..6d9bb1a5e9 100644
--- a/tests/checkers/unittest_utils.py
+++ b/tests/checkers/unittest_utils.py
@@ -641,3 +641,40 @@ def test_foo(self):
     )
     result = utils.is_terminating_func(node)
     assert result is True
+
+
+def test_is_terminating_func_ignored_overload_noreturn() -> None:
+    node = astroid.extract_node(
+        """
+    from typing import Literal, NoReturn, overload
+    @overload
+    def create_client(version: int = ...) -> NoReturn: ...
+    @overload
+    def create_client(version: Literal[2] = ...) -> int: ...
+    def create_client(version: int = 2) -> int:
+        return 1
+    create_client(version=2)  #@
+    """
+    )
+    result = utils.is_terminating_func(node)
+    assert result is False
+
+
+def test_is_terminating_func_overload_with_noreturn_implementation() -> None:
+    node = astroid.extract_node(
+        """
+    from typing import NoReturn, overload
+
+    @overload
+    def always_fails(code: int) -> NoReturn: ...
+    @overload
+    def always_fails(code: str) -> NoReturn: ...
+
+    def always_fails(code: int | str) -> NoReturn:
+        raise SystemExit(code)
+
+    always_fails(1)  #@
+"""
+    )
+    result = utils.is_terminating_func(node)
+    assert result is True

diff --git a/pylint/checkers/utils.py b/pylint/checkers/utils.py
index 9e8a3906e9..7a45b472bd 100644
--- a/pylint/checkers/utils.py
+++ b/pylint/checkers/utils.py
@@ -2200,41 +2200,40 @@ def is_terminating_func(node: nodes.Call) -> bool:
         node.parent, nodes.Lambda
     ):
         return False
-
     try:
-        for inferred in node.func.infer():
-            if (
-                hasattr(inferred, "qname")
-                and inferred.qname() in TERMINATING_FUNCS_QNAMES
-            ):
-                return True
-            match inferred:
-                case astroid.BoundMethod(_proxied=astroid.UnboundMethod(_proxied=p)):
-                    # Unwrap to get the actual function node object
-                    inferred = p
-            if (  # pylint: disable=too-many-boolean-expressions
-                isinstance(inferred, nodes.FunctionDef)
-                and (
-                    not isinstance(inferred, nodes.AsyncFunctionDef)
-                    or isinstance(node.parent, nodes.Await)
-                )
-                and isinstance(inferred.returns, nodes.Name)
-                and (inferred_func := safe_infer(inferred.returns))
-                and hasattr(inferred_func, "qname")
-                and inferred_func.qname()
-                in (
-                    *TYPING_NEVER,
-                    *TYPING_NORETURN,
-                    # In Python 3.7 - 3.8, NoReturn is alias of '_SpecialForm'
-                    # "typing._SpecialForm",
-                    # But 'typing.Any' also inherits _SpecialForm
-                    # See #9751
-                )
-            ):
-                return True
+        inferred_funcs = list(node.func.infer())
     except (StopIteration, astroid.InferenceError):
-        pass
+        return False

+    for inferred in inferred_funcs:
+        if hasattr(inferred, "qname") and inferred.qname() in TERMINATING_FUNCS_QNAMES:
+            return True
+        match inferred:
+            case astroid.BoundMethod(_proxied=astroid.UnboundMethod(_proxied=p)):
+                # Unwrap to get the actual function node object
+                inferred = p
+        if is_overload_stub(inferred):
+            continue
+        if (  # pylint: disable=too-many-boolean-expressions
+            isinstance(inferred, nodes.FunctionDef)
+            and (
+                not isinstance(inferred, nodes.AsyncFunctionDef)
+                or isinstance(node.parent, nodes.Await)
+            )
+            and isinstance(inferred.returns, nodes.Name)
+            and (inferred_func := safe_infer(inferred.returns))
+            and hasattr(inferred_func, "qname")
+            and inferred_func.qname()
+            in (
+                *TYPING_NEVER,
+                *TYPING_NORETURN,
+                # In Python 3.7 - 3.8, NoReturn is alias of '_SpecialForm'
+                # "typing._SpecialForm",
+                # But 'typing.Any' also inherits _SpecialForm
+                # See #9751
+            )
+        ):
+            return True
     return False


# `provide_automatic_options` is weird

While dealing with trying to detect duplicate routes in https://github.com/pallets/werkzeug/issues/3105, I ran into an issue with Flask, which adds `OPTIONS` to every single rule by default.

I started looking into Flask's `provide_automatic_options` feature, and it's just weird.

- Why is there a `PROVIDE_AUTOMATIC_OPTIONS` config? What benefit is there to turning `OPTIONS` off at all, let alone configuring it for a given deployment.
- Flask adds `OPTIONS` to every route, then has a check that runs on dispatching _every_ request to see if the method was `OPTIONS` and `provide_automatic_options` was enabled for the rule. Performing this check for every request seems wasteful, as opposed to adding an actual rule and endpoint so that the routing that's already happening handles it.
    - But adding a rule is also sort of bad, as it results in a bunch of duplicate rules since Flask has no way to know what other rules have already been added. This can be caught with Werkzeug 3.2, and doesn't cause problems in earlier versions, but it's still annoying.
- `provide_automatic_options` can be set as an attribute on the view function. There are some other attributes that are barely documented as well, such as `required_methods`. This is presumably mostly for `View` classes, but works on functions too, making type annotations unhelpful.
- If `PROVIDE_AUTOMATIC_OPTIONS` is disabled (not the default), the logic for the `provide_automatic_options` argument and attribute doesn't work correctly to _enable_ it, it only works for _disabling_.
- The tests for this were incomplete and had some issues. Some other tests were revealed to be adding duplicate rules as well.

At a minimum I've fixed the enabling issue. I've also got a change that registers a route and view for options instead of using special dispatch logic. We should also consider deprecating the config.

diff --git a/tests/test_basic.py b/tests/test_basic.py
index f8a1152911..48365a6b6e 100644
--- a/tests/test_basic.py
+++ b/tests/test_basic.py
@@ -20,6 +20,7 @@
 from werkzeug.routing import RequestRedirect

 import flask
+from flask.testing import FlaskClient

 require_cpython_gc = pytest.mark.skipif(
     python_implementation() != "CPython",
@@ -67,63 +68,61 @@ def test_method_route_no_methods(app):
         app.get("/", methods=["GET", "POST"])

-def test_provide_automatic_options_attr():
-    app = flask.Flask(__name__)
+def test_provide_automatic_options_attr_disable(
+    app: flask.Flask, client: FlaskClient
+) -> None:
+    """Automatic options can be disabled by the view func attribute."""

     def index():
         return "Hello World!"

     index.provide_automatic_options = False
-    app.route("/")(index)
-    rv = app.test_client().open("/", method="OPTIONS")
+    app.add_url_rule("/", view_func=index)
+    rv = client.options()
     assert rv.status_code == 405

-    app = flask.Flask(__name__)

-    def index2():
+def test_provide_automatic_options_attr_enable(
+    app: flask.Flask, client: FlaskClient
+) -> None:
+    """When default automatic options is disabled in config, it can still be
+    enabled by the view function attribute.
+    """
+    app.config["PROVIDE_AUTOMATIC_OPTIONS"] = False
+
+    def index():
         return "Hello World!"

-    index2.provide_automatic_options = True
-    app.route("/", methods=["OPTIONS"])(index2)
-    rv = app.test_client().open("/", method="OPTIONS")
-    assert sorted(rv.allow) == ["OPTIONS"]
+    index.provide_automatic_options = True
+    app.add_url_rule("/", view_func=index)
+    rv = client.options()
+    assert rv.allow == {"GET", "HEAD", "OPTIONS"}

-def test_provide_automatic_options_kwarg(app, client):
-    def index():
-        return flask.request.method
-
-    def more():
-        return flask.request.method
+def test_provide_automatic_options_arg_disable(
+    app: flask.Flask, client: FlaskClient
+) -> None:
+    """Automatic options can be disabled by the route argument."""

-    app.add_url_rule("/", view_func=index, provide_automatic_options=False)
-    app.add_url_rule(
-        "/more",
-        view_func=more,
-        methods=["GET", "POST"],
-        provide_automatic_options=False,
-    )
-    assert client.get("/").data == b"GET"
+    @app.get("/", provide_automatic_options=False)
+    def index():
+        return "Hello World!"

-    rv = client.post("/")
+    rv = client.options()
     assert rv.status_code == 405
-    assert sorted(rv.allow) == ["GET", "HEAD"]

-    rv = client.open("/", method="OPTIONS")
-    assert rv.status_code == 405

-    rv = client.head("/")
-    assert rv.status_code == 200
-    assert not rv.data  # head truncates
-    assert client.post("/more").data == b"POST"
-    assert client.get("/more").data == b"GET"
+def test_provide_automatic_options_method_disable(
+    app: flask.Flask, client: FlaskClient
+) -> None:
+    """Automatic options is ignored if the route handles options."""

-    rv = client.delete("/more")
-    assert rv.status_code == 405
-    assert sorted(rv.allow) == ["GET", "HEAD", "POST"]
+    @app.route("/", methods=["OPTIONS"])
+    def index():
+        return "", {"X-Test": "test"}

-    rv = client.open("/more", method="OPTIONS")
-    assert rv.status_code == 405
+    rv = client.options()
+    assert rv.headers["X-Test"] == "test"

 def test_request_dispatching(app, client):
diff --git a/tests/test_blueprints.py b/tests/test_blueprints.py
index ed1683c470..001e1981c7 100644
--- a/tests/test_blueprints.py
+++ b/tests/test_blueprints.py
@@ -220,28 +220,19 @@ def test_templates_and_static(test_apps):
         assert flask.render_template("nested/nested.txt") == "I'm nested"

-def test_default_static_max_age(app):
+def test_default_static_max_age(app: flask.Flask) -> None:
     class MyBlueprint(flask.Blueprint):
         def get_send_file_max_age(self, filename):
             return 100

-    blueprint = MyBlueprint("blueprint", __name__, static_folder="static")
+    blueprint = MyBlueprint(
+        "blueprint", __name__, url_prefix="/bp", static_folder="static"
+    )
     app.register_blueprint(blueprint)

-    # try/finally, in case other tests use this app for Blueprint tests.
-    max_age_default = app.config["SEND_FILE_MAX_AGE_DEFAULT"]
-    try:
-        with app.test_request_context():
-            unexpected_max_age = 3600
-            if app.config["SEND_FILE_MAX_AGE_DEFAULT"] == unexpected_max_age:
-                unexpected_max_age = 7200
-            app.config["SEND_FILE_MAX_AGE_DEFAULT"] = unexpected_max_age
-            rv = blueprint.send_static_file("index.html")
-            cc = parse_cache_control_header(rv.headers["Cache-Control"])
-            assert cc.max_age == 100
-            rv.close()
-    finally:
-        app.config["SEND_FILE_MAX_AGE_DEFAULT"] = max_age_default
+    with app.test_request_context(), blueprint.send_static_file("index.html") as rv:
+        cc = parse_cache_control_header(rv.headers["Cache-Control"])
+        assert cc.max_age == 100

 def test_templates_list(test_apps):
diff --git a/tests/test_cli.py b/tests/test_cli.py
index a5aab5013d..8f8f25cf01 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -483,12 +483,18 @@ def test_sort(self, app, invoke):
             ["yyy_get_post", "static", "aaa_post"],
             invoke(["routes", "-s", "rule"]).output,
         )
-        match_order = [r.endpoint for r in app.url_map.iter_rules()]
+        match_order = [
+            r.endpoint
+            for r in app.url_map.iter_rules()
+            if r.endpoint != "_automatic_options"
+        ]
         self.expect_order(match_order, invoke(["routes", "-s", "match"]).output)

     def test_all_methods(self, invoke):
         output = invoke(["routes"]).output
-        assert "GET, HEAD, OPTIONS, POST" not in output
+        assert "HEAD" not in output
+        assert "OPTIONS" not in output
+
         output = invoke(["routes", "--all-methods"]).output
         assert "GET, HEAD, OPTIONS, POST" in output

diff --git a/tests/test_views.py b/tests/test_views.py
index eab5eda286..d002b50439 100644
--- a/tests/test_views.py
+++ b/tests/test_views.py
@@ -2,6 +2,7 @@
 from werkzeug.http import parse_set_header

 import flask.views
+from flask.testing import FlaskClient

 def common_test(app):
@@ -98,44 +99,55 @@ def dispatch_request(self):
     assert rv.data == b"Awesome"

-def test_view_provide_automatic_options_attr():
-    app = flask.Flask(__name__)
+def test_view_provide_automatic_options_attr_disable(
+    app: flask.Flask, client: FlaskClient
+) -> None:
+    """Automatic options can be disabled by the view class attribute."""

-    class Index1(flask.views.View):
+    class Index(flask.views.View):
         provide_automatic_options = False

         def dispatch_request(self):
             return "Hello World!"

-    app.add_url_rule("/", view_func=Index1.as_view("index"))
-    c = app.test_client()
-    rv = c.open("/", method="OPTIONS")
+    app.add_url_rule("/", view_func=Index.as_view("index"))
+    rv = client.options()
     assert rv.status_code == 405

-    app = flask.Flask(__name__)

-    class Index2(flask.views.View):
-        methods = ["OPTIONS"]
+def test_view_provide_automatic_options_attr_enable(
+    app: flask.Flask, client: FlaskClient
+) -> None:
+    """When default automatic options is disabled in config, it can still be
+    enabled by the view class attribute.
+    """
+    app.config["PROVIDE_AUTOMATIC_OPTIONS"] = False
+
+    class Index(flask.views.View):
         provide_automatic_options = True

         def dispatch_request(self):
             return "Hello World!"

-    app.add_url_rule("/", view_func=Index2.as_view("index"))
-    c = app.test_client()
-    rv = c.open("/", method="OPTIONS")
-    assert sorted(rv.allow) == ["OPTIONS"]
+    app.add_url_rule("/", view_func=Index.as_view("index"))
+    rv = client.options("/")
+    assert rv.allow == {"GET", "HEAD", "OPTIONS"}

-    app = flask.Flask(__name__)

-    class Index3(flask.views.View):
+def test_provide_automatic_options_method_disable(
+    app: flask.Flask, client: FlaskClient
+) -> None:
+    """Automatic options is ignored if the route handles options."""
+
+    class Index(flask.views.View):
+        methods = ["OPTIONS"]
+
         def dispatch_request(self):
-            return "Hello World!"
+            return "", {"X-Test": "test"}

-    app.add_url_rule("/", view_func=Index3.as_view("index"))
-    c = app.test_client()
-    rv = c.open("/", method="OPTIONS")
-    assert "OPTIONS" in rv.allow
+    app.add_url_rule("/", view_func=Index.as_view("index"))
+    rv = client.options()
+    assert rv.headers["X-Test"] == "test"

 def test_implicit_head(app, client):
@@ -180,7 +192,7 @@ def dispatch_request(self):
     app.add_url_rule("/", view_func=Index.as_view("index"))

     with pytest.raises(AssertionError):
-        app.add_url_rule("/", view_func=Index.as_view("index"))
+        app.add_url_rule("/other", view_func=Index.as_view("index"))

     # But these tests should still pass. We just log a warning.
     common_test(app)

diff --git a/src/flask/sansio/app.py b/src/flask/sansio/app.py
index e069b9087a..a734793e40 100644
--- a/src/flask/sansio/app.py
+++ b/src/flask/sansio/app.py
@@ -627,19 +627,19 @@ def add_url_rule(
         # Methods that should always be added
         required_methods: set[str] = set(getattr(view_func, "required_methods", ()))

-        # starting with Flask 0.8 the view_func object can disable and
-        # force-enable the automatic options handling.
         if provide_automatic_options is None:
             provide_automatic_options = getattr(
                 view_func, "provide_automatic_options", None
             )

-        if provide_automatic_options is None:
-            if "OPTIONS" not in methods and self.config["PROVIDE_AUTOMATIC_OPTIONS"]:
-                provide_automatic_options = True
-                required_methods.add("OPTIONS")
-            else:
-                provide_automatic_options = False
+            if provide_automatic_options is None:
+                provide_automatic_options = (
+                    "OPTIONS" not in methods
+                    and self.config["PROVIDE_AUTOMATIC_OPTIONS"]
+                )
+
+        if provide_automatic_options:
+            required_methods.add("OPTIONS")

         # Add the required methods now.
         methods |= required_methods