[ty] avoid fixpoint unioning of types containing current-cycle Divergent (#21910)

Partially addresses https://github.com/astral-sh/ty/issues/1732

## Summary

Don't union the previous type in fixpoint iteration if the previous type
contains a `Divergent` from the current cycle and the latest type does
not. The theory here, as outlined by @mtshiba at
https://github.com/astral-sh/ty/issues/1732#issuecomment-3609937420, is
that oscillation can't occur by removing and then reintroducing a
`Divergent` type repeatedly, since `Divergent` types are only introduced
at the start of fixpoint iteration.

## Test Plan

Removes a `Divergent` type from the added mdtest, doesn't otherwise
regress any tests.
This commit is contained in:
Carl Meyer 2025-12-11 19:52:34 -08:00 committed by GitHub
parent 5e42926eee
commit 0138cd238a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 39 additions and 1 deletions

View File

@ -141,3 +141,18 @@ class C:
# revealed: (*, kw_only=Unknown | ((*, kw_only=Unknown) -> Unknown)) -> Unknown
reveal_type(self.d)
```
## Self-referential implicit attributes
```py
class Cyclic:
def __init__(self, data: str | dict):
self.data = data
def update(self):
if isinstance(self.data, str):
self.data = {"url": self.data}
# revealed: Unknown | str | dict[Unknown, Unknown] | dict[Unknown | str, Unknown | str]
reveal_type(Cyclic("").data)
```

View File

@ -922,7 +922,30 @@ impl<'db> Type<'db> {
{
self
}
_ => UnionType::from_elements_cycle_recovery(db, [self, previous]),
_ => {
// Also avoid unioning in a previous type which contains a Divergent from the
// current cycle, if the most-recent type does not. This cannot cause an
// oscillation, since Divergent is only introduced at the start of fixpoint
// iteration.
let has_divergent_type_in_cycle = |ty| {
any_over_type(
db,
ty,
&|nested_ty| {
matches!(
nested_ty,
Type::Dynamic(DynamicType::Divergent(DivergentType { id }))
if cycle.head_ids().contains(&id))
},
false,
)
};
if has_divergent_type_in_cycle(previous) && !has_divergent_type_in_cycle(self) {
self
} else {
UnionType::from_elements_cycle_recovery(db, [self, previous])
}
}
}
.recursive_type_normalized(db, cycle)
}