From 8ac890bb8f7a87c41eadd8f8ddd7f9173a6011d7 Mon Sep 17 00:00:00 2001 From: Caleb White Date: Sat, 5 Sep 2026 21:08:35 -0500 Subject: [PATCH] Fix false positive comparing identical generic types below level 8 RuleLevelHelper relaxes the nullability of the accepted type but not of the accepting one, which is what lets a nullable value be passed where a non-nullable one is expected below level 8. TypeTraverser applied that relaxation to generic type arguments as well, so the argument of an accepted Collection became int while the accepting side kept int|null. Compared invariantly, two identical types then stopped matching and the reported message printed the same type on both sides. Skip the mapper on GenericObjectType and map its children via Type::traverse() instead, which also preserves subclasses through recreate(). --- phpstan-baseline.neon | 6 ++ src/Rules/RuleLevelHelper.php | 25 +++++++- .../Rules/Methods/CallMethodsRuleTest.php | 15 +++++ .../data/generic-argument-nullability.php | 57 +++++++++++++++++++ 4 files changed, 101 insertions(+), 2 deletions(-) create mode 100644 tests/PHPStan/Rules/Methods/data/generic-argument-nullability.php diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 18a7e5c81ac..ac4c0366e8d 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -708,6 +708,12 @@ parameters: count: 2 path: src/Rules/RuleErrorBuilder.php + - + rawMessage: Doing instanceof PHPStan\Type\Generic\GenericObjectType is error-prone and deprecated. + identifier: phpstanApi.instanceofType + count: 1 + path: src/Rules/RuleLevelHelper.php + - rawMessage: Doing instanceof PHPStan\Type\IntersectionType is error-prone and deprecated. identifier: phpstanApi.instanceofType diff --git a/src/Rules/RuleLevelHelper.php b/src/Rules/RuleLevelHelper.php index 1c1bd926f90..31ef304e3ab 100644 --- a/src/Rules/RuleLevelHelper.php +++ b/src/Rules/RuleLevelHelper.php @@ -11,6 +11,7 @@ use PHPStan\Type\CallableType; use PHPStan\Type\ClosureType; use PHPStan\Type\ErrorType; +use PHPStan\Type\Generic\GenericObjectType; use PHPStan\Type\Generic\TemplateMixedType; use PHPStan\Type\IntersectionType; use PHPStan\Type\MixedType; @@ -49,6 +50,22 @@ public function __construct( { } + /** + * Skip the mapper on this node and map its children instead. For a generic + * object that avoids applying the nullability relaxation to invariant type + * arguments; GenericObjectType::traverse() rebuilds the same subclass. + * + * @param callable(Type): Type $traverse + */ + private function traverseWithoutMapping(Type $type, callable $traverse): Type + { + if ($type instanceof GenericObjectType) { + return $type->traverse($traverse); + } + + return $traverse($type); + } + /** @api */ public function isThis(Expr $expression): bool { @@ -95,7 +112,7 @@ private function transformAcceptedType(Type $acceptingType, Type $acceptedType): return new CallableType( $acceptedType->getParameters(), - $traverse($acceptedType->getReturnType()), + $this->traverseWithoutMapping($acceptedType->getReturnType(), $traverse), $acceptedType->isVariadic(), $acceptedType->getTemplateTypeMap(), $acceptedType->getResolvedTemplateTypeMap(), @@ -111,7 +128,7 @@ private function transformAcceptedType(Type $acceptingType, Type $acceptedType): return new ClosureType( $acceptedType->getParameters(), - $traverse($acceptedType->getReturnType()), + $this->traverseWithoutMapping($acceptedType->getReturnType(), $traverse), $acceptedType->isVariadic(), $acceptedType->getTemplateTypeMap(), $acceptedType->getResolvedTemplateTypeMap(), @@ -127,6 +144,10 @@ private function transformAcceptedType(Type $acceptingType, Type $acceptedType): ); } + if ($acceptedType instanceof GenericObjectType) { + return $acceptedType->traverse($traverse); + } + if ( !$this->checkNullables && !$acceptingType instanceof NullType diff --git a/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php b/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php index e6d9639a749..d3fc29d8ba4 100644 --- a/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php +++ b/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php @@ -3023,6 +3023,21 @@ public function testCallablesWithoutCheckNullables(bool $checkNullables, bool $c $this->analyse([__DIR__ . '/data/callables-without-check-nullables.php'], $expectedErrors); } + public static function dataGenericArgumentNullability(): iterable + { + yield [false]; + yield [true]; + } + + #[DataProvider('dataGenericArgumentNullability')] + public function testGenericArgumentNullability(bool $checkNullables): void + { + $this->checkThisOnly = false; + $this->checkNullables = $checkNullables; + $this->checkUnionTypes = true; + $this->analyse([__DIR__ . '/data/generic-argument-nullability.php'], []); + } + #[RequiresPhp('>= 8.0.0')] public function testBug8713(): void { diff --git a/tests/PHPStan/Rules/Methods/data/generic-argument-nullability.php b/tests/PHPStan/Rules/Methods/data/generic-argument-nullability.php new file mode 100644 index 00000000000..4d8dc51cd76 --- /dev/null +++ b/tests/PHPStan/Rules/Methods/data/generic-argument-nullability.php @@ -0,0 +1,57 @@ +): T $cb + * @return T + */ + public function grabTwoArgs(callable $cb) + { + return $cb(null, []); + } + + /** @param Collection $collection */ + public function acceptNullable(Collection $collection): void + { + } + +} + +/** + * @param Collection $plain + * @param Collection $nullable + * @param array $array + */ +function test(Foo $foo, Collection $plain, Collection $nullable, array $array, ?int $scalar): void +{ + $foo->grab(fn () => $plain); + $foo->grab(fn () => $nullable); + $foo->grab(fn () => $array); + $foo->grab(fn () => $scalar); + $foo->grabTwoArgs(fn () => $nullable); + $foo->acceptNullable($nullable); +}