[ty] Fix infinite hang on mutually recursive TypeAliasType definitions (#23397)
## Summary
Fix an infinite hang when type-checking files with mutually recursive
`TypeAliasType` definitions created via the manual constructor:
```python
from typing import Union
from typing_extensions import TypeAliasType
A = TypeAliasType('A', Union[str, 'B'])
B = TypeAliasType('B', list[A])
```
This was discovered while testing ty on pydantic's
[`tests/test_type_alias_type.py`](https://github.com/pydantic/pydantic/blob/main/tests/test_type_alias_type.py),
which contains self-referential `TypeAliasType` definitions such as:
```python
JsonType = TypeAliasType(
"JsonType",
Union[list["JsonType"], dict[str, "JsonType"], str, int, float, bool, None]
)
```
## Root Cause
`ManualPEP695TypeAliasType` was a `#[salsa::interned]` struct with the
resolved value type as one of its fields:
```rust
#[salsa::interned]
pub struct ManualPEP695TypeAliasType<'db> {
pub name: ast::name::Name,
pub definition: Option>,
pub value: Type<'db>, // <-- causes non-convergence
}
```
With `#[salsa::interned]`, **all fields contribute to
identity/deduplication**. When Salsa detects a cycle in
`infer_definition_types`, it re-executes the function iteratively until
the result converges (stops changing). But each iteration produces a
different `value` type as the cycle resolves, creating a new interned ID
each time:
- **Iteration 1**: value = `Union[str, Divergent]` (B not yet resolved)
→ interned ID **X1**
- **Iteration 2**: value = `Union[str, TypeAlias_B]` (B resolved) →
interned ID **X2**
- **Iteration 3**: yet another value → yet another interned ID **X3**
- ... **never converges**
This contrasts with `PEP695TypeAliasType` (the `type X = ...` form),
which does NOT store the value in the interned struct and instead
computes it lazily via a separate tracked method with its own cycle
detection.
## Fix
Remove the `value` field from `ManualPEP695TypeAliasType` and compute it
lazily (matching the `PEP695TypeAliasType` pattern):
1. **`types.rs`**: Removed `value` field. Added a lazy `value_type()`
tracked method with Salsa cycle annotations that re-parses the value
from the definition's AST on demand.
2. **`infer/builder.rs`**: Created a dedicated
`infer_typealiastype_call` method, following the same pattern used by
`TypeVar`, `ParamSpec`, and `NewType`. This validates the arguments,
constructs the `ManualPEP695TypeAliasType`, and defers inference of the
value argument to avoid cycles. Also added an "invalid context"
diagnostic when `TypeAliasType` is used outside a simple variable
assignment.
3. **`class.rs`**: Removed the `TypeAliasType` handling from
`KnownClass::check_call` entirely, since it is now fully handled by the
dedicated inference method.
Additional improvements:
- Added validation that the first argument is a string literal matching
the assignment target name (like `TypeVar` and `NewType`).
- Corrected the error message for non-string-literal names (was
referencing `typing.TypeAlias`, now says `TypeAliasType`).
- The self-referential `JSONValue` test case in `cycle.md` now correctly
resolves to the actual type instead of `Divergent`, since lazy
evaluation means the alias is already bound by the time `value_type()`
runs.
- Goto-type-definition for `TypeAliasType` variables now correctly
navigates to the assignment (previously returned "No type definitions
found" because the definition was `None`).
## Approaches Explored (and rejected)
### `#[salsa::tracked]` with `#[no_eq]` on value
Changed from `#[salsa::interned]` to `#[salsa::tracked]` with `#[no_eq]`
on the value field. **Result**: Still hangs — tracked struct recycling
does NOT work during Salsa cycle re-iterations. Each iteration creates a
new tracked struct with a fresh ID (observed IDs growing unboundedly:
9800, 9801, 9802, ...).
### Register RHS as standalone expression in the semantic index builder
Registered the call expression RHS as a standalone expression so that
`check_call` could find the definition via `try_expression`. **Result**:
Breaks `TypeVar`, `ParamSpec`, `NamedTuple`, and `NewType` handling,
since `infer_assignment_definition_impl` has a code path for standalone
expressions (generic inference) and a separate code path with
specialized handlers for these known classes. Registering call
expressions as standalone causes all of them to take the wrong path.
## Test plan
- Added new mdtest for mutually recursive `TypeAliasType` definitions in
`pep695_type_aliases.md`
- Added mdtest for name-mismatch and invalid-context diagnostics
- Updated `cycle.md` test expectation (improved from `Divergent` to
actual resolved type)
- Updated `ty_ide` snapshot for `goto_type_of_bare_type_alias_type` (now
correctly finds the definition)
- All 325 mdtests pass
- All unit tests pass
- All corpus tests pass
---------
Co-authored-by: Carl Meyer <carl@astral.sh>