Docker builds can reuse an older rustup installer layer. Pin rustup to
[1.29.2](https://blog.rust-lang.org/2026/10/20/Rustup-1.29.2/) so the
builder uses a release that blocks the compromised eMudhra intermediate
certificates. The Rust toolchain version is unchanged.
Run the Docker image build for pull requests that change the Dockerfile.
The playground's locked `fflate` 0.8.2 has a [security
advisory](https://github.com/advisories/GHSA-px8p-9vwx-vf98). Update it
to 0.8.3, which is outside the affected range and allowed by the
playground's declared dependency range.
Version 0.8.3 was [published on May 16,
2026](https://github.com/101arrowz/fflate/releases/tag/v0.8.3). The
advisory was published on July 22, 2026 and reviewed by GitHub on
September 3, when [Ruff's Dependabot
alert](https://github.com/astral-sh/ruff/security/dependabot/235) was
created. The fix was therefore available when the alert was created.
Ruff's Renovate configuration enables vulnerability alerts, but Codex
could not confirm why Renovate did not open a security update. An [open
Renovate
discussion](https://github.com/renovatebot/renovate/discussions/45331)
reports that Renovate can fail to read the locked version of an npm
workspace dependency when it is hoisted to the workspace root. The
playground declares `fflate` in `playground/shared/package.json` and
hoists it to the workspace root, with its locked entry under
`node_modules/fflate` in `playground/package-lock.json`. If the same
problem prevents Renovate from matching a GitHub alert to the locked
version, it might not open a security-update PR. Codex has not confirmed
whether this happened in Ruff; the linked discussion concerns OSV-based
alerts.
Related to #29518.
The playground's locked `ws` 8.18.2 has security advisories. Update this
transitive dependency to 8.22.0, which is outside the affected ranges.
Codex's analysis is that Renovate's missing [support for targeted
updates of transitive dependencies in
`package-lock.json`](https://github.com/renovatebot/renovate/issues/46379)
may explain why it did not open a security update for the locked `ws`
version.
Split from #29518.
The playground's locked `picomatch` 2.3.1 has a security advisory.
Update it to 2.3.2, which is outside the affected range. Updating
`picomatch` also refreshes the other locked version from 4.0.4 to 4.0.7.
Codex's analysis is that Renovate's missing [support for targeted
updates of transitive dependencies in
`package-lock.json`](https://github.com/renovatebot/renovate/issues/46379)
may explain why it did not open a security update for the vulnerable
`picomatch` version.
Split from #29518.
The playground's locked `tar` 7.5.15 has security advisories. Update
this transitive dependency to 7.5.22, which is outside the affected
ranges. Regenerating the lockfile also moves `tar` and several of its
dependencies to top-level `node_modules` entries.
Codex's analysis is that Renovate's missing [support for targeted
updates of transitive dependencies in
`package-lock.json`](https://github.com/renovatebot/renovate/issues/46379)
may explain why it did not open a security update for the locked `tar`
version.
Split from #29518.
The playground's locked `source-map-js` 1.2.1 has a [security
advisory](https://github.com/advisories/GHSA-68fv-2mgg-jv7q). Update
this transitive dependency to 1.2.2, which is outside the affected
range.
Codex's analysis is that Renovate's missing [support for targeted
updates of transitive dependencies in
`package-lock.json`](https://github.com/renovatebot/renovate/issues/46379)
may explain why it did not open a security update for the locked
`source-map-js` version.
Split from #29518.
The playground API lockfile contains vulnerable versions of `sharp` and
`undici` through Wrangler and directly declared Miniflare packages. The
older Miniflare packages also bring in vulnerable `picomatch` and
`cross-spawn` versions. Remove the unused direct Miniflare packages and
update Wrangler within its existing version range. This removes those
`picomatch` and `cross-spawn` copies and updates `sharp` and `undici` to
fixed versions.
Change the API's deploy script to `wrangler deploy` because the updated
Wrangler no longer supports `wrangler publish`.
Codex's analysis is that Renovate's missing [support for targeted
updates of transitive dependencies in
`package-lock.json`](https://github.com/renovatebot/renovate/issues/46379)
may explain why it did not open security updates for the vulnerable
transitive dependencies.
Split from #29518.
Exempt the Astral-maintained uv package from the seven-day dependency
cooldown, replacing the temporary uv cutoff added in #29551. This allows
lockfile updates to select new uv releases without waiting seven days.
Codex prepared this update.
The [eMudhra incident](https://emudhra.we-have-a-problem.com/)
compromised intermediate CA keys that could be used to impersonate HTTPS
sites. Go 1.27.3 rejects certificate chains containing the corresponding
intermediate public keys.
Update the Go version used by the ShellCheck CI job to 1.27.3. The job
uses Go to download and run go-shellcheck.
Codex prepared this update.
The [eMudhra incident](https://emudhra.we-have-a-problem.com/)
compromised intermediate CA keys that could be used to impersonate HTTPS
sites. uv 0.13.4 removes the emSign root that anchors the affected
intermediates from its bundled trust store.
Update the uv versions used in CI and by the uv-lock hook, and the
locked uv dependency. A uv-specific cutoff allows the updated release
through the seven-day dependency waiting period.
Codex prepared this update.
Rename the ty review skill from `review-ty-pr` to `review-ty-changes` to
reflect its coverage of local changes. Update the skill's directory,
metadata name, and default prompt.
Stacked on #29509.
`review-ty-pr` currently assumes every review targets a pull request.
Extend it to cover local changes, including staged and unstaged edits,
branches compared with their base, and changes just written by a user or
an agent.
To avoid executing untrusted code on the host, require untrusted code
and code of uncertain trust to be vetted and run offline in a disposable
container. Allow host testing when the checkout, dependencies, inputs,
and commands are trusted; if vetting or isolation is unavailable, the
affected review work remains static.
The skill also clarifies how to assess typing-spec conformance and
ecosystem impacts, judge whether tests are necessary, and handle
test-generated snapshot updates.
Our `py-fuzzer` script currently limits each failing seed to have a
maximum of 60 seconds for minimization, even when the CI job has time
remaining. For example, in [this fuzz job on
#29025](https://github.com/astral-sh/ruff/actions/runs/37632264340/job/112829497328),
minimization timed out for seed 57 even though there would have been
another 15 minutes before GitHub cancelled the CI job. Running the
script locally demonstrated that pysource_minimize was [easily
able](https://github.com/astral-sh/ruff/pull/29025#issuecomment-6064567417)
to minimize the panic if only given slightly more time to do so.
This PR replaces the per-seed limit in CI with a global, shared budget
for checking seeds and minimizing failures. Local runs have no overall
budget by default. CI, however, records the job start time in its first
step and passes it and the job timeout to the fuzzer, which subtracts
elapsed setup and build time and reserves an additional 90 seconds for
other job overhead and reporting. Individual calls to ty or Ruff still
have a five-second timeout, allowing us to minimize hangs or
pathological performance issues.
By default, the fuzzer reports failing seeds as they are found and
prints completed minimizations. When the deadline is reached, it stops
starting new seed and minimizer checks, reports the original source for
unfinished minimizations, and identifies unchecked seeds. Checks already
in progress can finish after the deadline. The logs and, when requested,
the GitHub issue body include a command to rerun failing and unchecked
seeds; the script exits with status 2 if any seeds were left unchecked.
Here are some mockups of what bot-opened issues could look like, using
fictional data:
<details>
<summary>Issue with minimized, unminimized, and unchecked
seeds</summary>
Run listed here: https://github.com/astral-sh/ty/actions/runs/1234567890
Each snippet caused ty to exit with a status other than 0, 1, or 2, or
time out after five seconds during fuzzing.
Build ty from commit `0123456789abcdef0123456789abcdef01234567` with
`cargo build --locked --bin=ty`. Save a snippet to `repro.py`, then run:
```
target/debug/ty check repro.py --python-version=3.10 --python-platform=linux
```
### Seed: 78
```python
class Example:
pass
```
<details>
<summary>Seed: 93 (original source; minimization did not
complete)</summary>
```python
class Original:
value = [1, 2, 3]
def method(self):
return self.value
```
</details>
### Unchecked seeds
The time budget expired before 2 seeds could be checked.
- `123`
- `124`
To check all seeds that failed or were left unchecked, check out Ruff
commit `0123456789abcdef0123456789abcdef01234567` and run from the
repository root:
```shell
uv run --project=./python/py-fuzzer fuzz --bin=ty 78 93 123 124
```
</details>
<details>
<summary>Issue with unchecked seeds and no failures</summary>
Run listed here:
https://github.com/astral-sh/ruff/actions/runs/1234567890
Test executable: ruff at commit
`0123456789abcdef0123456789abcdef01234567`.
### Unchecked seeds
The time budget expired before 3 seeds could be checked.
- `10`
- `11`
- `12`
To check all seeds that failed or were left unchecked, check out Ruff
commit `0123456789abcdef0123456789abcdef01234567` and run from the
repository root:
```shell
uv run --project=./python/py-fuzzer fuzz --bin=ruff 10 11 12
```
</details>
## Rejected alternatives
1. Keep a per-minimization budget, but just increase it (by a lot?).
We could do this, and it fixes the theoretical problem that codex raised
where a couple of seeds that take a very long time to minimize stop the
script from even discovering bugs in later seeds (because it never even
gets to those later seeds).
I decided against this approach, however, because in practice I think it
will only be hangs that take a _long_ time to minimize. We've seen in
the past that a ty hang can cause the script as a whole to timeout in
CI, but whenever it's done so in the past, it's never caused the script
to fail to get onto non-hanging seeds. It's always been the case that
the script has completed all seeds except the problematic ones that were
causing it to hang. So I decided that per-minimization budgets were a
solution looking for a problem.
2. Get rid of all budgets altogether, go back to something more similar
to what the script was like prior to
https://github.com/astral-sh/ruff/pull/29045.
This is very attractive, because it avoids a lot of the new complexity
added in this PR to do with calculating budgets, interrupting
subprocesses that have already been started, etc.
I decided against this, however, because I do think the fact that the
script now minimizes hangs substantially increases the risk that the
script might finish without having finished minimizing all seeds. In the
event that this happens, I _do_ think it's quite useful (though not
essential) for the script to report which seeds it didn't get to, before
GitHub cancels the CI job altogether.
## Summary
Ruff uses `phf_codegen` to generate a Unicode alias map and `phf` to
look up values in it. The generated map must be compatible with the
lookup implementation; updating `phf` alone can make lookups fail. For
example, [#29362](https://github.com/astral-sh/ruff/pull/29362) fails
Unicode alias tests.
Group Renovate updates to both crates when both are available so they
can be reviewed together, following the existing rule for `strum` and
`strum_macros`. Renovate can still propose an update to one crate on its
own if the other update is unavailable.
After [#29364](https://github.com/astral-sh/ruff/pull/29364) upgraded uv
to 0.13.1, the [`cargo test (linux)` job on
`main`](https://github.com/astral-sh/ruff/actions/runs/38555989744/job/115825271718)
failed. uv 0.13 defaults to downloading Python 3.15 when no version is
requested and no compatible interpreter is already installed. In the
failing job, `Run tests` used Python 3.15, and the later py-fuzzer
dogfooding step failed to install locked MarkupSafe because the pinned
version of MarkupSafe doesn't have published wheels for 3.15, but source
builds are disabled by py-fuzzer's `pyproject.toml` file:
https://github.com/astral-sh/ruff/blob/9ff6b0f0d27dcbac5a1e6447a336ad8562720f3c/python/py-fuzzer/pyproject.toml#L100-L101
In the [corresponding PR
job](https://github.com/astral-sh/ruff/actions/runs/38553508773/job/115817479045),
the preceding "annotation mdtests" step ran, and `Run tests` used Python
3.13. The mdtests can install a Python version that uv later reuses,
which may explain why the py-fuzzer step passed on the PR. The
annotation mdtests are skipped on pushes to `main`.
This PR fixes the issue by setting `PYTHON_VERSION` (currently 3.14)
explicitly for py-fuzzer's dogfooding, lint, format, and type checks. We
set `UV_PYTHON` for the scripts dogfooding step too, so that both its uv
command and the uv subprocesses ty starts for script dependencies use
the configured version. This also avoids selecting Python 3.15 for
locked script dependencies that lack wheels for it.
The mdtest README's snapshot example uses an assertion that cannot match
its diagnostic, and its displayed output does not correspond to the
code. Its introductory example also fails to parse as a standalone
mdtest.
Correct the examples and explain how to generate inline snapshots.
Clarify that the proposed full-output syntax would work without the
inline assertions used by the existing snapshot feature.
Black 26.10.0’s preview style adds missing blank lines after stub
functions whose bodies contain only a docstring, without inserting blank
lines within overload groups. Previously, this formatting behavior
required installing Black from Git. Switch typeshed formatting to the
released wheel so that Black no longer needs an exception to `no-build`,
and remove its unused build constraints.
Canonical equivalence can make distinct string values look the same. ty
escapes their displays to distinguish them, but the current approach
relies on ICU4X's deprecated `NfcInert` property, which [ICU4X plans to
remove](https://github.com/unicode-org/icu4x/issues/7892), and can
escape characters that do not need escaping.
Replace inertness checks with NFC checks on runs of unescaped text. When
adding a character would make a run non-NFC, escape that character and
continue checking after it. The resulting string literal is in NFC and
evaluates to the original value, so canonically equivalent values remain
distinguishable.
For example, `\u212b` (the Angstrom sign) is canonically equivalent to
`Å`. ty now keeps the unrelated `é` visible while still distinguishing
the two values:
```py
reveal_type("éÅ")
# main: Literal["éÅ"]
# this PR: Literal["éÅ"]
reveal_type("é\u212b")
# main: Literal["\xe9\u212b"]
# this PR: Literal["é\u212b"]
```
The change also uses Unicode grapheme-break properties to keep
characters visually separate from the quotes and escape sequences around
them. It escapes the braille blank because it can look like an ordinary
space.
ty's uv integration tests clear the inherited environment before passing
a small set of variables to uv subprocesses.
Set `UV_EXCLUDE_NEWER` to `2026-09-22T00:00:00Z` for these tests,
limiting package and build-dependency resolution to distribution
artifacts uploaded before that time. This keeps newly uploaded artifacts
from changing which packages the tests can resolve.
Move snapshot generation into a job that uploads its changes as an
artifact. The PR creation job then commits and pushes those changes
before creating the PR. If snapshot generation fails or times out, the
workflow still creates a PR with the other changes, provided the
required sync steps succeeded.
Use GitHub-hosted runners and disable shared caching for the workflow.
## Summary
This addresses @sharkdp's feedback in
https://github.com/astral-sh/ruff/pull/28521#discussion_r4013438765.
Before:
<details>
<summary>Screenshot</summary>
<img width="1178" height="246" alt="image"
src="https://github.com/user-attachments/assets/fd06566d-b938-42bb-8af7-491feba5e1a8"
/>
</details>
On this PR:
<details>
<summary>Screenshot</summary>
<img width="1318" height="254" alt="image"
src="https://github.com/user-attachments/assets/10cac243-6de4-4d60-a7ee-ea79aec1e8a2"
/>
</details>
I'm still not _totally_ sold that this is better. I can see that the
reader's eye is naturally drawn towards the coloured text in the primary
annotation, but this change also makes our primary annotations for these
rules longer, and screen width is a scarce commodity. It also duplicates
information that's already presented in the diagnostic summary line. I'm
curious for what other folks think here!
## Test Plan
Inline snapshots.
## Summary
When a `redundant-condition` diagnostic points to an operand of `and` or
`or`, that operand can still determine the reachability of the enclosing
test. For example, `if flag or nonempty:` with `nonempty: tuple[str,
str]` always enters the `if` body, so its `else` branch is unreachable.
Trace the reported operand through `and`, `or`, and `not`, treating
other operands as unknown. Add an unreachable-code annotation only when
that operand alone determines the enclosing test's truthiness. This
covers negated conditions, `if`/`elif`, loops, `match` guards, and
assertions without attributing a result that requires several operands
to any one of them.
Clarify the mdtest guidance so short independent definitions can be
repeated to keep inline snapshots close to their examples. (Codex tried
to tell me off about "repeated setup" when it reviewed my tests here.)
Stacked on #28290.
## Summary
If we see that a `while` test is always true, we now annotate any
nontrivial statements following the `while` loop as unreachable.
Similarly, if we see it is always false, we now annotate the `while`
body as unreachable.
If we see that an `assert` statement is always `False`, we annotate any
nontrivial statements below it as unreachable.
And if we see that a `match` guard is always `False`, we annotate that
branch as always being unreachable.
## Test Plan
Inline snapshots
Adds secondary annotations to `redundant-condition` and
`redundant-condition-strict` diagnostics to show when an always-truthy
or always-falsy `if`/`elif` condition makes a branch or following
statement unreachable. Annotations stop at the first line of the first
nontrivial statement, keeping multiline bodies readable.
For exhaustive chains whose branches all exit, suppress the
`assert_never` or defensive-assertion fix when the diagnostic identifies
unreachable code after the chain. Only attach reachability annotations
when the diagnostic covers the complete condition.
<details><summary>Screenshot</summary>
<img width="1182" height="422" alt="image"
src="https://github.com/user-attachments/assets/533851c3-0fd7-4e40-9293-a974fe288f8c"
/>
</details>
<details><summary>Screenshot</summary>
<img width="1204" height="434" alt="image"
src="https://github.com/user-attachments/assets/69585174-f863-4465-8215-3a0feb2ca424"
/>
</details>
<details><summary>Screenshot</summary>
<img width="1254" height="324" alt="image"
src="https://github.com/user-attachments/assets/bc3a79c3-ff0c-41ce-b692-a328205f26a1"
/>
</details>
## Summary
Repeated statement-call reachability checks reconstruct the `(callable,
call_expr, is_await)` key for `analyze_non_terminal_call`. Intern that
information when constructing the predicate and pass its ID directly to
the tracked query, avoiding repeated argument interning. The same fields
continue to identify each call, including awaited calls.
## Performance
Median CPU time across four runs of profiling CLI builds on macOS arm64,
using the existing microbenchmark inputs:
| Benchmark | Before | After | Reduction |
| --- | ---: | ---: | ---: |
| `repeated_statement_calls` | 29.7 ms | 28.0 ms | 5.8% |
| `repeated_statement_calls_pre_narrowed` | 30.4 ms | 28.4 ms | 6.6% |
| `repeated_statement_calls_fixed_reachability` | 29.5 ms | 27.1 ms |
8.1% |
| `repeated_statement_calls_in_try_with_if_branches` | 82.0 ms | 73.6 ms
| 10.3% |
| `repeated_suppressing_context_managers_interleaved_calls` | 68.7 ms |
59.5 ms | 13.4% |
For `repeated_statement_calls`, the retained-memory report falls from
10.36 MB to 8.84 MB (14.6%).
## Summary
Add unsafe fixes to diagnostics that suggest `Literal`: wrap integer,
bytes, and boolean literals and strings that cannot be parsed as forward
annotations in `Literal[...]`, and replace parameterized `LiteralString`
with `Literal` when its arguments are valid literal parameters.
The fixes preserve literal spelling and reuse unshadowed import aliases.
They import from `typing` on Python 3.8+, or from `typing_extensions` on
Python 3.7 when it is a declared direct dependency and the installed
module exports `Literal`. The typing-module availability checks are
shared with the `reveal_type` and `assert_never` fixes.
When the final `elif` condition is always true but ty cannot add an
`assert_never` call, offer an unsafe fix for
`redundant-condition-strict` that replaces the `elif` with `else` and
moves the condition into a defensive assertion.
For example, on Python 3.10 in a project without a direct dependency on
`typing_extensions`, the fix makes this change:
```diff
def handle(value: str | int):
if isinstance(value, str):
print(value)
- elif isinstance(value, int):
+ else:
+ assert isinstance(value, int)
print(value)
```
After the `str` branch, `value` must be an `int`, so the final
`isinstance` check is statically always true. The assertion retains that
check at runtime while making the exhaustive branching explicit.
The fallback also applies when the tested value cannot be reused as an
`assert_never` argument or import-name conflicts prevent a usable
import. It preserves condition parentheses, comments, indentation, and
line endings, and parenthesizes bare assignment expressions. No edit is
offered when the body cannot accommodate an indented assertion or
rewriting the header would discard a comment.
The fix is unsafe because an incorrect static assumption can make the
assertion fail at runtime.
Stacked on #28178.
When the final `elif` condition is always true, offer an unsafe fix for
`redundant-condition-strict` that adds an `else` branch calling
`assert_never`. This makes the code explicit that it intends to be
exhaustive.
The argument to `assert_never` is a plain variable that narrows to
`Never` when the condition is false. The existing condition and body are
preserved.
**Python 3.11+: add a new import.** After the `str` branch, `value` must
be an `int`, so the final condition is statically always true. The fix
adds an explicit exhaustiveness check:
```diff
+from typing import assert_never
def handle(value: str | int):
if isinstance(value, str):
print(value)
elif isinstance(value, int):
print(value)
+ else:
+ assert_never(value)
```
**Reuse an existing runtime import.** An unshadowed alias can be used
without adding another import:
```diff
from typing import assert_never as unreachable
def handle(value: str | int):
if isinstance(value, str):
print(value)
elif isinstance(value, int):
print(value)
+ else:
+ unreachable(value)
```
An existing qualified import such as `import typing as t` can similarly
supply `t.assert_never(value)`.
**Avoid a shadowed function name.** If `assert_never` already names a
parameter, the fix uses a qualified reference instead:
```diff
+import typing
def handle(value: str | int, assert_never: int):
if isinstance(value, str):
print(value)
elif isinstance(value, int):
print(value, assert_never)
+ else:
+ typing.assert_never(value)
```
**Python 3.10: use an available backport.** When uv dependency metadata
identifies `typing_extensions` as a direct dependency available to this
file, and the installed runtime module exports `assert_never`, the fix
imports from the backport:
```diff
+from typing_extensions import assert_never
def handle(value: str | int):
if isinstance(value, str):
print(value)
elif isinstance(value, int):
print(value)
+ else:
+ assert_never(value)
```
No fix is offered if there is no suitable plain variable, if assignment
expressions occur in the condition, or if neither an unqualified nor a
qualified reference can avoid existing bindings. On older Python
versions, missing dependency metadata, an indirect dependency, a missing
runtime module, or a backport without `assert_never` also prevent the
fix. The new branch raises if the static assumptions fail at runtime, so
the fix is unsafe.
The shared importer checks bindings without re-entering type inference
and only reuses runtime imports that have not been reassigned or
deleted.
Stacked on #28177.
On Python versions before 3.11, the `undefined-reveal` fix currently
offers to import `reveal_type` from `typing_extensions` even when the
project does not declare the backport. Only offer that fix when uv
dependency metadata identifies it as a direct dependency available to
the importing file. The Python 3.11+ fix continues to import from
`typing`.
Introduce a positive dependency query that shares project selection and
runtime module ownership with `missing-direct-dependency`. It respects
nested projects and dependency groups, and does not mistake a local
module shadowing `typing_extensions` for the declared distribution.
Missing metadata does not establish a dependency.
Stacked on #28170.
Future autofixes will need to introduce imports alongside code changes.
In particular, there are cases where inserting `assert_never(...)` would
be useful, but the fix often also needs to import `assert_never`. The
importer currently lives in `ty_ide`, so diagnostics emitted by
`ty_python_semantic` cannot use it.
Move the importer into `ty_python_semantic` and reuse it from
completions and inlay hints. As its first use in a diagnostic, attach
the `reveal_type` import fix directly to `undefined-reveal`, replacing
the custom LSP handling. The fix imports from `typing` on Python 3.11+
and `typing_extensions` on older Python versions.
Cache source-style detection and top-level import discovery in Salsa per
file, so diagnostics in separate inference regions and IDE features can
share the setup. The cached data does not retain the parsed AST or a
copy of the source text.
Stacked on #28169.
`redundant-condition` already catches conditions that test a coroutine
object instead of its result. Add an unsafe fix that awaits the
expression, inserting parentheses when needed to preserve precedence.
For an uncalled function or method known to return a coroutine, extend
the existing call fix to call and await it.
Only suggest `await` where it is syntactically valid, accounting for
annotations, nested scopes, comprehensions, Python-version differences,
and top-level await in notebooks. A return type of `Any`, `Unknown`, or
`Never` does not by itself justify adding `await`.
Stacked on #28168.
`tuple[T]` describes a tuple with exactly one element, so its instances
are always truthy. When `redundant-condition` flags such a value, the
intended annotation may instead be `tuple[T, ...]`.
This adds a hint pointing to the parameter or variable annotation that
establishes the one-element type and suggests the arbitrary-length form.
Tuple subclasses are excluded, replacement suggestions that cannot be
expressed in Python annotation syntax are omitted, and hints for
dependency annotations refer to the dependency's author.
Stacked on #28034.
## Summary
Builds on #28032.
Add two rules for boolean tests whose truthiness is statically known:
- `redundant-condition`, enabled by default at warning severity, catches
cases such as an uncalled function or method, an unawaited coroutine, a
generator tested for emptiness, or a tuple whose length makes its
truthiness constant.
- `redundant-condition-strict`, disabled by default, covers
boolean/integer conditions, conditions whose outcome is fixed only by
short-circuit evaluation, and conditions containing walrus expressions.
These can expose real bugs, but are also much more likely to be
intentional.
The checks reuse ty's truthiness inference and apply to `if`/`elif`,
`while`, assertions, match guards, conditional expressions,
comprehension filters, and unary `not`. The operands of `and`/`or` are
checked when the expression is used as a condition. The main design
question is which statically redundant tests are likely to represent
mistakes. The implementation deliberately prefers missing some redundant
conditions to warning about common defensive or compatibility patterns.
## Avoiding false positives
### Separate likely mistakes from more opinionated checks
Types assignable to `int`, including `bool`, use the strict rule. So do
conditions whose outcome is fixed by short-circuit evaluation even
though their inferred value type has ambiguous truthiness, and tests
containing walrus expressions. Other values with fixed truthiness use
the default rule.
This keeps checks of ordinary boolean/integer flags quiet by default,
because these are extremely common and it is difficult to distinguish
deliberately constant flags from mistakes:
```python
DEBUG = False
if DEBUG: # Only reported by redundant-condition-strict.
print("debugging")
```
The strict rule is a sibling of the default rule: it handles a separate
set of expressions rather than reporting the same expressions more
aggressively.
### Distinguish a condition's outcome from its value type
Short-circuit evaluation can establish a fixed outcome even when the
expression's value type does not:
```python
def check(value: object):
if value and False: # Always false; reported by redundant-condition-strict.
print("unreachable")
```
The condition fails whether `value` is truthy or falsy. However, saving
`value and False` to a variable and testing that variable later is
different: the saved value could be the original object, whose
`__bool__` result may change between calls. The redundant-condition
checks use short-circuit truthiness APIs introduced in
https://github.com/astral-sh/ruff/pull/28082 for direct conditions, and
value-type truthiness for tests of computed values. This also handles
chained comparisons whose intermediate results are objects with mutable
truthiness.
### Recognize explicitly constant tests
Both rules exempt direct boolean, integer and `None` literals in the
AST, such as `if False:`, `if 0:`, `while True:`, `while 1:`, and
`assert None`. These spellings are strong evidence that the constant
truthiness is deliberate. A name _inferred_ as `Literal[False]` is
different: its value may not be apparent where it is tested, so it
remains eligible for the strict rule.
### Treat walrus expressions as evidence of intentional side effects
An always-truthy operand can be useful because evaluating it assigns a
value:
```python
if should_continue() and (message := "continuing") and is_ready():
print(message)
```
The middle operand uses the strict rule, so it does not warn by default.
We does not attempt to classify arbitrary side effects. For example, `if
items.append(value) or fallback:` can still trigger the default rule
because `append` returns `None`.
An `and`/`or` expression used to compute a value (rather than one that
is used directly in an `if` test or similar), such as `result =
items.append(value) or fallback`, is not flagged by either rule.
### Follow the origins of environment-dependent conditions
Both rules exempt compatibility checks involving `sys.version_info`,
`sys.platform`, `os.name`, and `TYPE_CHECKING`. A condition being
constant for the configured environment does not make it redundant
across the environments supported by the project.
The exemption also follows names and attributes through their
definitions, including aliases and imports across modules:
```python
import sys
IS_WINDOWS = sys.platform == "win32"
LINE_PREFIX = "\n" if IS_WINDOWS else ""
if LINE_PREFIX: # Exempt even though its truthiness is known on this platform.
print(LINE_PREFIX)
```
The traversal follows assignment values, augmented assignments, walrus
bindings, loop and comprehension iterables, context-manager expressions,
and the subjects of captured `match` patterns.
This lookup is deliberately not flow-sensitive: if any resolved
definition depends on one of these symbols, the use is exempt. This is
so that we err on the side of avoiding false positives.
The shared definition-resolution machinery from #28032 allows this
traversal during inference without requesting completed inference of the
same scope. Name lookups are cached per scope and name; attribute
lookups are cached per receiver type and attribute name. Definition
traversal has its own Salsa query boundary and handles cyclic aliases,
so repeated conditions can share the provenance analysis.
### Preserve defensive assertions and exhaustive dispatch
Defensive boolean/integer assertions such as `assert isinstance(value,
int)` remain useful at runtime even when `value` is annotated as `int`,
since the runtime does not enforce type annotations. These are therefore
exempt from both rules.
Non-boolean mistakes such as `assert function` (where the user meant
`assert function()`) are still reported by the default rule. A complete
assertion whose value type has ambiguous truthiness but whose outcome is
fixed by short-circuit evaluation remains eligible for the strict rule.
For `if` and `elif` statements, the strict rule also recognizes
defensive exits. An always-false condition is exempt when its body ends
in a `raise`, an assertion that might fail, a call returning `Never`, or
a return assignable to `NotImplementedType`:
```python
def increment(value: int) -> int:
if not isinstance(value, int):
raise TypeError("expected an int")
return value + 1
```
For an always-true condition, the same heuristic examines an immediately
following `else` suite, or the remainder of the surrounding suite when
there are no later clauses. This accommodates exhaustive dispatch
followed by `assert_never`, and early-return dispatch followed by a
defensive exception.
These defensive-exit exemptions apply to boolean/integer conditions and
conditions requiring short-circuit analysis. An uncalled function is
still suspicious even when an otherwise unreachable branch raises.
### Check compound conditions once
In `result = flag and function`, Python does not test the truthiness of
the final operand: it simply returns that value if evaluation reaches
it. The rule therefore does not flag `function`, even though it is an
always-truthy object in a boolean expression. In `if flag and
predicate:`, the surrounding `if` does test the truthiness of
`predicate`, so the rule reports the uncalled function even though the
complete condition has ambiguous truthiness.
Unlike `and`/`or` expressions used to compute values, `not` explicitly
tests its operand's truthiness even when it used to computer a value, so
`not` expressions are checked even outside the context of
`if`/`while`/`elif`/`assert` tests.
For compound conditions, the redundant-condition checks visit operands
before deciding whether to report the complete expression. Reporting an
uncalled function in `if not function:` suppresses a second duplicate
diagnostic on the `not function` expression as a whole. Boolean/integer
operands and operands requiring short-circuit analysis are instead
suppressed so that the complete condition can be reported once. This
applies consistently to statement conditions, conditional expressions,
and comprehension filters.
Boolean tests inside value expressions, such as `if consume(not
function):`, are checked independently. Tests in nested scopes are
checked when those scopes are inferred.
Neither rule runs in stub files or files excluded from checking.
Diagnostic and provenance analysis is skipped entirely when both rules
are disabled.
## Diagnostics and fixes
Special-case diagnostics are provided to help explain some of the more
common errors we see in the ecosystem: an uncalled function or method
being always truthy, a fixed-length tuple being always truthy or always
falsy, a `TypedDict` always being truthy due to the presence of
`Required` fields, a `@final` class without `__bool__` or `__len__`
being always truthy, or an unevaluated generator being always truthy.
For simple function and method references, the diagnostic carries a call
fix. A parameterless signature gets an unsafe `()` fix; signatures with
parameters get a display-only `(...)` suggestion. Generator diagnostics
suggest `any()` and carry a display-only wrapping fix when the standard
`any` builtin is known not to be shadowed, including by a custom
`__builtins__.pyi`.
These fixes are deliberately not classified as safe: calling a function
or consuming a generator can change runtime behavior, and the intended
correction may be different.
### Default-rule diagnostics
<details>
<summary>Uncalled function</summary>

</details>
<details>
<summary>Uncalled method</summary>

</details>
<details>
<summary>Generator: suggest <code>any()</code></summary>

</details>
<details>
<summary>Tuple with a fixed length</summary>

</details>
<details>
<summary>Tuple with a minimum length</summary>

</details>
<details>
<summary>Empty tuple</summary>

</details>
<details>
<summary>Nonempty string literals</summary>

</details>
<details>
<summary>Empty string</summary>

</details>
<details>
<summary><code>None</code></summary>

</details>
<details>
<summary><code>TypedDict</code>: required fields and their
declarations</summary>

</details>
<details>
<summary>Final class without <code>__bool__</code> or
<code>__len__</code></summary>

</details>
<details>
<summary>Enum: implicit finality</summary>

</details>
### Strict-rule diagnostics
These examples enable `redundant-condition-strict` explicitly.
<details>
<summary>Indexing <code>bytes</code> returns <code>int</code></summary>

</details>
<details>
<summary>Comparing <code>str</code> with <code>bytes</code></summary>

</details>
<details>
<summary>Length comparison: explain the fixed tuple length</summary>

</details>
<details>
<summary>Comparison: omit an obvious literal's type annotation</summary>

</details>
## Implementation
The semantic index records the outermost boolean tests in each scope, so
one check owns the diagnostics for a complete condition and its
subexpressions. A shared checker separates classification, context
exemptions, and duplicate suppression from diagnostic rendering. The
main implementation is in
`types/infer/builder/redundant_conditions/mod.rs`; its `diagnostic.rs`
submodule constructs messages and fixes, and `exemptions.rs` handles
environment provenance and defensive exits.
## Summary
Function-like `Callable`s are ones where we know that the `Callable` is
also an instance of `FunctionType` at runtime. Since `FunctionType`
instances are always truthy, we should therefore treat function-like
`Callable` types as always truthy too. This fixes a false positive in
the ecosystem and will also reduce false negatives on
https://github.com/astral-sh/ruff/pull/28034.
## Test Plan
mdtests
## Summary
`typing.cast()` leaves its argument unchanged at runtime, but accepts
casts even when the inferred source type and destination type cannot
overlap. Add a disabled-by-default `disjoint-cast` rule to flag these
cases:
```python
from typing import cast
def f(value: int) -> str:
return cast(str, value) # disjoint-cast
```
Casts between overlapping types remain accepted. The rule exempts stub
files and casts to or from `Never`.
The documentation explains less obvious disjoint casts involving
invariant containers and `NewType`, and discusses `TypeGuard` as a
possible alternative. Enable the rule in ecosystem analysis to assess
its impact.
## Error-tree diagnostics
Add error-tree explanations for disjointness checks involving invariant
generic arguments, unions, tuples, protocol members, final classes, and
TypedDict fields. Secondary annotations identify the inferred source
type, the destination, and available type definitions, including
`@final` decorators.
For example, casting a class whose `values()` method returns `list[str]`
to a protocol requiring `list[int]` produces:
```text
info: protocol member `values` is incompatible
info: └── return types `list[int]` and `list[str]` are disjoint
info: └── `int` and `str` are not mutual subtypes of each other
```
## Parallels in other type checkers
| Checker | Rule | Comparison with `disjoint-cast` |
|---|---|---|
| **basedpyright** |
[`reportInvalidCast`](https://docs.basedpyright.com/latest/benefits-over-pyright/new-diagnostic-rules/#reportinvalidcast)
| Same high-level purpose: reject casts between non-overlapping types.
Its docs explicitly demonstrate `int` → `str`, and discuss rejecting
`dict` → `TypedDict` casts. |
| **Pyrefly** |
[`invalid-cast`](https://pyrefly.org/en/docs/error-kinds/#invalid-cast)
| Disabled by default, but deliberately narrower than `disjoint-cast`:
checks disjoint **runtime classes**. It accepts `list[int]` →
`list[str]`, ignores tuple-element differences, treats TypedDicts as
overlapping with `dict`, and excludes protocols. |
Mypy and upstream Pyright do not have direct equivalents. Both can
report redundant casts, but their current `cast` implementations do not
check source/destination overlap: [mypy
implementation](https://github.com/python/mypy/blob/master/mypy/checkexpr.py#L4546),
[Pyright
implementation](https://github.com/microsoft/pyright/blob/main/packages/pyright-internal/src/analyzer/typeEvaluator.ts#L10917).
### TypeScript
TypeScript has a similar restriction on type assertions: **TS2352**
rejects assertions between types that do not sufficiently overlap. For
example:
```typescript
const value = "hello" as number; // TS2352
```
Like `typing.cast()`, TypeScript's type assertions do not perform
runtime validation. An explicit two-step assertion through `unknown` or
`any`, such as `value as unknown as Target`, can bypass the restriction.
The overlap criterion differs from ty's disjointness relation, so this
is a precedent for checking suspicious casts rather than an exact
semantic equivalent. See the [TypeScript handbook on type
assertions](https://www.typescriptlang.org/docs/handbook/2/everyday-types.html#type-assertions).
## Summary
Builds on #28032.
Expand the test suite introduced in #28030 for the upcoming
`redundant-condition` and `redundant-condition-strict` rules in #28034.
The new examples cover short-circuiting, chained comparisons,
conditional expressions, nested boolean tests, required `TypedDict` keys
established by narrowing, and exemptions for environment-dependent
values and defensive runtime checks.
Only `redundant_condition.md` changes. Expected diagnostics remain
marked with TODOs, while `reveal_type` assertions document the
distinction between an expression's inferred value type and its
truthiness when used directly as a condition.
## Summary
This is a behaviour-preserving refactor of definition resolution:
navigation targets, docstrings, and existing type-checking results are
preserved. The changes are to the API boundaries and the inference work
performed internally.
## LSP benefits
Go-to-definition and go-to-declaration for ordinary builtins such as
`isinstance` no longer request completed inference of the enclosing
scope just to check whether numeric-annotation expansion applies. Only
`float` and `complex` need that check. Completing scope inference
computes and caches types for the scope's expressions, along with
diagnostics and other inference data, even though navigation only needs
the symbol's definition.
The expected benefit is lower navigation latency when scope inference is
uncached or has been invalidated by an edit, plus fewer temporary
allocations and fewer inference results retained solely for navigation.
The savings depend on the workload: cached inference leaves little
computation to avoid, and diagnostics may still require the same cached
data. This does not guarantee lower steady-state memory usage for the
server.
## Reuse during type inference
The shared API will support inference-side consumers such as
[#27634](https://github.com/astral-sh/ruff/pull/27634), whose
redundant-condition analysis needs source definitions as well as
inferred types:
- It follows assignments and imports to recognize conditions derived
from `sys.version_info`, `sys.platform`, `os.name`, or
`typing.TYPE_CHECKING`. These can be constant for the configured target
while still being deliberate compatibility guards, including when
accessed through aliases or attributes.
- It finds the annotation behind an always-truthy tuple so the
diagnostic can point to `tuple[T]` and suggest `tuple[T, ...]` when a
tuple of arbitrary length may have been intended.
These checks run while the enclosing scope is still being inferred.
Using the IDE API to obtain expression types at that point can re-enter
inference of the same scope, causing avoidable Salsa query cycles and
repeated work. The shared helpers instead accept an explicit scope or an
already-inferred receiver type, allowing these checks to reuse the
existing name, import, and member-resolution rules without requesting
completed inference of the current scope. Pydantic's existing
annotation-alias lookup is also migrated to this API.
## Diff overview
- **Moved:** Existing scope traversal, class/MRO member lookup, import
resolution, and resolved-definition/docstring helpers move into
`definition_resolution`, preserving their lookup algorithms.
Numeric-annotation expansion and stub-to-implementation mapping remain
in `ide_support`.
- **Added:** Shared lookup entry points and three regression tests that
assert definition lookup does not request completed scope inference.
- **Changed:** IDE wrappers and the Pydantic caller delegate to the
shared helpers; builtin name lookup gets the `float`/`complex` inference
guard described above. The remaining edits adapt parameters, imports,
and visibility, and document the shared API's contracts.