From 4e99486879dfe847a5d177f1d572919f02b7c215 Mon Sep 17 00:00:00 2001 From: ondrejmirtes <104888+ondrejmirtes@users.noreply.github.com> Date: Mon, 9 Mar 2026 16:07:45 +0000 Subject: [PATCH] Fix false positive for invariant generic types when checkNullables is off MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - At rule levels 3-7, RuleLevelHelper strips null from accepted type arguments - This caused Trap to become Trap on the accepted side - Invariant template check uses equals(), so int|null ≠ int → false positive - Added fallback in GenericObjectType::isSuperTypeOfInternal() to detect when the only difference is null stripping and accept the type in that case - New regression test in tests/PHPStan/Rules/Properties/data/bug-13876.php Closes https://github.com/phpstan/phpstan/issues/13876 --- src/Type/Generic/GenericObjectType.php | 13 +++- .../TypesAssignedToPropertiesRuleTest.php | 10 ++- .../Rules/Properties/data/bug-13876.php | 68 +++++++++++++++++++ 3 files changed, 89 insertions(+), 2 deletions(-) create mode 100644 tests/PHPStan/Rules/Properties/data/bug-13876.php diff --git a/src/Type/Generic/GenericObjectType.php b/src/Type/Generic/GenericObjectType.php index a82f3010649..307e283943c 100644 --- a/src/Type/Generic/GenericObjectType.php +++ b/src/Type/Generic/GenericObjectType.php @@ -20,6 +20,7 @@ use PHPStan\Type\IsSuperTypeOfResult; use PHPStan\Type\ObjectType; use PHPStan\Type\Type; +use PHPStan\Type\TypeCombinator; use PHPStan\Type\TypeWithClassName; use PHPStan\Type\UnionType; use PHPStan\Type\VerbosityLevel; @@ -186,7 +187,17 @@ private function isSuperTypeOfInternal(Type $type, bool $acceptsContext): IsSupe if (!$thisVariance->invariant()) { $results[] = $thisVariance->isValidVariance($templateType, $this->types[$i], $ancestor->types[$i]); } else { - $results[] = $templateType->isValidVariance($this->types[$i], $ancestor->types[$i]); + $result = $templateType->isValidVariance($this->types[$i], $ancestor->types[$i]); + if ($acceptsContext && !$result->yes()) { + $thisWithoutNull = TypeCombinator::removeNull($this->types[$i]); + if ( + $this->types[$i]->isSuperTypeOf($ancestor->types[$i])->yes() + && $ancestor->types[$i]->isSuperTypeOf($thisWithoutNull)->yes() + ) { + $result = IsSuperTypeOfResult::createYes(); + } + } + $results[] = $result; } $results[] = IsSuperTypeOfResult::createFromBoolean($thisVariance->validPosition($ancestorVariance)); diff --git a/tests/PHPStan/Rules/Properties/TypesAssignedToPropertiesRuleTest.php b/tests/PHPStan/Rules/Properties/TypesAssignedToPropertiesRuleTest.php index 6c03bc86c3c..e84d3d8e7ed 100644 --- a/tests/PHPStan/Rules/Properties/TypesAssignedToPropertiesRuleTest.php +++ b/tests/PHPStan/Rules/Properties/TypesAssignedToPropertiesRuleTest.php @@ -13,13 +13,15 @@ class TypesAssignedToPropertiesRuleTest extends RuleTestCase { + private bool $checkNullables = true; + private bool $checkExplicitMixed = false; private bool $checkImplicitMixed = false; protected function getRule(): Rule { - return new TypesAssignedToPropertiesRule(new RuleLevelHelper(self::createReflectionProvider(), true, false, true, $this->checkExplicitMixed, $this->checkImplicitMixed, false, true), new PropertyReflectionFinder()); + return new TypesAssignedToPropertiesRule(new RuleLevelHelper(self::createReflectionProvider(), $this->checkNullables, false, true, $this->checkExplicitMixed, $this->checkImplicitMixed, false, true), new PropertyReflectionFinder()); } public function testTypesAssignedToProperties(): void @@ -1038,4 +1040,10 @@ public function testBug4525(): void $this->analyse([__DIR__ . '/data/bug-4525.php'], []); } + public function testBug13876(): void + { + $this->checkNullables = false; + $this->analyse([__DIR__ . '/data/bug-13876.php'], []); + } + } diff --git a/tests/PHPStan/Rules/Properties/data/bug-13876.php b/tests/PHPStan/Rules/Properties/data/bug-13876.php new file mode 100644 index 00000000000..5217e91744b --- /dev/null +++ b/tests/PHPStan/Rules/Properties/data/bug-13876.php @@ -0,0 +1,68 @@ +bait = $bait; + $this->switch = $switch; + } + + /** + * @return PROMISED + */ + public function fall() + { + return ($this->switch)($this->bait); + } +} + +class A {} + +class B { + + /** + * @var Trap + */ + private Trap $b; + + public function __construct() { + + /** + * @var Trap + */ + $nullPerson = new Trap(null, function (): ?A { + return null; + }); + + $this->b = $nullPerson; + } + + /** + * @return ?A + */ + public function getB() { + return $this->b->fall(); + } +}