Files
Lérè a8d3850605 [ty] Selectively promote a union of homogeneous fixed-length tuples to a single variadic tuple. (#24705)
<!--
Thank you for contributing to Ruff/ty! To help us out with reviewing,
please consider the following:

- Does this pull request include a summary of the change? (See below.)
- Does this pull request include a descriptive title? (Please prefix
with `[ty]` for ty pull
  requests.)
- Does this pull request include references to any relevant issues?
- Does this PR follow our AI policy
(https://github.com/astral-sh/.github/blob/main/AI_POLICY.md)?
-->

## Summary

This implements structural promotion of tuple size in the inferred type
of a collection literal.

The promotion only applies to a very specific circumstance: when tuple
literals in an inferred collection element type produce a union of
homogeneous fixed-length tuples of differing lengths, and only literal
tuple sources have contributed to that type, then we widen that union to
a single variadic tuple (e.g., `tuple[str] | tuple[str, str]` is widened
to `tuple[str, ...]`).

The result is that this scenario described in
https://github.com/astral-sh/ty/issues/2620 succeeds:

```python
languages = {
    "python": (".py", ".pyi"),
    "javascript": (".js", ".jsx", ".ts", ".tsx"),
}

# This no longer errors after this change, because the type of languages is `dict[str, tuple[str, ...]` rather than `dict[str, tuple[str, str]] | tuple[str, str, str, str]]`
languages["ruby"] = (".rb",) 
```

Closes https://github.com/astral-sh/ty/issues/2620.

### Approach

- I created a new submodule that encapsulates the tuple size promotion
policy. It exposes a `TupleSizePromotionConstraints` struct that we use
during inference to record the scenarios in which we should **not**
attempt to promote a tuple. If no such disqualifying scenarios are
encountered, then tuple size promotion is attempted. The set of
disqualifying scenarios is documented in new mdtests.
- I think the policy for when to promote unions that involve empty
tuples deserves particular scrutiny. Since empty tuples do not have an
element type, they present a special case. The rule I've chosen is that
empty tuples do not contribute to evidence of different tuple lengths.
That means that a union containing an empty tuple must also contain
other tuples of differing lengths to trigger promotion (i.e., `[(),
(1,)]` remains `list[tuple[()] | tuple[int]]`, but `[(), (1,), (1,2)]`
is promoted to `list[tuple[int, ...]]`. This is conservative and, I
hope, useful for modeling situations in which the size of a tuple is
specifically meant to be 0 or N.

## Test Plan

Please see new and updated mdtests.

<!-- How was it tested? -->
2026-05-04 11:31:21 -07:00
..
2026-04-15 15:08:55 +00:00