Do not treat an invariance mismatch as disjointness in GenericObjectType::isSuperTypeOf() - #6419
Open
phpstan-bot wants to merge 1 commit into
Open
Conversation
…Type::isSuperTypeOf()`
* `GenericObjectType::isSuperTypeOfInternal()` no longer answers `no` for an invariant
type argument that merely differs - it answers `maybe` when the two type arguments
still overlap. `accepts()` (the `$acceptsContext` branch) keeps answering `no`, so
variance is still enforced on arguments, return types and `@var` tags. The reasons
carried by the original result (the "Template type T is not covariant" tip) are
preserved on the downgraded result.
* `TypeCombinator::intersect()` merges two `GenericObjectType`s of the same class by
intersecting their type arguments position-wise, instead of leaving
`Foo<Animal> & Foo<Cat>` as an intersection of two generic types. A `never` type
argument still collapses the whole intersection to `never`, so genuinely disjoint
type arguments (`Bag<int> & Bag<string>`) keep reporting impossible checks.
* Same-family cases fixed by the same change, each with its own failing test:
function / method / static method `@phpstan-assert` narrowing of an invariant
generic (`ImpossibleCheckType{Function,Method,StaticMethod}CallRule`), the
declaration-site "Asserted type ... can never happen" check
(`Function/MethodAssertRule`), `===` between two invariant generic types
(`StrictComparisonOfDifferentTypesRule`), and `Foo<A>&Foo<B>` written in a PHPDoc.
* `GenericStaticType` / `ThisType` intersections were probed and were already correct.
* `tests/PHPStan/Analyser/data/enum-reflection-backed.php` moves back to `nsrt/`; it
was excluded on PHP >= 8.4 because the invariant `ReflectionEnum` stub used there
broke `isBacked()` narrowing.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
On PHP 8.4+ PHPStan reported
Call to method ReflectionEnum<T of UnitEnum>::isBacked() will always evaluate to false.for perfectly valid code, and narrowing afterisBacked()produced*NEVER*.The trigger was the
ReflectionEnumWithLazyObjects.stub, which declares@template T of UnitEnum(invariant) rather than@template-covariant, because it@extends ReflectionClass<T>and that class became invariant when the lazy-object methods were added.isBacked()is described with@phpstan-assert-if-true self<T&BackedEnum> $this, and intersectingReflectionEnum<T>withReflectionEnum<T&BackedEnum>collapsed tonever.The underlying problem is not specific to
ReflectionEnum: PHPStan conflated "not assignable because the template is invariant" with "these two types are disjoint".Changes
src/Type/Generic/GenericObjectType.php— inisSuperTypeOfInternal(), when the comparison of an invariant type argument comes backnobut the two type arguments still overlap, the result is downgraded tomaybe. This only happens outside of the accepts context ($acceptsContext === false), soaccepts()still answersnoand variance keeps being enforced on arguments, return types and@vartags. The reasons of the original result (including the "Template type T on class X is not covariant" tip) are carried over to the downgraded result.src/Type/TypeCombinator.php— newmergeGenericObjectTypes()helper, called fromintersect()next to the existingGenericClassStringTypemerging. TwoGenericObjectTypes of the same class (same type-argument count, same variances, no subtracted types) are merged by intersecting their type arguments position-wise. Anevertype argument makes the whole intersectionnever.tests/PHPStan/Analyser/data/enum-reflection-backed.php→tests/PHPStan/Analyser/nsrt/enum-reflection-backed.php, and thePHP_VERSION_ID < 80400guard inNodeScopeResolverTestis dropped — the test was excluded on PHP 8.4+ precisely because of this bug.tests/PHPStan/Type/Generic/GenericObjectTypeTest.php— invariant same-classisSuperTypeOf()expectations updated fromnotomaybe, with new data sets pinning that disjoint type arguments still answerno.dataTypeProjections()was shared bytestIsSuperTypeOf()andtestAccepts(); it now carries an optional fourth element for the accepts answer where the two differ.tests/PHPStan/Rules/Methods/MethodSignatureRuleTest.php—testBug4707now gets "should be covariant with" instead of "should be compatible with"; the error is still reported at the same line, and the wording now matches the neighbouringtestBug3523expectation for the same static-type shape.Analogous cases fixed by the same change (each covered by a test that fails without it):
@phpstan-assertnarrowing of an invariant generic through a function call, a method call and a static method call —ImpossibleCheckTypeFunctionCallRule,ImpossibleCheckTypeMethodCallRule,ImpossibleCheckTypeStaticMethodCallRule.Foo<Cat>for$bwith typeFoo<Animal>can never happen." —FunctionAssertRuleandMethodAssertRule.===between two invariant generic types —StrictComparisonOfDifferentTypesRule.Foo<Animal>&Foo<Cat>written directly in a PHPDoc, which used to be "unresolvable type" plus*NEVER*.Probed and found already correct, so left alone:
no).GenericStaticType/ThisTypeintersections (@phpstan-assert static<Cat> $this) — already produced an intersection rather thannever.GenericClassStringType,ArrayType,IterableTypeandConstantArrayType—intersect()already merged their type arguments.Root cause
isSuperTypeOf()andaccepts()were giving the same answer for invariant generics, but they are asked different questions. Invariance meansFoo<Cat>is not assignable toFoo<Animal>— that is theaccepts()answer, and it is what the variance rules enforce. It does not mean the two types are disjoint: PHP generics are erased, so one and the same object can satisfy both claims.Because
GenericObjectType::isSuperTypeOfInternal()returnednoin both directions forFoo<Animal>vsFoo<Cat>, every consumer that readsnoas "impossible" drew the wrong conclusion:TypeCombinator::intersect()hit itsif ($isSuperTypeA->no()) return new NeverType()branch, so any narrowing of an invariant generic producednever;ImpossibleCheckTypeHelpercompares$resultType->isSuperTypeOf($argumentType)and reported "will always evaluate to false";AssertRuleHelperreported "Asserted type ... can never happen" at the declaration site;StrictComparisonOfDifferentTypesRulereported===as always false.The fix answers
maybefor that case, which is the honest answer, and leavesnoin place when the type arguments really cannot overlap. OnceisSuperTypeOf()returnsmaybe,intersect()would keep both types side by side (Foo<Animal>&Foo<Cat>), somergeGenericObjectTypes()folds them into the single preciseFoo<Cat>— the same result the covariant case already produced.Test
tests/PHPStan/Analyser/nsrt/bug-15165.php— the reporter's playground snippet withassertType()calls; fails without the fix (*NEVER*instead ofReflectionEnum<BackedEnum&T of UnitEnum>).tests/PHPStan/Rules/Comparison/data/bug-15165.php+ImpossibleCheckTypeMethodCallRuleTest::testBug15165— the reporter's snippet verbatim, expecting no errors; without the fix it reproduces the two errors from the issue.tests/PHPStan/Analyser/nsrt/enum-reflection-backed.php— the pre-existingisBacked()narrowing test, no longer skipped on PHP 8.4+.tests/PHPStan/Analyser/nsrt/invariant-generic-narrowing.php— user-defined invariant generics: narrowing through function / method / static-method asserts, through a template type argument, through a PHPDoc intersection, and through===; plus a covariant control and a disjoint control that must stay*NEVER*. Seven of its assertions fail without the fix.ImpossibleCheckTypeFunctionCallRuleTest,ImpossibleCheckTypeMethodCallRuleTest,ImpossibleCheckTypeStaticMethodCallRuleTest::testInvariantGenericAssert— the false positives are gone while the genuinely impossibleBag<int>/Bag<string>assert is still reported.FunctionAssertRuleTest,MethodAssertRuleTest::testInvariantGenericAssert— same split at the declaration site.StrictComparisonOfDifferentTypesRuleTest::testInvariantGenericComparison— same split for===.Fixes phpstan/phpstan#15165