Skip to content

Do not treat an invariance mismatch as disjointness in GenericObjectType::isSuperTypeOf() - #6419

Open
phpstan-bot wants to merge 1 commit into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-0y3c4bo
Open

Do not treat an invariance mismatch as disjointness in GenericObjectType::isSuperTypeOf()#6419
phpstan-bot wants to merge 1 commit into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-0y3c4bo

Conversation

@phpstan-bot

Copy link
Copy Markdown
Collaborator

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 after isBacked() 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 intersecting ReflectionEnum<T> with ReflectionEnum<T&BackedEnum> collapsed to never.

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 — in isSuperTypeOfInternal(), when the comparison of an invariant type argument comes back no but the two type arguments still overlap, the result is downgraded to maybe. This only happens outside of the accepts context ($acceptsContext === false), so accepts() still answers no and variance keeps being enforced on arguments, return types and @var tags. 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 — new mergeGenericObjectTypes() helper, called from intersect() next to the existing GenericClassStringType merging. Two GenericObjectTypes of the same class (same type-argument count, same variances, no subtracted types) are merged by intersecting their type arguments position-wise. A never type argument makes the whole intersection never.
  • tests/PHPStan/Analyser/data/enum-reflection-backed.phptests/PHPStan/Analyser/nsrt/enum-reflection-backed.php, and the PHP_VERSION_ID < 80400 guard in NodeScopeResolverTest is dropped — the test was excluded on PHP 8.4+ precisely because of this bug.
  • tests/PHPStan/Type/Generic/GenericObjectTypeTest.php — invariant same-class isSuperTypeOf() expectations updated from no to maybe, with new data sets pinning that disjoint type arguments still answer no. dataTypeProjections() was shared by testIsSuperTypeOf() and testAccepts(); it now carries an optional fourth element for the accepts answer where the two differ.
  • tests/PHPStan/Rules/Methods/MethodSignatureRuleTest.phptestBug4707 now 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 neighbouring testBug3523 expectation for the same static-type shape.

Analogous cases fixed by the same change (each covered by a test that fails without it):

  • @phpstan-assert narrowing of an invariant generic through a function call, a method call and a static method call — ImpossibleCheckTypeFunctionCallRule, ImpossibleCheckTypeMethodCallRule, ImpossibleCheckTypeStaticMethodCallRule.
  • The declaration-site check "Asserted type Foo<Cat> for $b with type Foo<Animal> can never happen." — FunctionAssertRule and MethodAssertRule.
  • === 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:

  • Covariant and contravariant templates (they never produced the false no).
  • GenericStaticType / ThisType intersections (@phpstan-assert static<Cat> $this) — already produced an intersection rather than never.
  • GenericClassStringType, ArrayType, IterableType and ConstantArrayTypeintersect() already merged their type arguments.

Root cause

isSuperTypeOf() and accepts() were giving the same answer for invariant generics, but they are asked different questions. Invariance means Foo<Cat> is not assignable to Foo<Animal> — that is the accepts() 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() returned no in both directions for Foo<Animal> vs Foo<Cat>, every consumer that reads no as "impossible" drew the wrong conclusion:

  • TypeCombinator::intersect() hit its if ($isSuperTypeA->no()) return new NeverType() branch, so any narrowing of an invariant generic produced never;
  • ImpossibleCheckTypeHelper compares $resultType->isSuperTypeOf($argumentType) and reported "will always evaluate to false";
  • AssertRuleHelper reported "Asserted type ... can never happen" at the declaration site;
  • StrictComparisonOfDifferentTypesRule reported === as always false.

The fix answers maybe for that case, which is the honest answer, and leaves no in place when the type arguments really cannot overlap. Once isSuperTypeOf() returns maybe, intersect() would keep both types side by side (Foo<Animal>&Foo<Cat>), so mergeGenericObjectTypes() folds them into the single precise Foo<Cat> — the same result the covariant case already produced.

Test

  • tests/PHPStan/Analyser/nsrt/bug-15165.php — the reporter's playground snippet with assertType() calls; fails without the fix (*NEVER* instead of ReflectionEnum<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-existing isBacked() 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 impossible Bag<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

…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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant