Skip to content

fix Pyrefly doesn't reveal type as Never in unreachable code #3202#3205

Open
asukaminato0721 wants to merge 2 commits intofacebook:mainfrom
asukaminato0721:3202
Open

fix Pyrefly doesn't reveal type as Never in unreachable code #3202#3205
asukaminato0721 wants to merge 2 commits intofacebook:mainfrom
asukaminato0721:3202

Conversation

@asukaminato0721
Copy link
Copy Markdown
Contributor

@asukaminato0721 asukaminato0721 commented Apr 22, 2026

Summary

Fixes #3202

makes local name assignments inside a type-impossible branch resolve to Never instead of preserving the RHS literal type.

Test Plan

add test

@github-actions

This comment has been minimized.

@github-actions

This comment has been minimized.

@github-actions
Copy link
Copy Markdown

Diff from mypy_primer, showing the effect of this PR on open source code:

pip (https://github.com/pypa/pip)
+ ERROR src/pip/_vendor/tomli_w/_writer.py:111:24-35: Expected class object, got `Never` [invalid-argument]
+ ERROR src/pip/_vendor/tomli_w/_writer.py:112:36-39: Argument `object` is not assignable to parameter `obj` with type `list[Unknown] | tuple[Unknown, ...]` in function `format_inline_array` [bad-argument-type]
+ ERROR src/pip/_vendor/tomli_w/_writer.py:219:25-36: Expected class object, got `Never` [invalid-argument]

openlibrary (https://github.com/internetarchive/openlibrary)
- ERROR openlibrary/plugins/upstream/models.py:561:25-38: Expected `__bool__` to be a callable, got `Unknown | None` [not-callable]
- ERROR openlibrary/plugins/upstream/models.py:563:28-35: Returned type `list[Edition | Image]` is not assignable to declared return type `list[Image]` [bad-return]

cloud-init (https://github.com/canonical/cloud-init)
+ ERROR tests/unittests/sources/test_oracle.py:1266:48-68: `str` is not assignable to attribute `imds_url_used` with type `Never` [bad-assignment]

aiohttp (https://github.com/aio-libs/aiohttp)
- ERROR aiohttp/multipart.py:325:20-32: Returned type `bytearray` is not assignable to declared return type `bytes` [bad-return]

mitmproxy (https://github.com/mitmproxy/mitmproxy)
+ ERROR test/mitmproxy/addons/test_view.py:561:30-35: `Literal['GET']` is not assignable to attribute `method` with type `Never` [bad-assignment]

urllib3 (https://github.com/urllib3/urllib3)
- ERROR test/test_response.py:955:21-22: `Literal[2]` is not assignable to attribute `_fp` with type `HTTPResponse | None` [bad-assignment]
+ ERROR test/test_response.py:955:21-22: `Literal[2]` is not assignable to attribute `_fp` with type `Never` [bad-assignment]

pwndbg (https://github.com/pwndbg/pwndbg)
- ERROR pwndbg/aglib/dynamic.py:768:37-41: Cannot set item in `dict[str, type[Any]]` [unsupported-operation]
- ERROR pwndbg/commands/got_tracking.py:219:17-38: Expected a callable, got `None` [not-callable]
- ERROR pwndbg/commands/got_tracking.py:220:20-39: Object of class `NoneType` has no attribute `symtab_read` [missing-attribute]
- ERROR pwndbg/commands/got_tracking.py:221:47-61: Object of class `NoneType` has no attribute `string` [missing-attribute]
- ERROR pwndbg/commands/got_tracking.py:226:15-42: Object of class `NoneType` has no attribute `name` [missing-attribute]
- ERROR pwndbg/gdblib/got.py:412:20-49: Object of class `NoneType` has no attribute `has_field` [missing-attribute]

apprise (https://github.com/caronc/apprise)
- ERROR tests/test_config_http.py:315:26-41: `int` is not assignable to attribute `max_buffer_size` with type `Never` [bad-assignment]
- ERROR tests/test_config_http.py:348:26-41: `int` is not assignable to attribute `max_buffer_size` with type `Never` [bad-assignment]
- ERROR tests/test_plugin_exotel.py:428:28-42: Object of class `NoneType` has no attribute `url_id` [missing-attribute]
+ ERROR tests/test_plugin_matrix.py:761:24-37: `Literal['already.set']` is not assignable to attribute `home_server` with type `Never` [bad-assignment]
+ ERROR tests/test_plugin_matrix.py:766:24-37: `Literal['already.set']` is not assignable to attribute `home_server` with type `Never` [bad-assignment]
+ ERROR tests/test_plugin_pushbullet.py:361:28-368:22: `bytes` is not assignable to attribute `content` with type `Never` [bad-assignment]
+ ERROR tests/test_plugin_pushbullet.py:370:28-30: `dict[@_, @_]` is not assignable to attribute `headers` with type `Never` [bad-assignment]
+ ERROR tests/test_plugin_pushbullet.py:374:33-37: `Literal[b'}']` is not assignable to attribute `content` with type `Never` [bad-assignment]
+ ERROR tests/test_plugin_pushbullet.py:376:33-35: `dict[@_, @_]` is not assignable to attribute `headers` with type `Never` [bad-assignment]
- ERROR tests/test_utils_pem.py:121:26-33: Argument `str | None` is not assignable to parameter `encrypted_payload` with type `bytes | str` in function `apprise.utils.pem.ApprisePEMController.decrypt` [bad-argument-type]
- ERROR tests/test_utils_pem.py:220:15-30: Argument `bool | str | None` is not assignable to parameter `path` with type `PathLike[bytes] | PathLike[str] | bytes | str` in function `os.unlink` [bad-argument-type]
- ERROR tests/test_utils_pem.py:227:15-30: Argument `bool | str | None` is not assignable to parameter `path` with type `PathLike[bytes] | PathLike[str] | bytes | str` in function `os.unlink` [bad-argument-type]

mongo-python-driver (https://github.com/mongodb/mongo-python-driver)
- ERROR pymongo/asynchronous/mongo_client.py:1249:17-24: Argument `dict[tuple[Never, Never], ServerDescription]` is not assignable to parameter `server_descriptions` with type `dict[tuple[str, int | None], ServerDescription]` in function `pymongo.topology_description.TopologyDescription.__init__` [bad-argument-type]
- ERROR pymongo/synchronous/mongo_client.py:1249:17-24: Argument `dict[tuple[Never, Never], ServerDescription]` is not assignable to parameter `server_descriptions` with type `dict[tuple[str, int | None], ServerDescription]` in function `pymongo.topology_description.TopologyDescription.__init__` [bad-argument-type]

pandera (https://github.com/pandera-dev/pandera)
+ ERROR pandera/api/dataframe/model.py:299:46-55: Expected a type form, got instance of `Never` [not-a-type]
+ ERROR pandera/api/pyspark/model.py:204:46-55: Expected a type form, got instance of `Never` [not-a-type]
- ERROR tests/pandas/test_schema_components.py:589:36-58: Object of class `Iterable` has no attribute `tolist` [missing-attribute]

async-utils (https://github.com/mikeshardmind/async-utils)
- ERROR src/async_utils/_graphs.py:76:29-44: Expected a type form, got instance of `TypeVar` [not-a-type]
+ ERROR src/async_utils/_graphs.py:76:29-44: Expected a type form, got instance of `Never` [not-a-type]
- ERROR src/async_utils/_graphs.py:83:30-45: Expected a type form, got instance of `TypeVar` [not-a-type]
+ ERROR src/async_utils/_graphs.py:83:30-45: Expected a type form, got instance of `Never` [not-a-type]
- ERROR src/async_utils/_graphs.py:84:20-35: Expected a type form, got instance of `TypeVar` [not-a-type]
+ ERROR src/async_utils/_graphs.py:84:20-35: Expected a type form, got instance of `Never` [not-a-type]
- ERROR src/async_utils/_graphs.py:86:31-46: Expected a type form, got instance of `TypeVar` [not-a-type]
+ ERROR src/async_utils/_graphs.py:86:31-46: Expected a type form, got instance of `Never` [not-a-type]
- ERROR src/async_utils/_graphs.py:120:38-53: Expected a type form, got instance of `TypeVar` [not-a-type]
+ ERROR src/async_utils/_graphs.py:120:38-53: Expected a type form, got instance of `Never` [not-a-type]
- ERROR src/async_utils/_graphs.py:120:55-70: Expected a type form, got instance of `TypeVar` [not-a-type]
+ ERROR src/async_utils/_graphs.py:120:55-70: Expected a type form, got instance of `Never` [not-a-type]
- ERROR src/async_utils/_graphs.py:121:29-44: Expected a type form, got instance of `TypeVar` [not-a-type]
+ ERROR src/async_utils/_graphs.py:121:29-44: Expected a type form, got instance of `Never` [not-a-type]
- ERROR src/async_utils/_graphs.py:121:55-70: Expected a type form, got instance of `TypeVar` [not-a-type]
+ ERROR src/async_utils/_graphs.py:121:55-70: Expected a type form, got instance of `Never` [not-a-type]
- ERROR src/async_utils/_graphs.py:127:38-53: Expected a type form, got instance of `TypeVar` [not-a-type]
+ ERROR src/async_utils/_graphs.py:127:38-53: Expected a type form, got instance of `Never` [not-a-type]
- ERROR src/async_utils/_graphs.py:127:70-85: Expected a type form, got instance of `TypeVar` [not-a-type]
+ ERROR src/async_utils/_graphs.py:127:70-85: Expected a type form, got instance of `Never` [not-a-type]
- ERROR src/async_utils/_graphs.py:138:36-51: Expected a type form, got instance of `TypeVar` [not-a-type]
+ ERROR src/async_utils/_graphs.py:138:36-51: Expected a type form, got instance of `Never` [not-a-type]
- ERROR src/async_utils/_graphs.py:138:66-81: Expected a type form, got instance of `TypeVar` [not-a-type]
+ ERROR src/async_utils/_graphs.py:138:66-81: Expected a type form, got instance of `Never` [not-a-type]
- ERROR src/async_utils/_graphs.py:149:35-50: Expected a type form, got instance of `TypeVar` [not-a-type]
+ ERROR src/async_utils/_graphs.py:149:35-50: Expected a type form, got instance of `Never` [not-a-type]
- ERROR src/async_utils/_graphs.py:152:33-48: Expected a type form, got instance of `TypeVar` [not-a-type]
+ ERROR src/async_utils/_graphs.py:152:33-48: Expected a type form, got instance of `Never` [not-a-type]
- ERROR src/async_utils/_graphs.py:153:19-34: Expected a type form, got instance of `TypeVar` [not-a-type]
+ ERROR src/async_utils/_graphs.py:153:19-34: Expected a type form, got instance of `Never` [not-a-type]
- ERROR src/async_utils/_graphs.py:155:21-36: Expected a type form, got instance of `TypeVar` [not-a-type]
+ ERROR src/async_utils/_graphs.py:155:21-36: Expected a type form, got instance of `Never` [not-a-type]
- ERROR src/async_utils/_graphs.py:156:26-41: Expected a type form, got instance of `TypeVar` [not-a-type]
+ ERROR src/async_utils/_graphs.py:156:26-41: Expected a type form, got instance of `Never` [not-a-type]
- ERROR src/async_utils/_graphs.py:160:15-37: Expected a type form, got instance of `_SpecialForm` [not-a-type]
+ ERROR src/async_utils/_graphs.py:160:15-37: Expected a type form, got instance of `Never` [not-a-type]
- ERROR src/async_utils/_graphs.py:186:39-54: Expected a type form, got instance of `TypeVar` [not-a-type]
+ ERROR src/async_utils/_graphs.py:186:39-54: Expected a type form, got instance of `Never` [not-a-type]
- ERROR src/async_utils/_graphs.py:201:37-52: Expected a type form, got instance of `TypeVar` [not-a-type]
+ ERROR src/async_utils/_graphs.py:201:37-52: Expected a type form, got instance of `Never` [not-a-type]
- ERROR src/async_utils/task_cache.py:86:40-41: Expected a type form, got instance of `ParamSpec` [not-a-type]
+ ERROR src/async_utils/task_cache.py:86:40-41: Expected a type form, got instance of `Never` [not-a-type]
- ERROR src/async_utils/task_cache.py:86:43-44: Expected a type form, got instance of `TypeVar` [not-a-type]
+ ERROR src/async_utils/task_cache.py:86:43-44: Expected a type form, got instance of `Never` [not-a-type]
- ERROR src/async_utils/task_cache.py:86:59-60: Expected a type form, got instance of `ParamSpec` [not-a-type]
+ ERROR src/async_utils/task_cache.py:86:59-60: Expected a type form, got instance of `Never` [not-a-type]
- ERROR src/async_utils/task_cache.py:86:62-63: Expected a type form, got instance of `TypeVar` [not-a-type]
+ ERROR src/async_utils/task_cache.py:86:62-63: Expected a type form, got instance of `Never` [not-a-type]

build (https://github.com/pypa/build)
+ ERROR tests/test_env.py:323:16-35: Expected a type form, got instance of `Never` [not-a-type]
+ ERROR tests/test_env.py:346:16-35: Expected a type form, got instance of `Never` [not-a-type]
+ ERROR src/build/__main__.py:164:16-30: Expected a type form, got instance of `Never` [not-a-type]
+ ERROR src/build/__main__.py:210:16-30: Expected a type form, got instance of `Never` [not-a-type]
+ ERROR src/build/__main__.py:274:16-30: Expected a type form, got instance of `Never` [not-a-type]
+ ERROR src/build/__main__.py:310:16-30: Expected a type form, got instance of `Never` [not-a-type]
+ ERROR src/build/__main__.py:367:16-30: Expected a type form, got instance of `Never` [not-a-type]
+ ERROR src/build/env.py:107:20-29: Expected a type form, got instance of `Never` [not-a-type]
+ ERROR src/build/env.py:109:25-34: Expected a type form, got instance of `Never` [not-a-type]

vision (https://github.com/pytorch/vision)
- ERROR torchvision/prototype/datasets/utils/_encoded.py:54:33-59: Argument `ReadOnlyTensorBuffer` is not assignable to parameter `fp` with type `IO[bytes] | PathLike[bytes] | PathLike[str] | bytes | str` in function `PIL.Image.open` [bad-argument-type]
- ERROR torchvision/transforms/_functional_pil.py:253:23-40: Argument `tuple[int, ...]` is not assignable to parameter `size` with type `list[int] | ndarray | tuple[int, int]` in function `PIL.Image.Image.resize` [bad-argument-type]

zulip (https://github.com/zulip/zulip)
+ ERROR corporate/tests/test_stripe.py:5762:39-50: `Literal['cus_12345']` is not assignable to attribute `stripe_customer_id` with type `Never` [bad-assignment]
+ ERROR zerver/tests/test_auth_backends.py:9335:31-64: `dict[str, str]` is not assignable to attribute `headers` with type `Never` [bad-assignment]
+ ERROR zerver/tests/test_auth_backends.py:9354:31-33: `dict[@_, @_]` is not assignable to attribute `headers` with type `Never` [bad-assignment]
- ERROR zerver/tests/test_upload_s3.py:714:55-64: `hex_value` may be uninitialized [unbound-name]

static-frame (https://github.com/static-frame/static-frame)
+ ERROR static_frame/core/archive_npy.py:239:37-42: `Literal[False]` is not assignable to attribute `writeable` with type `Never` [bad-assignment]

pandas (https://github.com/pandas-dev/pandas)
- ERROR pandas/core/frame.py:15674:24-35: Object of class `ndarray` has no attribute `_reduce` [missing-attribute]
+ ERROR pandas/core/generic.py:7180:49-53: Argument `int` is not assignable to parameter `iterable` with type `Iterable[@_]` in function `enumerate.__new__` [bad-argument-type]
+ ERROR pandas/core/resample.py:246:36-40: `Literal[True]` is not assignable to attribute `_is_resample` with type `Never` [bad-assignment]
- ERROR pandas/tests/arithmetic/test_period.py:1283:15-23: Object of class `NaTType` has no attribute `freq` [missing-attribute]
- ERROR pandas/tests/extension/date/array.py:115:23-32: Type `object_` is not iterable [not-iterable]
- ERROR pandas/tests/resample/test_datetime_index.py:406:15-30: Object of class `Index` has no attribute `as_unit` [missing-attribute]

pytest-robotframework (https://github.com/detachhead/pytest-robotframework)
- ERROR pytest_robotframework/__init__.py:136:35-37: `TracebackType | None` is not assignable to attribute `__traceback__` with type `Never` [bad-assignment]

meson (https://github.com/mesonbuild/meson)
+ ERROR mesonbuild/ast/introspection.py:372:41-44: Cannot set item in `dict[str, Never]` [unsupported-operation]
+ ERROR mesonbuild/ast/introspection.py:373:16-32: Returned type `dict[str, Never]` is not assignable to declared return type `dict[str, TYPE_var]` [bad-return]
- ERROR mesonbuild/modules/hotdoc.py:249:20-23: Returned type `list[CustomTarget | CustomTargetIndex | File]` is not assignable to declared return type `CustomTarget | CustomTargetIndex | File` [bad-return]
+ ERROR mesonbuild/optinterpreter.py:95:22-33: `str` is not assignable to attribute `file` with type `Never` [bad-assignment]

werkzeug (https://github.com/pallets/werkzeug)
- ERROR tests/test_wrappers.py:744:20-26: `timedelta` is not assignable to attribute `age` with type `Never` [bad-assignment]
- ERROR tests/test_wrappers.py:750:28-31: `datetime` is not assignable to attribute `retry_after` with type `Never` [bad-assignment]

pyodide (https://github.com/pyodide/pyodide)
- ERROR pyodide-build/pyodide_build/recipe/skeleton.py:418:47-54: Argument `list[Literal['sdist', 'wheel'] | str] | list[str]` is not assignable to parameter `source_types` with type `list[Literal['sdist', 'wheel']]` in function `_find_dist` [bad-argument-type]
+ ERROR pyodide-build/pyodide_build/recipe/skeleton.py:418:47-54: Argument `list[Literal['sdist', 'wheel'] | str]` is not assignable to parameter `source_types` with type `list[Literal['sdist', 'wheel']]` in function `_find_dist` [bad-argument-type]

core (https://github.com/home-assistant/core)
- ERROR homeassistant/components/ssdp/scanner.py:538:23-37: Cannot set item in `dict[Never, Never]` [unsupported-operation]
- ERROR homeassistant/components/ssdp/scanner.py:538:41-44: Cannot set item in `dict[Never, Never]` [unsupported-operation]
- ERROR homeassistant/components/ssdp/scanner.py:549:14-23: Argument `dict[Unknown, Unknown] | dict[Never, Never]` is not assignable to parameter `upnp` with type `Mapping[str, Any]` in function `homeassistant.helpers.service_info.ssdp.SsdpServiceInfo.__init__` [bad-argument-type]

sphinx (https://github.com/sphinx-doc/sphinx)
- ERROR sphinx/directives/patches.py:54:32-44: `int` is not assignable to attribute `line` with type `Never` [bad-assignment]
- ERROR sphinx/domains/cpp/_parser.py:1145:40-46: Argument `Literal['class', 'enum', 'struct', 'typename', 'union'] | None` is not assignable to parameter `prefix` with type `str` in function `sphinx.domains.cpp._ast.ASTTrailingTypeSpecName.__init__` [bad-argument-type]
+ ERROR sphinx/transforms/__init__.py:103:45-53: `BuildEnvironment` is not assignable to attribute `env` with type `Never` [bad-assignment]

xarray (https://github.com/pydata/xarray)
- ERROR xarray/core/indexing.py:180:28-32: Cannot set item in `dict[int, Index | PandasIndex]` [unsupported-operation]
- ERROR xarray/core/indexing.py:180:36-40: Cannot set item in `dict[int, Index | PandasIndex]` [unsupported-operation]

pydantic (https://github.com/pydantic/pydantic)
- ERROR pydantic/v1/generics.py:124:25-132:14: No matching overload found for function `pydantic.v1.main.create_model` called with arguments: (str, __module__=str, __base__=tuple[type[GenericModelT], *tuple[type[Any], ...]], __config__=None, __validators__=dict[str, classmethod[Any, Ellipsis, Any] | classmethod[Any, Ellipsis, Unknown]], __cls_kwargs__=None, **dict[str, tuple[DeferredType, FieldInfo]]) [no-matching-overload]
+ ERROR pydantic/v1/generics.py:124:25-132:14: No matching overload found for function `pydantic.v1.main.create_model` called with arguments: (str, __module__=str, __base__=tuple[type[GenericModelT], *tuple[type[Any], ...]], __config__=None, __validators__=dict[str, classmethod[Any, Ellipsis, Any]], __cls_kwargs__=None, **dict[str, tuple[DeferredType, FieldInfo]]) [no-matching-overload]
- ERROR pydantic/v1/main.py:952:51-55: Default `None` is not assignable to parameter `__validators__` with type `dict[str, classmethod[Any, Ellipsis, Any] | classmethod[Any, Ellipsis, Unknown]]` [bad-function-definition]
+ ERROR pydantic/v1/main.py:952:51-55: Default `None` is not assignable to parameter `__validators__` with type `dict[str, classmethod[Any, Ellipsis, Any]]` [bad-function-definition]
- ERROR pydantic/v1/main.py:966:51-55: Default `None` is not assignable to parameter `__validators__` with type `dict[str, classmethod[Any, Ellipsis, Any] | classmethod[Any, Ellipsis, Unknown]]` [bad-function-definition]
+ ERROR pydantic/v1/main.py:966:51-55: Default `None` is not assignable to parameter `__validators__` with type `dict[str, classmethod[Any, Ellipsis, Any]]` [bad-function-definition]
- ERROR pydantic/v1/main.py:979:51-55: Default `None` is not assignable to parameter `__validators__` with type `dict[str, classmethod[Any, Ellipsis, Any] | classmethod[Any, Ellipsis, Unknown]]` [bad-function-definition]
+ ERROR pydantic/v1/main.py:979:51-55: Default `None` is not assignable to parameter `__validators__` with type `dict[str, classmethod[Any, Ellipsis, Any]]` [bad-function-definition]

schema_salad (https://github.com/common-workflow-language/schema_salad)
- ERROR schema_salad/sourceline.py:211:32-37: Argument `list[int] | list[Never]` is not assignable to parameter `lc` with type `list[int] | None` in function `cmap` [bad-argument-type]
+ ERROR schema_salad/sourceline.py:213:30-32: `str` is not assignable to attribute `filename` with type `Never` [bad-assignment]
+ ERROR schema_salad/sourceline.py:226:30-32: `str` is not assignable to attribute `filename` with type `Never` [bad-assignment]

prefect (https://github.com/PrefectHQ/prefect)
- ERROR src/integrations/prefect-gcp/prefect_gcp/workers/cloud_run_v2.py:191:30-38: `str` is not assignable to attribute `_job_name` with type `Never` [bad-assignment]

@github-actions
Copy link
Copy Markdown

Primer Diff Classification

❌ 10 regression(s) | ✅ 13 improvement(s) | ➖ 3 neutral | 26 project(s) total | +64, -68 errors

10 regression(s) across openlibrary, cloud-init, aiohttp, mitmproxy, urllib3, pandera, async-utils, build, zulip, static-frame. error kinds: bad-assignment, bad-return, not-callable. caused by binding_to_type_info_name_assign(), current_flow_narrow_exhaustive_key(). 13 improvement(s) across pip, pwndbg, apprise, mongo-python-driver, vision, pandas, pytest-robotframework, meson, werkzeug, core, xarray, schema_salad, prefect.

Project Verdict Changes Error Kinds Root Cause
pip ✅ Improvement +3 Never inference failure on isinstance with tuple variable binding_to_type_info_name_assign()
openlibrary ❌ Regression -2 bad-return, not-callable binding_to_type_info_name_assign()
cloud-init ❌ Regression +1 bad-assignment binding_to_type_info_name_assign()
aiohttp ❌ Regression -1 bad-return binding_to_type_info_name_assign()
mitmproxy ❌ Regression +1 False positive from Never inference in reachable code current_flow_narrow_exhaustive_key()
urllib3 ❌ Regression +1, -1 bad-assignment current_flow_narrow_exhaustive_key()
pwndbg ✅ Improvement -6 missing-attribute, not-callable binding_to_type_info_name_assign()
apprise ✅ Improvement +6, -6 New bad-assignment with Never type binding_to_type_info_name_assign()
mongo-python-driver ✅ Improvement -2 bad-argument-type binding_to_type_info_name_assign()
pandera ❌ Regression +2, -1 missing-attribute, not-a-type binding_to_type_info_name_assign()
async-utils ❌ Regression +24, -24 not-a-type with Never (new) binding_to_type_info_name_assign()
build ❌ Regression +9 not-a-type on valid Literal type alias binding_to_type_info_name_assign()
vision ✅ Improvement -2 bad-argument-type binding_to_type_info_name_assign()
zulip ❌ Regression +3, -1 Never inference failures in new assignments binding_to_type_info_name_assign()
static-frame ❌ Regression +1 bad-assignment binding_to_type_info_name_assign()
pandas ✅ Improvement +2, -4 New Never-inference false positives binding_to_type_info_name_assign()
pytest-robotframework ✅ Improvement -1 bad-assignment binding_to_type_info_name_assign()
meson ✅ Improvement +3, -1 Never inference failures in new assignments binding_to_type_info_name_assign()
werkzeug ✅ Improvement -2 bad-assignment binding_to_type_info_name_assign()
pyodide ➖ Neutral +1, -1 bad-argument-type
core ✅ Improvement -3 bad-argument-type, unsupported-operation binding_to_type_info_name_assign()
sphinx ➖ Neutral +1, -2 New Never inference false positive binding_to_type_info_name_assign()
xarray ✅ Improvement -2 unsupported-operation binding_to_type_info_name_assign()
pydantic ➖ Neutral +4, -4 bad-function-definition, no-matching-overload
schema_salad ✅ Improvement +2, -1 New Never-typed false positives on fn variable binding_to_type_info_name_assign()
prefect ✅ Improvement -1 bad-assignment binding_to_type_info_name_assign()
Detailed analysis

❌ Regression (10)

openlibrary (-2)

Both errors were false positives caused by pyrefly's inability to properly narrow types through and expressions. On line 561, cover_edition has type Edition (via cast), and the expression cover_edition and cover_edition.get_cover() requires evaluating __bool__ on Edition. Pyrefly couldn't resolve __bool__ through the dynamic client.Thing class hierarchy, producing the Unknown | None error. On line 563, because pyrefly didn't properly narrow the and expression, it inferred cover's type as Edition | Image | None (the union of the left operand type and the right operand's return type) rather than Image | None. After the if cover: truthiness check narrowed away None, the remaining type was Edition | Image, making [cover] a list[Edition | Image] which doesn't match the declared list[Image] return type. In reality, when the and expression is truthy, the result is always from cover_edition.get_cover() (type Image | None), never the Edition itself. The PR's improved flow narrowing fixed the underlying inference for and expressions and name assignments in narrowed branches, correctly removing these spurious errors.
Attribution: The changes to binding_to_type_info_name_assign() in pyrefly/lib/alt/solve.rs and the new flow_narrow_exhaustive field plumbed through pyrefly/lib/binding/target.rs and pyrefly/lib/binding/bindings.rs improved flow narrowing for name assignments, allowing pyrefly to better track types through short-circuit expressions and conditional branches, eliminating these false positives.

cloud-init (+1)

This is a false positive (regression). The error claims imds_url_used has type Never, but the code is inside the function assert_in_context_manager (line 1263), which is a callback function used as a mock side_effect. The variable m_fetched_metadata is a mock.MagicMock() — setting any attribute on it is valid. Pyrefly's new Never-in-unreachable-branches feature (from the PR) is incorrectly determining that this code is unreachable due to flow narrowing. The Never type appearing in the error message is a clear indicator of an inference failure — the attribute imds_url_used should not have type Never. Neither mypy nor pyright flag this. The PR's logic in current_flow_narrow_exhaustive_key() appears to be collecting flow narrows too broadly, causing code that IS reachable to be treated as unreachable.
Attribution: The change in pyrefly/lib/binding/target.rs adds flow_narrow_exhaustive to NameAssign bindings. When is_new_binding_in_flow is true and there are active flow narrows, it creates an exhaustive key. Then in pyrefly/lib/alt/solve.rs, binding_to_type_info_name_assign() checks if flow_narrow_exhaustive resolves to Never and if so, collapses the assigned variable's type to Never. The problem is that this logic is being applied too aggressively — the function assert_in_context_manager at line 1263 is a nested function inside a test method. It's not inside an if branch that narrows anything to impossible. The m_fetched_metadata variable is created inside this function, and pyrefly is incorrectly determining that some flow narrow makes this branch unreachable. Looking at the broader context, this function is used as a side_effect for a mock (line 1271-1273), so it's definitely reachable at runtime. The current_flow_narrow_exhaustive_key() in pyrefly/lib/binding/bindings.rs collects ALL active flow narrows in the current scope, which may include narrows from outer scopes that don't actually make this code unreachable.

aiohttp (-1)

The removed error was a genuine type error (bytearray is not bytes). The branch containing it is NOT truly unreachable — read(decode=True) is called elsewhere in the codebase (lines 465, 472, 487). The type checker narrows the decode parameter from bool to Literal[False] based on the default value, causing the if decode: branch to be considered unreachable. When pyrefly treats this branch as unreachable, it skips type-checking within it, which suppresses the real bad-return error at line 325. Note that the # type: ignore[unreachable] comment on line 319 only suppresses the unreachability warning on that specific line — it does not suppress the bad-return error on line 325. The bad-return error disappears only because pyrefly's unreachability analysis causes it to skip type-checking the entire dead branch. The unreachability analysis issue was already known (referenced by the mypy issue #17537 comment in the code), so this is a pre-existing limitation in how type checkers handle parameters with literal default values.
Attribution: The change to binding_to_type_info_name_assign() in pyrefly/lib/alt/solve.rs now collapses assigned variables to Never when flow_narrow_exhaustive resolves to Never. The new current_flow_narrow_exhaustive_key() in pyrefly/lib/binding/bindings.rs creates exhaustive keys from active narrows. Together, these cause decoded_data in the narrowed-away branch to become Never, making the bad-return error disappear since Never is assignable to bytes.

mitmproxy (+1)

False positive from Never inference in reachable code: The PR's new exhaustive narrowing logic incorrectly collapses mod.request.method to Never at line 561, even though this code is clearly reachable. The current_flow_narrow_exhaustive_key() function appears to be picking up unrelated narrow entries from the surrounding flow context and incorrectly determining the branch is unreachable.

Overall: This is a false positive introduced by the PR's new unreachable code detection. Looking at the test code:

mod = tft(method="put", start=6)  # line 556
v.add([mod])  # line 557
...
mod.request.method = "GET"  # line 561 - ERROR

Line 561 is clearly reachable code — there's no conditional branch making it unreachable. The mod variable is a valid HTTPFlow object, and assigning "GET" to mod.request.method is perfectly normal.

The PR's logic in current_flow_narrow_exhaustive_key() collects ALL active flow narrows and creates an exhaustive binding. The check is_new_binding_in_flow at line 524 of target.rs determines if a name is new in the current flow. If there happen to be any active narrows in the flow (from unrelated conditions), and those narrows resolve to Never (perhaps from some exhaustive match elsewhere), the new assignment gets incorrectly collapsed to Never.

The method attribute is being typed as Never instead of str, which is clearly wrong. The error message Literal['GET'] is not assignable to attribute method with type Never confirms that pyrefly incorrectly inferred the type of mod.request.method as Never due to the overly aggressive unreachable code detection.

Per-category reasoning:

  • False positive from Never inference in reachable code: The PR's new exhaustive narrowing logic incorrectly collapses mod.request.method to Never at line 561, even though this code is clearly reachable. The current_flow_narrow_exhaustive_key() function appears to be picking up unrelated narrow entries from the surrounding flow context and incorrectly determining the branch is unreachable.

Attribution: The PR changes in pyrefly/lib/alt/solve.rs and pyrefly/lib/binding/target.rs introduce the flow_narrow_exhaustive mechanism. Specifically, current_flow_narrow_exhaustive_key() in pyrefly/lib/binding/bindings.rs collects all active flow narrows and creates an Exhaustive binding. Then in binding_to_type_info_name_assign() in pyrefly/lib/alt/solve.rs, if the exhaustive key resolves to Never, the assigned variable is collapsed to Never. The bug is that is_new_binding_in_flow check in pyrefly/lib/binding/target.rs (line 524) triggers for mod at line 561 because mod was defined outside the current flow context (it was defined at line 556, before the with taddons.context(v) as tctx: block or some flow boundary). The narrow entries from some unrelated condition are being incorrectly applied, causing mod.request.method to be typed as Never when it shouldn't be.

urllib3 (+1, -1)

This change reports the same real type violation (assigning Literal[2] to _fp). The new error reports the attribute type as Never instead of HTTPResponse | None. Without access to the actual _fp type annotation in the HTTPResponse class definition, we cannot definitively determine which reported type is more accurate at this specific program point. The Never type could potentially arise from type narrowing — for example, if resp3 = HTTPResponse("foodata") causes the type checker to determine that after construction with a string body and preloading, _fp is narrowed to None, and then some further narrowing logic produces Never. However, Never as the attribute type is unusual and likely represents a type inference regression, since _fp is almost certainly declared as HTTPResponse | None (referring to http.client.HTTPResponse | None) in the class definition, and there's no obvious narrowing that would produce Never at this point in the code. Both old and new errors correctly identify the same real bug. The change in the reported type from HTTPResponse | None to Never is likely a degradation in type inference quality, though the root cause may not necessarily be related to unreachable branch logic as originally speculated — it could stem from other changes in how attribute types are resolved or narrowed.
Attribution: The change in pyrefly/lib/binding/target.rs adds flow_narrow_exhaustive to NameAssign bindings. The current_flow_narrow_exhaustive_key() method in pyrefly/lib/binding/bindings.rs collects all active narrow entries in the current flow scope. This appears to be incorrectly picking up narrows from earlier in the test_io method (possibly from the try/finally or conditional blocks), causing _fp's type to collapse to Never when it shouldn't.

pandera (+2, -1)

The two new errors occur at lines where raw_annot = annot_info.origin[param_dict[annot_info.arg]] is assigned, and then Optional[raw_annot] is used. The # type: ignore on the assignment line indicates this was already known to be problematic for static type checkers. Pyright also reports errors at these locations (pyright: yes), which means this is not a pyrefly-specific false positive from aggressive exhaustive narrowing, but rather a case where the subscript operation on annot_info.origin produces a type (Never) that cannot be used as a type form. The removed error (tolist on Iterable) was a genuine false positive that is now correctly resolved — get_regex_columns returns a pandas Index which does have tolist(). Net assessment: the two new errors align with pyright's analysis and represent improved type inference consistency rather than regressions. The one removed error is a clear improvement.
Attribution: The changes to binding_to_type_info_name_assign() in pyrefly/lib/alt/solve.rs and the new flow_narrow_exhaustive field in pyrefly/lib/binding/binding.rs cause the regression. The current_flow_narrow_exhaustive_key() in pyrefly/lib/binding/bindings.rs collects all active flow narrows and creates an exhaustive binding. When is_new_binding_in_flow is true (line in pyrefly/lib/binding/target.rs), this key is attached to the NameAssign. The solver then collapses the type to Never if the exhaustive key resolves to Never. The bug is that flow narrows from outer conditions (like isinstance checks) are being incorrectly propagated, making raw_annot appear to be in an unreachable branch when it isn't.

async-utils (+24, -24)

not-a-type with Never (new): 24 new errors all say 'Expected a type form, got instance of Never'. These are inference failures caused by the PR's new Never-collapsing logic incorrectly treating the else branch of if TYPE_CHECKING: as unreachable (due to TYPE_CHECKING = False on line 27). HashAndCompareT is a valid TypeVar and should be usable in annotations. 0/24 co-reported by mypy/pyright. This is a regression.
not-a-type with TypeVar (removed): 24 removed errors said 'Expected a type form, got instance of TypeVar'. These were also false positives — TypeVars ARE valid type forms in annotations. Removing them would be an improvement, except they were replaced by equally-wrong (arguably worse) Never errors. Net effect: the removal itself is positive, but the replacement errors negate the benefit.

Overall: This is a net-neutral-to-regression change. The old errors ('got instance of TypeVar') were already false positives — TypeVars are valid type forms in annotations. The new errors ('got instance of Never') are also false positives, but they indicate a WORSE inference state: pyrefly now infers Never where it previously at least recognized the TypeVar. The root cause is the TYPE_CHECKING = False pattern on line 27 — this is a well-known exempt pattern (rule 9) that mypy/pyright handle correctly. The PR's new Never-collapsing logic in binding_to_type_info_name_assign() appears to incorrectly treat the else branch of if TYPE_CHECKING: as unreachable when TYPE_CHECKING is reassigned to False, causing names defined there (including the type aliases CanHashAndCompareLT and CanHashAndCompareGT) to collapse to Never. This cascades: the TypeVar HashAndCompareT bound to CanHashAndCompare (which is CanHashAndCompareLT | CanHashAndCompareGT) becomes Never, and then every use of HashAndCompareT as a type annotation triggers 'Expected a type form, got instance of Never'. The error message changed from one false positive flavor to another, with the new one being arguably worse (Never inference failure vs. TypeVar misclassification). Since both old and new errors are false positives at the same locations, and the error quality degraded (Never is less informative than TypeVar), this is a regression.

Attribution: The change in pyrefly/lib/alt/solve.rs in binding_to_type_info_name_assign() now checks flow_narrow_exhaustive and collapses the type to Never when the branch is deemed impossible. The new field flow_narrow_exhaustive in NameAssign (added in pyrefly/lib/binding/binding.rs) is populated via current_flow_narrow_exhaustive_key() (added in pyrefly/lib/binding/bindings.rs). The logic in pyrefly/lib/binding/target.rs sets flow_narrow_exhaustive when is_new_binding_in_flow is true. The problem is that this mechanism is incorrectly triggering for TypeVar definitions or type alias resolutions in this project, causing HashAndCompareT to resolve to Never instead of being recognized as a TypeVar. The TYPE_CHECKING = False pattern on line 27 (which is a well-known exempt pattern per rule 9) combined with the if TYPE_CHECKING: / else: branching likely causes pyrefly to treat the else branch as unreachable, and the new Never-collapsing logic then makes all names defined in that branch resolve to Never, cascading into the TypeVar bound resolution.

build (+9)

not-a-type on valid Literal type alias: All 9 errors flag build.env.Installer or _env.Installer (which is typing.Literal['pip', 'uv']) as 'Expected a type form, got instance of Never'. This is a valid type alias being incorrectly resolved to Never by pyrefly. The type alias is defined at module level in src/build/env.py and is not inside any conditional branch. The exact internal mechanism in pyrefly causing the incorrect resolution is not determinable from the source code alone, but the errors are clearly false positives since Installer = typing.Literal['pip', 'uv'] is a valid type alias that should be usable as a type annotation. Neither mypy nor pyright report any issues at these locations.

Overall: All 9 errors are not-a-type with message 'Expected a type form, got instance of Never', occurring on annotations like installer: build.env.Installer and installer: _env.Installer. build.env.Installer is defined as Installer = typing.Literal['pip', 'uv'] — a perfectly valid type alias per the typing spec. Pyrefly is incorrectly resolving this type alias to Never instead of recognizing it as the valid Literal['pip', 'uv'] type form. The exact internal mechanism causing this is unclear from the source code alone, but the result is that pyrefly treats the resolved type as an instance of Never rather than as a type form. Neither mypy nor pyright flag any of these locations (0/9 cross-check matches). These are all false positives — the type alias is valid and should be accepted as a type annotation.

Attribution: The PR changes binding_to_type_info_name_assign() in pyrefly/lib/alt/solve.rs to collapse types to Never when flow_narrow_exhaustive indicates the branch is impossible. The current_flow_narrow_exhaustive_key() method in pyrefly/lib/binding/bindings.rs collects all active flow narrows and creates an Exhaustive binding. The bind_target_name() in pyrefly/lib/binding/target.rs now passes flow_narrow_exhaustive for new bindings in the flow. The problem is that this mechanism is too aggressive — it's collapsing build.env.Installer (which is typing.Literal['pip', 'uv']) to Never when it shouldn't be. The type alias Installer is being resolved through the module build.env, and the new Never-collapsing logic is incorrectly treating it as a value in an unreachable branch rather than as a type form. This causes the not-a-type error because pyrefly sees Never where it expects a type annotation.

zulip (+3, -1)

Never inference failures in new assignments: All 3 new errors show Never or @_ types where concrete types should exist. The PR's new logic in binding_to_type_info_name_assign() collapses variable types to Never when flow_narrow_exhaustive resolves to Never, but this is triggering in branches that are clearly reachable (normal test method bodies with no exhaustive narrowing). These are false positives — regressions.
Removed unbound-name check: The removal of the hex_value may be uninitialized error could be either an improvement (if it was a false positive) or a regression (if it was catching a real bug). Without seeing the source code, the assessment is uncertain, but given the PR's changes to how Never types propagate, it's plausible this was a side effect of the same mechanism.

Overall: The 3 new errors are all false positives caused by the PR's overly aggressive Never type inference in branches with active flow narrows. The error messages themselves contain Never and @_ types, which are classic signs of inference failures (per rule 6). Looking at the actual code:

  1. corporate/tests/test_stripe.py:5762customer.stripe_customer_id = "cus_12345" is a straightforward assignment in test_update_or_create_stripe_customer_logic(). The customer variable is a Django Customer model created on line 5752. There's no type narrowing that would make this line unreachable.

  2. zerver/tests/test_auth_backends.py:9335 and 9354 — These assign headers attributes with dict[str, str] values, but pyrefly claims the attribute type is Never, indicating the same inference bug.

All 3 errors are pyrefly-only (neither mypy nor pyright flag them), and all involve Never types where concrete types should be inferred. The PR's current_flow_narrow_exhaustive_key() function collects ALL active narrows in the current flow scope and creates an exhaustiveness check. This appears to be triggering incorrectly in test methods where narrows from earlier in the method (or from the test framework) are still active but don't actually make the current code unreachable.

The removed error (hex_value may be uninitialized) could be a legitimate loss, but it's also possible the variable is now incorrectly typed as Never instead, which would suppress the uninitialized warning but introduce a different (hidden) issue.

Per-category reasoning:

  • Never inference failures in new assignments: All 3 new errors show Never or @_ types where concrete types should exist. The PR's new logic in binding_to_type_info_name_assign() collapses variable types to Never when flow_narrow_exhaustive resolves to Never, but this is triggering in branches that are clearly reachable (normal test method bodies with no exhaustive narrowing). These are false positives — regressions.
  • Removed unbound-name check: The removal of the hex_value may be uninitialized error could be either an improvement (if it was a false positive) or a regression (if it was catching a real bug). Without seeing the source code, the assessment is uncertain, but given the PR's changes to how Never types propagate, it's plausible this was a side effect of the same mechanism.

Attribution: The changes in pyrefly/lib/alt/solve.rs in binding_to_type_info_name_assign() are directly responsible. The new logic checks if flow_narrow_exhaustive resolves to Never, and if so, collapses the assigned name's type to Never. The flow_narrow_exhaustive key is computed in pyrefly/lib/binding/target.rs via current_flow_narrow_exhaustive_key() — it's set for any new binding in a flow that has active narrows. The problem is this is too aggressive: it's marking variables as Never in branches that are NOT actually unreachable. For example, at line 5762 of test_stripe.py, customer.stripe_customer_id = "cus_12345" is in a normal test method body — there's no narrowing that makes this branch unreachable. The narrowing logic appears to be incorrectly detecting active narrows in contexts where the branch is perfectly reachable.

static-frame (+1)

This is a cascade error from pyrefly's new unreachable-branch detection. The else branch at line 232 is considered unreachable by type narrowing (the code already has # type: ignore[unreachable] on line 237). The PR now types new variables in such branches as Never, which causes a noisy cascade bad-assignment on line 239. While the branch being unreachable is arguably correct from a type perspective, producing additional errors within an already-unreachable block is unnecessary noise. Neither mypy nor pyright produce this cascade error — they stop at flagging the branch as unreachable. The Never type in the error message confirms this is an inference cascade, not a real bug being caught.
Attribution: The change to binding_to_type_info_name_assign() in pyrefly/lib/alt/solve.rs now checks flow_narrow_exhaustive and collapses the assigned type to Never when the branch is deemed impossible. The new current_flow_narrow_exhaustive_key() in pyrefly/lib/binding/bindings.rs creates exhaustive keys from current flow narrows. This causes array on line 237 to be typed as Never, which cascades to the bad-assignment error on line 239 when assigning to array.flags.writeable.

✅ Improvement (13)

pip (+3)

Never inference failure on isinstance with tuple variable: All 3 errors stem from pyrefly failing to properly handle isinstance(obj, ARRAY_TYPES) where ARRAY_TYPES = (list, tuple) is a module-level tuple of classes. The PR's new Never-propagation in binding_to_type_info_name_assign() (solve.rs) incorrectly collapses types to Never when it shouldn't. Lines 111 and 219 get Expected class object, got 'Never' (the ARRAY_TYPES argument is misresolved), and line 112 gets a cascading bad-argument-type because obj is incorrectly narrowed to Never/object instead of list | tuple. All 3 are pyrefly-only false positives.

Overall: These are all false positives caused by the PR's new Never-propagation logic interacting poorly with isinstance() checks that use a module-level tuple variable (ARRAY_TYPES = (list, tuple)) as the class argument. The Never in the error messages (Expected class object, got 'Never') is a clear inference failure — pyrefly is failing to resolve ARRAY_TYPES as tuple[type[list], type[tuple]] in the isinstance context, and the new code path then collapses the result to Never, cascading into the bad-argument-type error on line 112. Neither mypy nor pyright flags these. The code is perfectly valid Python that works correctly at runtime.

Attribution: The PR changes binding_to_type_info_name_assign() in pyrefly/lib/alt/solve.rs to collapse types to Never when flow_narrow_exhaustive resolves to Never. The new current_flow_narrow_exhaustive_key() in pyrefly/lib/binding/bindings.rs collects all active flow narrows and creates an exhaustive binding. The problem is that this mechanism is too aggressive — it's incorrectly determining that the branch containing isinstance(obj, ARRAY_TYPES) is unreachable. Looking at format_literal(), obj is typed as object. After the preceding isinstance checks for bool, (int, float, date, datetime), time, and str, the remaining type is narrowed. But isinstance(obj, ARRAY_TYPES) where ARRAY_TYPES = (list, tuple) should still be reachable since object minus those types still includes list and tuple. The issue is likely that pyrefly cannot properly resolve ARRAY_TYPES as a tuple of class objects for isinstance narrowing — it may be treating it as an opaque value, and the new Never-propagation logic then incorrectly collapses the type. The is_aot function at line 219 has the same issue with isinstance(obj, ARRAY_TYPES) where obj: Any.

pwndbg (-6)

The PR fixes how Never types propagate in unreachable branches. Previously, pyrefly's incorrect Never/None inference was cascading into reachable code, causing 6 false positive errors. The fix correctly scopes Never inference to genuinely unreachable branches, eliminating these false positives.
Attribution: The changes to binding_to_type_info_name_assign() in pyrefly/lib/alt/solve.rs now correctly collapse types to Never only when flow_narrow_exhaustive key resolves to Never (genuinely unreachable branches). The new current_flow_narrow_exhaustive_key() in pyrefly/lib/binding/bindings.rs and the flow_narrow_exhaustive field in pyrefly/lib/binding/binding.rs ensure this only applies to new bindings in unreachable flow paths. This fixed the incorrect type propagation that was causing false positives in reachable code.

apprise (+6, -6)

New bad-assignment with Never type: All 6 new errors report assigning a concrete type (e.g., Literal['already.set']) to an attribute with type Never. The Never type likely results from pyrefly over-aggressively narrowing the home_server attribute type based on assertions on different object instances earlier in the same function (e.g., assert obj2.home_server == "matrix.org" followed by assert obj6.home_server is None). The checker appears to be conflating type narrowing across distinct instances of the same class. The assignments like obj4.home_server = "already.set" are in perfectly reachable test code — they're setting up test preconditions on freshly created objects. 0/6 co-reported by mypy or pyright. These are regressions (false positives).
Removed bad-argument-type errors: 3 removed errors about str | None not assignable to bytes | str. Without seeing the exact code, the None part genuinely isn't in bytes | str, so these may have been legitimate catches. However, if the value was narrowed to str at the call site, these were false positives. Ambiguous, but the removal pattern suggests improvement.
Removed bad-assignment with Never type: 2 removed errors about int not assignable to attribute with type Never — same pattern as the new errors but in reverse. These were false positives from the same type narrowing issue that the PR partially fixes. Their removal is an improvement.
Removed missing-attribute on NoneType: 1 removed error about NoneType missing url_id. Could be a legitimate error (accessing attribute on None) or a false positive from incorrect narrowing. Without more context, ambiguous.

Overall: The 6 new bad-assignment errors all have Never as the target type, which indicates a type narrowing issue. The code obj4.home_server = "already.set" is straightforward attribute assignment on a NotifyMatrix instance — home_server is a regular str | None attribute. The Never type likely arises from pyrefly over-aggressively narrowing the type of the home_server attribute based on assertions earlier in the function (e.g., assert obj2.home_server == "matrix.org" and assert obj6.home_server is None), possibly conflating narrowing across different object instances within the same function scope. Neither mypy nor pyright flag any of these 6 errors. The 6 removed errors are a mix — the 2 removed bad-assignment with Never types were likely the same class of false positive (improvement), the missing-attribute removal and bad-argument-type removals need more context but are plausibly improvements. Overall, the net effect is neutral-to-negative: 6 new false positives added, ~6 old errors removed (some of which were likely false positives too).

Attribution: The PR changes binding_to_type_info_name_assign() in pyrefly/lib/alt/solve.rs to check flow_narrow_exhaustive — when active flow narrows make a branch impossible, newly assigned names collapse to Never. The current_flow_narrow_exhaustive_key() method in pyrefly/lib/binding/bindings.rs collects all active narrow entries in the current flow. The target.rs change passes this key for new bindings (where is_new_binding_in_flow is true). The problem is that obj4.home_server = "already.set" appears after narrowing has occurred earlier in the test function (e.g., after assert obj2._login() is True and assert obj2.home_server == "matrix.org" which may create narrows). The PR incorrectly treats these assignments as being in unreachable branches because there are active narrow entries in the flow, even though the code is perfectly reachable. The is_new_binding_in_flow check (self.scopes.current_flow_idx(&name.id).[is_none()](https://github.com/facebook/pyrefly/blob/main/pyrefly/lib/alt/solve.rs)) triggers because home_server hasn't been assigned in the current flow scope before, and the narrow entries from earlier assertions cause the exhaustive check to resolve to Never.

mongo-python-driver (-2)

Both removed errors showed dict[tuple[Never, Never], ServerDescription] — a clear Never inference failure (per rule #6). The self._seeds set is populated with tuple[str, int] values throughout the constructor, so the dict comprehension keys are valid at runtime. Pyrefly was incorrectly inferring Never for the iteration variables and propagating that into the dict type. The PR fix correctly prevents Never from leaking into dict literal type construction.
Attribution: The change to binding_to_type_info_name_assign() in pyrefly/lib/alt/solve.rs that skips populate_dict_literal_facets when the type is Never (the if !type_info.ty().[is_never()](https://github.com/facebook/pyrefly/blob/main/pyrefly/lib/alt/solve.rs) guard) prevents the Never type from leaking into dict literal facet construction, which removed these false positive errors.

vision (-2)

Both removed errors were false positives. The first error flagged ReadOnlyTensorBuffer as incompatible with PIL.Image.open's fp parameter, but this class implements file-like behavior (it provides the read method and other IO[bytes] interface methods needed by PIL) and works correctly at runtime. The type checker was being overly strict about structural compatibility. The second error flagged tuple[int, ...] as incompatible with tuple[int, int]. Looking at the code, size has type Union[list[int], int], and by line 253 it has been narrowed to list[int] (due to the isinstance(size, list) and len(size) == 2 check on line 250 that raises TypeError otherwise). The expression tuple(size[::-1]) applies a slice to list[int] (producing list[int]) and then tuple() on that, which gives tuple[int, ...]. While the runtime check guarantees exactly 2 elements, the type system cannot track list length through slice operations, so tuple[int, ...] is the inferred type. Since tuple[int, ...] is not structurally assignable to tuple[int, int] (a variable-length tuple type is not a subtype of a fixed-length tuple type), the type checker flagged this. However, this is a false positive because the code guarantees the correct length at runtime. Removing these false positives is an improvement in pyrefly's accuracy.
Attribution: The PR changes binding_to_type_info_name_assign() in pyrefly/lib/alt/solve.rs to accept a flow_narrow_exhaustive parameter. When the flow narrows indicate an impossible branch, the assigned name collapses to Never. The key change is the new guard if !type_info.ty().[is_never()](https://github.com/facebook/pyrefly/blob/main/pyrefly/lib/alt/solve.rs) that skips populate_dict_literal_facets when the type is Never. However, the connection to these specific PIL-related errors is indirect — the PR modifies how types are resolved in narrowed branches, which could affect type inference in ways that cascade to these call sites. The current_flow_narrow_exhaustive_key() method in pyrefly/lib/binding/bindings.rs creates exhaustive bindings from current flow narrows, and pyrefly/lib/binding/target.rs passes this to NameAssign. It's possible these changes altered type resolution paths that previously produced overly-strict tuple or buffer types, though the direct mechanism isn't entirely clear from the diff alone.

pandas (+2, -4)

New Never-inference false positives: Both new errors contain Never/@_ types and are pyrefly-only. The enumerate error incorrectly infers locs as non-iterable, and the _is_resample error incorrectly types the attribute as Never. These are regressions from the new unreachable-code logic over-triggering.
Removed false positives on pandas classes: All 4 removed errors were incorrect: NaTType.freq exists, Index.as_unit exists on subclasses, ndarray.reduce was a resolution failure, and object iterability was wrong. These removals are improvements.

Overall: Net: 4 false positives removed, 2 false positives added. The new errors both contain Never/@_ types (classic inference failure pattern), are pyrefly-only, and flag code that is clearly correct at runtime. The removed errors were genuine false positives on well-known pandas classes. Overall this is a mixed bag but the removals outweigh the additions, and the new errors are clearly inference failures from the Never-propagation change.

Per-category reasoning:

  • New Never-inference false positives: Both new errors contain Never/@_ types and are pyrefly-only. The enumerate error incorrectly infers locs as non-iterable, and the _is_resample error incorrectly types the attribute as Never. These are regressions from the new unreachable-code logic over-triggering.
  • Removed false positives on pandas classes: All 4 removed errors were incorrect: NaTType.freq exists, Index.as_unit exists on subclasses, ndarray.reduce was a resolution failure, and object iterability was wrong. These removals are improvements.

Attribution: The changes to binding_to_type_info_name_assign() in pyrefly/lib/alt/solve.rs that collapse types to Never when flow_narrow_exhaustive resolves to Never, combined with current_flow_narrow_exhaustive_key() in pyrefly/lib/binding/bindings.rs that attaches exhaustive keys to new bindings in flow, cause the two new false positive errors. The removed errors are likely a side effect of the changed type propagation logic.

pytest-robotframework (-1)

The removed error was a false positive. The method _get_failure has parameters *args: Never, **kwargs: Never, which means the arguments passed to get_arg_with_type involve Never types. Pyrefly was incorrectly propagating Never through the call chain, likely causing exc_value to be inferred as Never or a type involving Never. Since any attribute access on Never yields Never, exc_value.__traceback__ was inferred as Never instead of the correct TracebackType | None (which is the type of BaseException.__traceback__). The PR's changes to how Never is propagated fixed this inference bug, correctly removing the spurious bad-assignment error.
Attribution: The change in pyrefly/lib/alt/solve.rs in binding_to_type_info_name_assign() now only collapses types to Never when flow_narrow_exhaustive indicates the branch is truly unreachable AND the variable is a new binding in that flow (checked via is_new_binding_in_flow in pyrefly/lib/binding/target.rs). This more precise handling prevents Never from incorrectly leaking into types of existing variables like exc_value.__traceback__.

meson (+3, -1)

Never inference failures in new assignments: The introspection.py errors (lines 372-373) show dict[str, Never] inference for flattened_kwargs. This occurs because the PR's narrowing determines the value type is Never after the isinstance checks on val. Whether this is correct depends on the definition of TYPE_var — if it includes types beyond BaseNode | str | bool | int | float (as it typically does), these are false positives. The optinterpreter.py error (line 95) shows Never for e.file because the branch if not isinstance(ast, mparser.CodeBlockNode) may be inferred as unreachable. This depends on the return type of Parser.parse() — if it returns CodeBlockNode, the inference is correct; if it returns a broader type like BaseNode, it's a false positive.
Removed hotdoc.py bad-return: The removed error flagged a list return from ensure_file() whose parameter type T.Union[str, File, CustomTarget, CustomTargetIndex] doesn't include list. The isinstance(value, list) branch is unreachable per declared types, so the PR's improved unreachable-branch handling correctly suppresses this error. This is a minor improvement.

Overall: The 3 new errors all involve Never type inference:

  1. introspection.py:372-373: flattened_kwargs = {} on line 365 is inferred as dict[str, Never]. The flatten_kwargs() method iterates over kwargs.items() (where kwargs: T.Dict[str, TYPE_var]) and conditionally adds entries. Line 367 checks isinstance(val, BaseNode) and line 371 checks isinstance(val, (str, bool, int, float)) or include_unknown_args. If the PR's improved narrowing determines that TYPE_var is exhaustively covered by these isinstance checks such that the value type collapses to Never, this would explain the error. Whether this is a true or false positive depends on the exact definition of TYPE_var — if TYPE_var includes types beyond BaseNode | str | bool | int | float (which it typically does, often including list, dict, etc.), then this is a false positive from overly aggressive exhaustive narrowing.

  2. optinterpreter.py:95: e.file = option_file is flagged because the entire if not isinstance(ast, mparser.CodeBlockNode) branch (line 92) is inferred as unreachable. On line 88, ast = mparser.Parser(code, option_file).parse() — if parse() is typed to return mparser.CodeBlockNode, then the not isinstance(ast, mparser.CodeBlockNode) check is indeed unreachable per the type system, and the Never inference for e (and thus e.file) would be correct. However, if parse() returns a broader type like mparser.BaseNode, then this branch is reachable and the error is a false positive. The determination depends on the return type annotation of Parser.parse().

  3. The removed error (hotdoc.py:249) was flagging a list return from ensure_file() whose parameter type is T.Union[str, File, CustomTarget, CustomTargetIndex]. Since list is not in this union, the isinstance(value, list) branch on line 245 is unreachable per the declared types. The PR's improved unreachable-branch handling correctly suppresses this error, which is a minor improvement.

Net assessment: The 3 new errors involve Never type inference. The introspection.py errors are likely false positives if TYPE_var includes types not covered by the isinstance checks. The optinterpreter.py error may be a true or false positive depending on the return type of Parser.parse(). The removed error is a reasonable improvement.

Attribution: The PR changes binding_to_type_info_name_assign() in pyrefly/lib/alt/solve.rs to collapse newly assigned variables to Never when the current flow narrows make the branch impossible. The current_flow_narrow_exhaustive_key() in pyrefly/lib/binding/bindings.rs creates an exhaustive key from all active flow narrows. The target.rs change adds flow_narrow_exhaustive to NameAssign bindings when is_new_binding_in_flow is true. This is causing false Never inference in flatten_kwargs where flattened_kwargs = {} creates a dict[str, Never] instead of an empty dict that should be inferred as dict[str, TYPE_var]. The issue is that the exhaustive narrowing check is too aggressive — it's applying to code paths that ARE reachable (inside normal for-loop/if branches), not just truly unreachable branches.

werkzeug (-2)

Both removed errors show Never as the attribute type, which is a classic Never inference failure pattern (see rule 6). The werkzeug Response.age setter accepts int | timedelta and Response.retry_after setter accepts datetime | int — assigning timedelta and datetime respectively is perfectly valid. The Never type appearing here indicates pyrefly was incorrectly inferring Never for these attributes in reachable code. The PR correctly scopes Never inference to genuinely unreachable branches only, fixing these false positives.
Attribution: The PR changes binding_to_type_info_name_assign() in pyrefly/lib/alt/solve.rs to check flow_narrow_exhaustive and only collapse types to Never when the flow narrow key resolves to Never (i.e., the branch is genuinely unreachable). The new flow_narrow_exhaustive field in NameAssign (in pyrefly/lib/binding/binding.rs) and the current_flow_narrow_exhaustive_key() method (in pyrefly/lib/binding/bindings.rs) track whether a name assignment is in an unreachable branch. The is_new_binding_in_flow check in pyrefly/lib/binding/target.rs ensures this only applies to new bindings. This fix prevents Never from incorrectly propagating to attribute types in reachable code, which was causing the false positive bad-assignment errors on response.age and response.retry_after.

core (-3)

These three removed errors were all false positives caused by a Never type inference bug related to type narrowing. The dict[Never, Never] type appearing in the error messages is the telltale sign. Looking at the code, info_desc has type Mapping[str, Any]. On line 524, there's an isinstance(info_desc, CaseInsensitiveDict) check. In the if branch (line 525), info_desc is narrowed to CaseInsensitiveDict, and upnp_info = {**info_desc.as_dict()} produces a dict. In the else branch (line 527), upnp_info = {**info_desc} should produce dict[str, Any] from unpacking the Mapping[str, Any]. However, pyrefly was incorrectly inferring the type in the else branch as involving Never, likely due to how it narrowed Mapping[str, Any] after excluding CaseInsensitiveDict via the isinstance check. This caused upnp_info to be inferred as dict[Never, Never] in that branch, leading to the first two errors on line 538 (cannot set item in dict[Never, Never]) and the third error on line 549 where the union of the two branches' types (dict[Unknown, Unknown] | dict[Never, Never]) was not assignable to Mapping[str, Any]. The PR fixes the root cause of this Never type propagation issue, which eliminates these false positives since the code is clearly reachable and upnp_info should be dict[str, Any].
Attribution: The PR changes binding_to_type_info_name_assign() in pyrefly/lib/alt/solve.rs to accept a flow_narrow_exhaustive parameter. When this key resolves to Never (indicating an unreachable branch), the assigned name collapses to Never. Crucially, the new code also adds if !type_info.ty().[is_never()](https://github.com/facebook/pyrefly/blob/main/pyrefly/lib/alt/solve.rs) before populate_dict_literal_facets(), which prevents dict literal facet population for Never types. The current_flow_narrow_exhaustive_key() in pyrefly/lib/binding/bindings.rs and the is_new_binding_in_flow check in pyrefly/lib/binding/target.rs ensure this only applies to genuinely new bindings in unreachable flow branches. Previously, pyrefly was apparently leaking Never types from unreachable branch analysis into reachable code paths, causing the false dict[Never, Never] inference. The fix properly scopes Never inference to only genuinely unreachable branches.

xarray (-2)

Both removed errors were false positives where pyrefly inferred unique_indexes as dict[int, Index | PandasIndex] from one branch's assignments and then flagged the unique_indexes[None] = None assignment in another branch as incompatible. The dict is intentionally used with both int and None keys. Removing these errors is correct.
Attribution: The change to binding_to_type_info_name_assign() in pyrefly/lib/alt/solve.rs that skips populate_dict_literal_facets when type_info.ty().[is_never()](https://github.com/facebook/pyrefly/blob/main/pyrefly/lib/alt/solve.rs) likely changed how dict type facets are tracked in narrowed branches, which removed the overly-narrow dict type inference that caused these false positives.

schema_salad (+2, -1)

New Never-typed false positives on fn variable: Lines 213 and 226 report str is not assignable to attribute filename with type Never. The variable fn is correctly str (after None-narrowing at lines 182-183) — the error confirms this. The Never type is on the attribute filename of cm.lc/cs.lc, not on fn. The LineCol object returned by .lc on CommentedMap/CommentedSeq likely doesn't declare filename in its type stubs, and the PR's changes cause pyrefly to resolve this undeclared attribute as Never. These assignments are valid at runtime. Both errors are pyrefly-only false positives and represent a regression.
Removed list[Never] false positive: The removed error at line 211 claimed list[int] | list[Never] was not assignable to parameter lc with type list[int] | None. The list[Never] was an inference artifact — uselc can only be list[int] (either from the explicit list construction at line 206 or from lc which is list[int] after narrowing). Removing this false positive is an improvement.

Overall: The two new errors are false positives. At lines 213 and 226, fn is correctly typed as str (narrowed from str | None at lines 182-183), as confirmed by the error messages themselves which say str is not assignable to attribute filename with type Never. The problem is that pyrefly infers the filename attribute on cm.lc/cs.lc as Never — likely because the LineCol type (returned by the .lc property on CommentedMap/CommentedSeq) doesn't declare a filename attribute in its type stubs, and the PR's changes cause pyrefly to resolve this undeclared attribute as Never rather than allowing it. The code cm.lc.filename = fn and cs.lc.filename = fn are perfectly valid at runtime. The removed error at line 211 was a genuine false positive where list[Never] appeared as an inference artifact in the union type for uselc. Net effect: removed 1 false positive but introduced 2 new false positives.

Attribution: The PR changes binding_to_type_info_name_assign() in pyrefly/lib/alt/solve.rs to check flow_narrow_exhaustive and collapse the type to Never when the branch is impossible. The new field flow_narrow_exhaustive is added to NameAssign in binding.rs and populated in target.rs via current_flow_narrow_exhaustive_key(). The problem is that this mechanism is too aggressive — it's marking fn (which is str after narrowing from str | None) as Never because there happen to be active flow narrows on d (the function parameter). The narrow on d (e.g., isinstance(d, MutableMapping)) doesn't make the branch unreachable — it's the active branch! But the code in current_flow_narrow_exhaustive_key() collects ALL active narrows in the current flow without checking whether they actually make the branch impossible. The fn variable at line 213 is being assigned inside the if isinstance(d, MutableMapping) branch, which is reachable. The is_new_binding_in_flow check in target.rs triggers because fn was already assigned earlier (line 183) but the flow tracking considers it a new binding in this particular flow context.

prefect (-1)

This error arises because _job_name is declared as _job_name: str = PrivateAttr(default=None) on line 159. The type annotation says str, but the default value is None (via Pydantic's PrivateAttr). On line 181, if self._job_name is None: narrows the str-typed attribute. Since str cannot be None, the narrowed type inside the branch becomes Never — this is actually a logically correct narrowing result given the declared type. On line 191, assigning str to an attribute narrowed to Never triggers the bad-assignment error. The root cause is that Pydantic's PrivateAttr(default=None) allows a runtime None value despite the str annotation — the annotation should arguably be Optional[str] to be type-correct. However, this is a very common Pydantic pattern, and the PR's fix to how Never types are handled in flow narrowing resolved this by not producing errors in such narrowing scenarios, making this a false positive that was correctly eliminated.
Attribution: The changes to pyrefly/lib/alt/solve.rs in binding_to_type_info_name_assign() added logic to check flow_narrow_exhaustive and collapse types to Never only when the flow narrows make a branch truly impossible. The new is_never() guard and the flow_narrow_exhaustive field (added in pyrefly/lib/binding/binding.rs and pyrefly/lib/binding/target.rs) ensure that Never is only propagated for genuinely unreachable branches, not for valid narrowing contexts like this one.

➖ Neutral (3)

pyodide (+1, -1)

Same errors at same locations with same error kinds — message wording changed, no behavioral impact.

sphinx (+1, -2)

New Never inference false positive: The new error on document.settings.env with type Never is a false positive. The code at line 103 assigns self.env (typed BuildEnvironment) to document.settings.env, a dynamically-set attribute on a docutils settings object. The Never type inference is incorrect — this is a known pattern in Sphinx where settings attributes are set dynamically (see line 92-93 which does the same with a hasattr check).
Removed false positives: The removed error on figure_node.line = caption.line (patches.py:54) was a false positive — docutils Node.line is typed as int | None, and the old error incorrectly had line typed as Never. The removed error on ASTTrailingTypeSpecName (cpp/_parser.py:1145) involved Literal['class', 'enum', 'struct', 'typename', 'union'] | None passed to parameter prefix typed as str. While the Literal values are valid subtypes of str, the None component would be invalid if the parameter only accepts str. Without seeing the __init__ signature, this removal is ambiguous — it could be a genuine error about None rather than a false positive.

Overall: Net effect is mixed. Two errors removed and one new error introduced. The new error on document.settings.env with type Never is a false positive — the code at line 103 clearly assigns a BuildEnvironment to a dynamically-set attribute on a docutils settings object, and the Never inference is incorrect. The removed error on figure_node.line (patches.py:54) was a false positive where line was incorrectly narrowed to Never — docutils nodes have line typed as int | None, so the assignment is valid. The removed error on ASTTrailingTypeSpecName (cpp/_parser.py:1145) involved passing Literal['class', 'enum', 'struct', 'typename', 'union'] | None to a parameter typed as str. The Literal values are subtypes of str, but the None component could be a genuine issue if the parameter doesn't accept None. Without seeing the __init__ signature, this removal may or may not be correct. On balance, at least one clear false positive removed and one clear false positive introduced, with one ambiguous removal.

Attribution: The change to binding_to_type_info_name_assign() in pyrefly/lib/alt/solve.rs adds flow_narrow_exhaustive logic that collapses types to Never in unreachable branches. The new current_flow_narrow_exhaustive_key() in pyrefly/lib/binding/bindings.rs collects active narrows. This correctly fixes the two removed false positives but introduces a new false positive where document.settings.env is incorrectly inferred as Never — likely because the exhaustive narrowing logic doesn't properly handle dynamically-set attributes on external (docutils) objects.

pydantic (+4, -4)

Same errors at same locations with same error kinds — message wording changed, no behavioral impact.

Suggested fixes

Summary: The new flow_narrow_exhaustive mechanism in current_flow_narrow_exhaustive_key() over-aggressively collects ALL active flow narrows and applies exhaustive Never-collapsing to any new binding in the flow, causing ~40+ false positive Never-inference errors across 10+ projects.

1. In current_flow_narrow_exhaustive_key() in pyrefly/lib/binding/bindings.rs, the function collects ALL active flow narrow indices via current_flow_narrow_idxs() and creates an Exhaustive binding from them. This is too broad — it picks up narrows from unrelated conditions (e.g., isinstance checks on different variables, assertions on different objects, TYPE_CHECKING guards). The fix should only create the exhaustive key when the narrow entries are directly relevant to the current assignment's reachability. Specifically, the function should filter narrow_entries to only include narrows that actually guard the current code path (i.e., narrows whose scope/branch directly contains the assignment point), rather than collecting every active narrow in the entire flow. As a simpler alternative fix: in bind_target_name() in target.rs, the condition is_new_binding_in_flow (line 524: self.scopes.current_flow_idx(&name.id).[is_none()](https://github.com/facebook/pyrefly/blob/main/pyrefly/lib/binding/bindings.rs)) is too permissive — a variable being 'new in the flow' doesn't mean it's in an unreachable branch. The exhaustive key should only be attached when the assignment is inside a branch that was created by a narrowing condition (e.g., inside an if/elif/else block where the condition involves a narrow), not for any assignment that happens to occur while narrows are active in the flow. One concrete approach: only set flow_narrow_exhaustive when the current scope is inside a conditional branch (if/elif/else/match) whose condition created the active narrows, rather than for any new binding anywhere in the flow.

Files: pyrefly/lib/binding/bindings.rs, pyrefly/lib/binding/target.rs
Confidence: high
Affected projects: cloud-init, mitmproxy, async-utils, build, zulip, static-frame, urllib3, apprise, pip, meson, sphinx, schema_salad, pandas, pandera
Fixes: not-a-type, bad-assignment, bad-argument-type
The test case test_assigned_name_in_never_branch shows the intended behavior: inside if some != 'a': where some: Literal['a'], the branch is genuinely unreachable and assigned = 'b' should be Never. But the current implementation applies this logic to ANY new variable when ANY narrows are active in the flow, not just when the variable is inside a branch made unreachable by those narrows. This causes false positives in: cloud-init (mock callback function), mitmproxy (normal test code), async-utils (TYPE_CHECKING else branch), build (module-level type aliases), zulip (test method bodies), static-frame (unreachable cascade), and others. All ~40+ pyrefly-only errors across these projects would be eliminated by properly scoping when flow_narrow_exhaustive is applied.

2. In current_flow_narrow_idxs() in pyrefly/lib/binding/scope.rs, the method iterates over ALL entries in the current flow's info map and collects every narrow index. This means narrows on completely unrelated variables (e.g., a narrow on variable x from an isinstance check) get included when computing exhaustiveness for an assignment to variable y. The fix should be to either: (1) only collect narrows that are on variables that appear in the condition guarding the current branch, or (2) track which narrows were introduced by the current branch's condition and only use those. The current implementation self.current().flow.info.iter().filter_map(|(_, info)| info.narrow.[as_ref()](https://github.com/facebook/pyrefly/blob/main/pyrefly/lib/binding/scope.rs).map(|narrow| narrow.idx)).collect() has no filtering by relevance whatsoever.

Files: pyrefly/lib/binding/scope.rs
Confidence: high
Affected projects: cloud-init, mitmproxy, async-utils, build, zulip, static-frame, urllib3, apprise, pip, meson, sphinx, schema_salad, pandas, pandera
Fixes: not-a-type, bad-assignment, bad-argument-type
This is the root data source for the over-broad exhaustive check. In mitmproxy's test, narrows from unrelated conditions cause mod.request.method to be typed as Never. In cloud-init, narrows from the outer test method leak into a nested callback function. In async-utils, the TYPE_CHECKING narrow causes all definitions in the else branch to become Never. Fixing the data source would fix all downstream consumers. Expected outcome: eliminates all ~40+ pyrefly-only Never-inference false positives across the affected projects.


Was this helpful? React with 👍 or 👎

Classification by primer-classifier (2 heuristic, 24 LLM)

@asukaminato0721 asukaminato0721 marked this pull request as ready for review April 23, 2026 09:22
Copilot AI review requested due to automatic review settings April 23, 2026 09:22
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Fixes pyrefly’s handling of unreachable branches caused by flow narrowing so that names assigned inside an impossible branch can be treated as Never (aligning reveal_type / assert_never behavior with expectations from #3202).

Changes:

  • Added a regression test covering an assignment in an unreachable (Never) branch.
  • Threaded a new flow_narrow_exhaustive key through NameAssign so the solver can collapse the assigned name’s type to Never when the active narrows make the branch impossible.
  • Added scope helpers to collect active narrow bindings in the current flow and build an Exhaustive binding from them.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
pyrefly/lib/test/narrow.rs Adds a regression test asserting assigned reveals as Never in an unreachable branch.
pyrefly/lib/binding/target.rs Computes and stores a flow_narrow_exhaustive key on NameAssign bindings.
pyrefly/lib/binding/scope.rs Exposes current-flow narrow indices to support reachability/exhaustiveness checks.
pyrefly/lib/binding/bindings.rs Builds an Exhaustive binding from current-flow narrows for later solve-time checks.
pyrefly/lib/binding/binding.rs Extends NameAssign to carry the optional flow_narrow_exhaustive key.
pyrefly/lib/alt/solve.rs Uses flow_narrow_exhaustive to collapse assigned-name type to Never (while still evaluating RHS for errors).

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +564 to +568
flow_narrow_exhaustive: if is_new_binding_in_flow {
self.current_flow_narrow_exhaustive_key(name.range)
} else {
None
},
Copy link

Copilot AI Apr 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

flow_narrow_exhaustive is only set on the first value-binding in the current flow (is_new_binding_in_flow). In an impossible/narrowed-to-Never branch, a second assignment to the same name (still unreachable) will skip this and can “escape” back to a non-Never type. Consider attaching the exhaustiveness key for all NameAssigns that occur under active narrows (or otherwise keying off reachability), not just the first binding in the flow, so subsequent reassignments in unreachable code also collapse to Never.

Suggested change
flow_narrow_exhaustive: if is_new_binding_in_flow {
self.current_flow_narrow_exhaustive_key(name.range)
} else {
None
},
flow_narrow_exhaustive: self.current_flow_narrow_exhaustive_key(name.range),

Copilot uses AI. Check for mistakes.
Comment on lines +2062 to +2068
pub fn current_flow_narrow_idxs(&self) -> Vec<Idx<Key>> {
self.current()
.flow
.info
.iter()
.filter_map(|(_, info)| info.narrow.as_ref().map(|narrow| narrow.idx))
.collect()
Copy link

Copilot AI Apr 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

current_flow_narrow_idxs allocates a Vec and iterates over the entire flow.info map. Since this is used from assignment binding logic, it may be on hot paths in large functions. If performance becomes an issue, consider returning an iterator, or tracking active narrow idxs incrementally in Flow to avoid repeatedly scanning all names.

Suggested change
pub fn current_flow_narrow_idxs(&self) -> Vec<Idx<Key>> {
self.current()
.flow
.info
.iter()
.filter_map(|(_, info)| info.narrow.as_ref().map(|narrow| narrow.idx))
.collect()
pub fn current_flow_narrow_idxs(&self) -> impl Iterator<Item = Idx<Key>> + '_ {
self.current()
.flow
.info
.values()
.filter_map(|info| info.narrow.as_ref().map(|narrow| narrow.idx))

Copilot uses AI. Check for mistakes.
@kinto0 kinto0 self-assigned this Apr 24, 2026
@meta-codesync
Copy link
Copy Markdown
Contributor

meta-codesync Bot commented Apr 24, 2026

@kinto0 has imported this pull request. If you are a Meta employee, you can view this in D102371205.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Pyrefly doesn't reveal type as Never in unreachable code

3 participants