From b4cb04114c04dde3764dabe5f2eebcd0fcda4d3f Mon Sep 17 00:00:00 2001 From: phpstan-bot <79867460+phpstan-bot@users.noreply.github.com> Date: Tue, 19 May 2026 13:28:59 +0000 Subject: [PATCH] Accept bidirectionally equivalent types in invariant template variance check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - In `TemplateTypeVariance::isValidVariance()`, the invariant check used `equals()` which is a structural comparison. `StaticType` and `ObjectType` are structurally different PHP classes, so `equals()` returned false even when both represent the same type (e.g. `static(FinalClass)` vs `FinalClass`). - After `equals()` fails, now check bidirectional `isSuperTypeOf()`: if both `$a->isSuperTypeOf($b)->yes()` and `$b->isSuperTypeOf($a)->yes()`, the types are semantically equivalent and accepted for invariant templates. - This fixes false positives like `Collection` not matching `Collection` when `Foo` is a final class and the template is invariant. - Probed analogous cases: `ThisType` in final classes (requires broader `ThisType::isSuperTypeOf` changes that affect intersection simplification for enums — left for a separate fix), covariant/contravariant templates (already handled by `isSuperTypeOf` directly), parameter types (already resolved by `transformStaticType`). --- src/Type/Generic/TemplateTypeVariance.php | 7 +- tests/PHPStan/Analyser/nsrt/bug-14647.php | 92 +++++++++++++++++++ .../Rules/Methods/ReturnTypeRuleTest.php | 6 ++ .../PHPStan/Rules/Methods/data/bug-14647.php | 88 ++++++++++++++++++ 4 files changed, 191 insertions(+), 2 deletions(-) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-14647.php create mode 100644 tests/PHPStan/Rules/Methods/data/bug-14647.php diff --git a/src/Type/Generic/TemplateTypeVariance.php b/src/Type/Generic/TemplateTypeVariance.php index 35536317dd6..7ce52f78d20 100644 --- a/src/Type/Generic/TemplateTypeVariance.php +++ b/src/Type/Generic/TemplateTypeVariance.php @@ -179,9 +179,12 @@ public function isValidVariance(TemplateType $templateType, Type $a, Type $b): I $result = $a->equals($b); $reasons = []; if (!$result) { - if ( + $aIsSuperTypeOfB = $a->isSuperTypeOf($b); + if ($aIsSuperTypeOfB->yes() && $b->isSuperTypeOf($a)->yes()) { + $result = true; + } elseif ( $templateType->getScope()->getClassName() !== null - && $a->isSuperTypeOf($b)->yes() + && $aIsSuperTypeOfB->yes() ) { $reasons[] = sprintf( 'Template type %s on class %s is not covariant. Learn more: https://phpstan.org/blog/whats-up-with-template-covariant', diff --git a/tests/PHPStan/Analyser/nsrt/bug-14647.php b/tests/PHPStan/Analyser/nsrt/bug-14647.php new file mode 100644 index 00000000000..9df764a266c --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-14647.php @@ -0,0 +1,92 @@ += 8.1 + +declare(strict_types = 1); + +namespace Bug14647Nsrt; + +use function PHPStan\Testing\assertType; + +/** + * @template TValue + */ +class Collection +{ + /** @param array $items */ + public function __construct(private readonly array $items) {} + + /** @return array */ + public function items(): array + { + return $this->items; + } +} + +abstract class AbstractValue +{ + final public function __construct() {} + + /** @return Collection */ + public function collect(): Collection + { + return new Collection([new static()]); + } +} + +final class Value extends AbstractValue +{ + /** @return Collection */ + #[\Override] + public function collect(): Collection + { + return parent::collect(); + } + + /** @return Collection */ + public function childCollect(): Collection + { + return new Collection([new static()]); + } + + public function testTypes(): void + { + assertType('Bug14647Nsrt\Collection', $this->collect()); + assertType('Bug14647Nsrt\Collection', $this->childCollect()); + assertType('static(Bug14647Nsrt\Value)', new static()); + } +} + +/** + * @template T + */ +class Box +{ + /** @param T $value */ + public function __construct(private readonly mixed $value) {} + + /** @return T */ + public function get(): mixed + { + return $this->value; + } +} + +final class FinalFoo +{ + /** @return Box */ + public function boxed(): Box + { + return new Box(new static()); + } + + /** @return Box */ + public static function staticBoxed(): Box + { + return new Box(new static()); + } +} + +function testFinalFoo(FinalFoo $foo): void +{ + assertType('Bug14647Nsrt\Box', $foo->boxed()); + assertType('Bug14647Nsrt\Box', FinalFoo::staticBoxed()); +} diff --git a/tests/PHPStan/Rules/Methods/ReturnTypeRuleTest.php b/tests/PHPStan/Rules/Methods/ReturnTypeRuleTest.php index a60a6a704a6..0ced1dc18ed 100644 --- a/tests/PHPStan/Rules/Methods/ReturnTypeRuleTest.php +++ b/tests/PHPStan/Rules/Methods/ReturnTypeRuleTest.php @@ -1347,4 +1347,10 @@ public function testBug14553(): void $this->analyse([__DIR__ . '/../../Analyser/nsrt/bug-14553.php'], []); } + #[RequiresPhp('>= 8.1')] + public function testBug14647(): void + { + $this->analyse([__DIR__ . '/data/bug-14647.php'], []); + } + } diff --git a/tests/PHPStan/Rules/Methods/data/bug-14647.php b/tests/PHPStan/Rules/Methods/data/bug-14647.php new file mode 100644 index 00000000000..22a613e7084 --- /dev/null +++ b/tests/PHPStan/Rules/Methods/data/bug-14647.php @@ -0,0 +1,88 @@ += 8.1 + +declare(strict_types = 1); + +namespace Bug14647; + +/** + * @template TValue + */ +class Collection +{ + /** @param array $items */ + public function __construct(private readonly array $items) {} + + /** @return array */ + public function items(): array + { + return $this->items; + } +} + +abstract class AbstractValue +{ + final public function __construct() {} + + /** @return Collection */ + public function collect(): Collection + { + return new Collection([new static()]); + } +} + +final class Value extends AbstractValue +{ + /** @return Collection */ + #[\Override] + public function collect(): Collection + { + return parent::collect(); + } + + /** @return Collection */ + public function childCollect(): Collection + { + return new Collection([new static()]); + } +} + +/** + * @template T + */ +class Box +{ + /** @param T $value */ + public function __construct(private readonly mixed $value) {} + + /** @return T */ + public function get(): mixed + { + return $this->value; + } +} + +final class FinalWithStaticMethods +{ + /** @return Box */ + public static function staticBoxed(): Box + { + return new Box(new static()); + } + + /** @return Box */ + public function instanceBoxed(): Box + { + return new Box(new static()); + } + + /** @param Box $box */ + public function acceptBox(Box $box): void + { + } + + public function test(): void + { + $this->acceptBox(new Box(new static())); + $this->acceptBox(new Box(new FinalWithStaticMethods())); + } +}