From a7496a610aa3f4931296e8932200b0ef9d1a397a Mon Sep 17 00:00:00 2001 From: phpstan-bot <79867460+phpstan-bot@users.noreply.github.com> Date: Sun, 20 Sep 2026 18:58:50 +0000 Subject: [PATCH] Ignore the `new Foo()` exactness flavour when comparing generic type arguments for variance * `TemplateTypeVariance::isValidVariance()` now routes the covariant and contravariant comparisons through a new private `compareTypeArguments()` that drops the final-by-keyword override from both type arguments before calling `isSuperTypeOf()`. The invariant branch never had the problem because `equals()` already ignores the flavour. * Fixes the reported case (`@param A` called on a `new B()` receiver) and, with the same change, the analogous ones found by probing the family: a receiver narrowed by `get_class($b) === B::class`, class-declared `@template-covariant` / `@template-contravariant` parameters, built-in generics (`Traversable`), the flavour arriving on the argument side through a `@return Wrapper` factory, generic property assignment (`@var A`), and type arguments nested inside unions, arrays and other generic types. * Probed and found already correct, so left alone: non-generic `@param static`, invariant type arguments, `array`/`iterable`/array-shape/callable parameter types, `class-string`, and `instanceof` narrowing - the flavour is still preserved everywhere else, so `(new B())->returnsStatic() instanceof Foo` stays impossible. * Regression tests in `tests/PHPStan/Rules/Methods/data/bug-15235.php` and `tests/PHPStan/Rules/Properties/data/bug-15235.php`; without the fix they report 8 false positives. --- src/Type/Generic/TemplateTypeVariance.php | 17 +- .../Rules/Methods/CallMethodsRuleTest.php | 8 + .../PHPStan/Rules/Methods/data/bug-15235.php | 173 ++++++++++++++++++ .../TypesAssignedToPropertiesRuleTest.php | 5 + .../Rules/Properties/data/bug-15235.php | 45 +++++ 5 files changed, 246 insertions(+), 2 deletions(-) create mode 100644 tests/PHPStan/Rules/Methods/data/bug-15235.php create mode 100644 tests/PHPStan/Rules/Properties/data/bug-15235.php diff --git a/src/Type/Generic/TemplateTypeVariance.php b/src/Type/Generic/TemplateTypeVariance.php index 4a44b32865b..d93273f3a8e 100644 --- a/src/Type/Generic/TemplateTypeVariance.php +++ b/src/Type/Generic/TemplateTypeVariance.php @@ -232,11 +232,11 @@ public function isValidVariance(TemplateType $templateType, Type $a, Type $b, bo } if ($this->covariant()) { - return $a->isSuperTypeOf($b); + return self::compareTypeArguments($a, $b); } if ($this->contravariant()) { - return $b->isSuperTypeOf($a); + return self::compareTypeArguments($b, $a); } if ($this->bivariant()) { @@ -246,6 +246,19 @@ public function isValidVariance(TemplateType $templateType, Type $a, Type $b, bo throw new ShouldNotHappenException(); } + /** + * The "exactly this class" flavour of a `new Foo()` value cannot be written in a + * PHPDoc type argument, and `Foo` with the flavour is only a maybe-supertype of a + * plain `Foo`. Comparing the two directly would make a type argument carrying the + * flavour unmatchable against the very same written type - the invariant branch + * above does not have the problem because equals() ignores the flavour. + */ + private static function compareTypeArguments(Type $super, Type $sub): IsSuperTypeOfResult + { + return TemplateTypeHelper::removeFinalByKeywordOverrides($super) + ->isSuperTypeOf(TemplateTypeHelper::removeFinalByKeywordOverrides($sub)); + } + public function equals(self $other): bool { return $other->value === $this->value; diff --git a/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php b/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php index 84849931a8a..81c5aaa2f3f 100644 --- a/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php +++ b/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php @@ -4452,6 +4452,14 @@ public function testBug8441(): void } #[RequiresPhp('>= 8.1.0')] + public function testBug15235(): void + { + $this->checkThisOnly = false; + $this->checkNullables = true; + $this->checkUnionTypes = true; + $this->analyse([__DIR__ . '/data/bug-15235.php'], []); + } + public function testBug15166(): void { $this->checkThisOnly = false; diff --git a/tests/PHPStan/Rules/Methods/data/bug-15235.php b/tests/PHPStan/Rules/Methods/data/bug-15235.php new file mode 100644 index 00000000000..02fa51c6891 --- /dev/null +++ b/tests/PHPStan/Rules/Methods/data/bug-15235.php @@ -0,0 +1,173 @@ += 8.1 + +declare(strict_types = 1); + +namespace Bug15235; + +/** + * @template T of object + */ +class A +{ + /** + * @param T $object + */ + public function __construct(public object $object) {} +} + +class B +{ + /** + * @param A $a + */ + public function method(A $a): void {} + + /** + * @param A $a + */ + public function invariant(A $a): void {} + + /** + * @param A $a + */ + public function contravariant(A $a): void {} +} + +class D +{ + public function inconsistent(B $b): void + { + $b->method(new A($b)); + } +} + +function fromNew(): void +{ + $b = new B(); + $b->method(new A($b)); + $b->invariant(new A($b)); + $b->contravariant(new A($b)); +} + +function fromGetClass(B $b): void +{ + if (get_class($b) !== B::class) { + return; + } + + $b->method(new A($b)); +} + +/** @template-covariant T of object */ +class Covariant +{ + /** @param T $object */ + public function __construct(public object $object) {} +} + +/** @template-contravariant T of object */ +class Contravariant +{ + /** @param T $object */ + public function set(object $object): void {} +} + +class E +{ + /** @param Covariant $c */ + public function declaredCovariant(Covariant $c): void {} + + /** @param Contravariant $c */ + public function declaredContravariant(Contravariant $c): void {} + + /** @param \Traversable $it */ + public function traversable(\Traversable $it): void {} + + /** @return Covariant */ + public function makeCovariant(): Covariant + { + return new Covariant($this); + } + + /** @return Wrapper */ + public function makeWrapper(): Wrapper + { + return new Wrapper($this); + } +} + +class Consumer +{ + /** @param Covariant $c */ + public function takesCovariant(Covariant $c): void {} + + /** @param Wrapper $wrapper */ + public function takesContravariantWrapper(Wrapper $wrapper): void {} + + /** @param Wrapper> $wrapper */ + public function takesNested(Wrapper $wrapper): void {} +} + +/** + * @param Covariant $covariant + * @param Contravariant $contravariant + * @param \Traversable $traversable + */ +function declaredVariances(Covariant $covariant, Contravariant $contravariant, \Traversable $traversable): void +{ + $e = new E(); + $e->declaredCovariant($covariant); + $e->declaredContravariant($contravariant); + $e->traversable($traversable); +} + +function flavourOnTheArgumentSide(Consumer $consumer): void +{ + $e = new E(); + $consumer->takesCovariant($e->makeCovariant()); + $consumer->takesContravariantWrapper($e->makeWrapper()); +} + +/** @template T of object */ +class Wrapper +{ + /** @param T $object */ + public function __construct(public object $object) {} +} + +function nestedInGenericType(Consumer $consumer): void +{ + $e = new E(); + $consumer->takesNested(new Wrapper($e->makeCovariant())); +} + +class Composite +{ + /** @param Covariant|null $c */ + public function inUnion(?Covariant $c): void {} + + /** @param array> $c */ + public function inArray(array $c): void {} + + /** @param Covariant> $c */ + public function nested(Covariant $c): void {} + + /** @param Wrapper> $c */ + public function nestedInvariant(Wrapper $c): void {} +} + +/** + * @param Covariant $covariant + * @param array> $array + * @param Covariant> $nested + * @param Wrapper> $nestedInvariant + */ +function compositeShapes(Covariant $covariant, array $array, Covariant $nested, Wrapper $nestedInvariant): void +{ + $composite = new Composite(); + $composite->inUnion($covariant); + $composite->inUnion(null); + $composite->inArray($array); + $composite->nested($nested); + $composite->nestedInvariant($nestedInvariant); +} diff --git a/tests/PHPStan/Rules/Properties/TypesAssignedToPropertiesRuleTest.php b/tests/PHPStan/Rules/Properties/TypesAssignedToPropertiesRuleTest.php index 8a874eb39a6..b43e510c5df 100644 --- a/tests/PHPStan/Rules/Properties/TypesAssignedToPropertiesRuleTest.php +++ b/tests/PHPStan/Rules/Properties/TypesAssignedToPropertiesRuleTest.php @@ -1096,6 +1096,11 @@ public function testBug8441(): void } #[RequiresPhp('>= 8.1.0')] + public function testBug15235(): void + { + $this->analyse([__DIR__ . '/data/bug-15235.php'], []); + } + public function testBug15166(): void { $this->analyse([__DIR__ . '/data/bug-15166.php'], []); diff --git a/tests/PHPStan/Rules/Properties/data/bug-15235.php b/tests/PHPStan/Rules/Properties/data/bug-15235.php new file mode 100644 index 00000000000..612d23cb87f --- /dev/null +++ b/tests/PHPStan/Rules/Properties/data/bug-15235.php @@ -0,0 +1,45 @@ += 8.1 + +declare(strict_types = 1); + +namespace Bug15235Properties; + +/** + * @template T of object + */ +class A +{ + /** + * @param T $object + */ + public function __construct(public object $object) {} +} + +/** @template-covariant T of object */ +class Covariant +{ + /** @param T $object */ + public function __construct(public object $object) {} +} + +class B +{ + /** @var A|null */ + public ?A $callSiteVariance = null; + + /** @var Covariant|null */ + public ?Covariant $declaredVariance = null; +} + +function fromParameter(B $b): void +{ + $b->callSiteVariance = new A($b); + $b->declaredVariance = new Covariant($b); +} + +function fromNew(): void +{ + $b = new B(); + $b->callSiteVariance = new A($b); + $b->declaredVariance = new Covariant($b); +}