From e9c2bbaff8907a31e47a4d34116fc3e5ab8a6150 Mon Sep 17 00:00:00 2001 From: VincentLanglet <9052536+VincentLanglet@users.noreply.github.com> Date: Thu, 14 May 2026 22:25:15 +0000 Subject: [PATCH 01/10] Do not treat `method_exists()` as always true for `@method`-annotated methods - In `ImpossibleCheckTypeHelper::findSpecifiedType()`, the `method_exists()` check used `hasMethod()` which includes `@method` PHPDoc annotations. Since `@method` methods are virtual (handled by `__call()`), they don't guarantee `method_exists()` returns true at runtime. - Added `hasNativeMethod()` check for object types to only report always-true when the method is actually declared in PHP code, not just via `@method`. - Applied the same fix for the generic class-string code path. - Verified that `property_exists()` already handles `@property` correctly via `PropertyExistsTypeSpecifyingExtension::isNative()` check. --- .../Comparison/ImpossibleCheckTypeHelper.php | 24 ++++- ...mpossibleCheckTypeFunctionCallRuleTest.php | 23 +++++ .../Rules/Comparison/data/bug-6211.php | 89 +++++++++++++++++++ 3 files changed, 133 insertions(+), 3 deletions(-) create mode 100644 tests/PHPStan/Rules/Comparison/data/bug-6211.php diff --git a/src/Rules/Comparison/ImpossibleCheckTypeHelper.php b/src/Rules/Comparison/ImpossibleCheckTypeHelper.php index 25c70705792..fa75f5b14c5 100644 --- a/src/Rules/Comparison/ImpossibleCheckTypeHelper.php +++ b/src/Rules/Comparison/ImpossibleCheckTypeHelper.php @@ -211,7 +211,18 @@ public function findSpecifiedType( if ($objectType->getObjectClassNames() !== []) { if ($objectType->hasMethod($methodType->getValue())->yes()) { - return true; + $hasNonNativeMethod = false; + foreach ($objectType->getObjectClassReflections() as $classReflection) { + if (!$classReflection->hasNativeMethod($methodType->getValue())) { + $hasNonNativeMethod = true; + break; + } + } + if (!$hasNonNativeMethod) { + return true; + } + + return null; } if ($objectType->hasMethod($methodType->getValue())->no()) { @@ -230,11 +241,18 @@ public function findSpecifiedType( }); if ($genericType instanceof TypeWithClassName) { + $classReflection = $genericType->getClassReflection(); if ($genericType->hasMethod($methodType->getValue())->yes()) { - return true; + if ( + $classReflection !== null + && $classReflection->hasNativeMethod($methodType->getValue()) + ) { + return true; + } + + return null; } - $classReflection = $genericType->getClassReflection(); if ( $classReflection !== null && $classReflection->isFinal() diff --git a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php index 17b6d29c7b5..d6daef0fc92 100644 --- a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php @@ -1230,4 +1230,27 @@ public function testBug8217(): void $this->analyse([__DIR__ . '/data/bug-8217.php'], []); } + public function testBug6211(): void + { + $this->treatPhpDocTypesAsCertain = true; + $this->analyse([__DIR__ . '/data/bug-6211.php'], [ + [ + 'Call to function method_exists() with Bug6211\Hell and \'test\' will always evaluate to true.', + 34, + ], + [ + 'Call to function method_exists() with \'Bug6211\\\\Hell\' and \'test\' will always evaluate to true.', + 39, + ], + [ + 'Call to function method_exists() with Bug6211\Bar and \'realMethod\' will always evaluate to true.', + 62, + ], + [ + 'Call to function property_exists() with Bug6211\Baz and \'realProp\' will always evaluate to true.', + 87, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Comparison/data/bug-6211.php b/tests/PHPStan/Rules/Comparison/data/bug-6211.php new file mode 100644 index 00000000000..82c398fe66e --- /dev/null +++ b/tests/PHPStan/Rules/Comparison/data/bug-6211.php @@ -0,0 +1,89 @@ + Date: Thu, 14 May 2026 23:06:42 +0000 Subject: [PATCH 02/10] Report nested method_exists() as always-true when already specified When inside an `if (method_exists(...))` block, a repeated method_exists() check for the same object and method is redundant and should be reported as always-true, even for @method-annotated methods. To detect this, the MethodExistsTypeSpecifyingExtension now also tracks the method_exists() FuncCall expression as ConstantBooleanType(true) in the scope (in addition to narrowing the argument type). This allows ImpossibleCheckTypeHelper to detect the nested case via Scope::hasExpressionType() before any type-specific logic runs. Co-Authored-By: Claude Opus 4.6 --- .../Comparison/ImpossibleCheckTypeHelper.php | 4 ++++ .../Php/MethodExistsTypeSpecifyingExtension.php | 11 +++++++++-- .../ImpossibleCheckTypeFunctionCallRuleTest.php | 16 ++++++++++++---- tests/PHPStan/Rules/Comparison/data/bug-6211.php | 6 ++++++ 4 files changed, 31 insertions(+), 6 deletions(-) diff --git a/src/Rules/Comparison/ImpossibleCheckTypeHelper.php b/src/Rules/Comparison/ImpossibleCheckTypeHelper.php index fa75f5b14c5..3e55cc35b91 100644 --- a/src/Rules/Comparison/ImpossibleCheckTypeHelper.php +++ b/src/Rules/Comparison/ImpossibleCheckTypeHelper.php @@ -205,6 +205,10 @@ public function findSpecifiedType( $methodType = $this->treatPhpDocTypesAsCertain ? $scope->getType($methodArg) : $scope->getNativeType($methodArg); if ($methodType instanceof ConstantStringType) { + if ($scope->hasExpressionType($node)->yes()) { + return true; + } + if ($objectType instanceof ConstantStringType) { $objectType = new ObjectType($objectType->getValue()); } diff --git a/src/Type/Php/MethodExistsTypeSpecifyingExtension.php b/src/Type/Php/MethodExistsTypeSpecifyingExtension.php index 3e42c9c42e5..e1a90c48e6f 100644 --- a/src/Type/Php/MethodExistsTypeSpecifyingExtension.php +++ b/src/Type/Php/MethodExistsTypeSpecifyingExtension.php @@ -61,6 +61,13 @@ public function specifyTypes( ); } + $funcCallSpec = $this->typeSpecifier->create( + new FuncCall(new FullyQualified('method_exists'), $node->getRawArgs()), + new ConstantBooleanType(true), + $context, + $scope, + ); + $objectType = $scope->getType($args[0]->value); if ($objectType->isString()->yes()) { if ($objectType->isClassString()->yes()) { @@ -72,7 +79,7 @@ public function specifyTypes( ]), $context, $scope, - ); + )->unionWith($funcCallSpec); } return new SpecifiedTypes([], []); @@ -89,7 +96,7 @@ public function specifyTypes( ]), $context, $scope, - ); + )->unionWith($funcCallSpec); } } diff --git a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php index d6daef0fc92..5e4ca30dc7e 100644 --- a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php @@ -1235,20 +1235,28 @@ public function testBug6211(): void $this->treatPhpDocTypesAsCertain = true; $this->analyse([__DIR__ . '/data/bug-6211.php'], [ [ - 'Call to function method_exists() with Bug6211\Hell and \'test\' will always evaluate to true.', + 'Call to function method_exists() with Bug6211\Hell and \'isTrue\' will always evaluate to true.', + 26, + ], + [ + 'Call to function method_exists() with \'Bug6211\\\\Hell\' and \'isTrue\' will always evaluate to true.', 34, ], + [ + 'Call to function method_exists() with Bug6211\Hell and \'test\' will always evaluate to true.', + 40, + ], [ 'Call to function method_exists() with \'Bug6211\\\\Hell\' and \'test\' will always evaluate to true.', - 39, + 45, ], [ 'Call to function method_exists() with Bug6211\Bar and \'realMethod\' will always evaluate to true.', - 62, + 68, ], [ 'Call to function property_exists() with Bug6211\Baz and \'realProp\' will always evaluate to true.', - 87, + 93, ], ]); } diff --git a/tests/PHPStan/Rules/Comparison/data/bug-6211.php b/tests/PHPStan/Rules/Comparison/data/bug-6211.php index 82c398fe66e..b38dbed2249 100644 --- a/tests/PHPStan/Rules/Comparison/data/bug-6211.php +++ b/tests/PHPStan/Rules/Comparison/data/bug-6211.php @@ -22,12 +22,18 @@ public function test(): bool // @method should not make method_exists always true if (\method_exists($hell, 'isTrue')) { + // nested method_exists should be reported - already specified to true + if (\method_exists($hell, 'isTrue')) { + } } // @method with class string should not make method_exists always true if (\method_exists(Hell::class, 'isTrue')) { + // nested method_exists should be reported - already specified to true + if (\method_exists(Hell::class, 'isTrue')) { + } } // native method should still be always true From 5db00ab9cbc68b73afef3f0a2176465037b85298 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Fri, 15 May 2026 07:35:05 +0000 Subject: [PATCH 03/10] Check hasNativeMethod() in MethodExistsTypeSpecifyingExtension like PropertyExistsTypeSpecifyingExtension Move the @method annotation check into MethodExistsTypeSpecifyingExtension to match how PropertyExistsTypeSpecifyingExtension handles @property annotations. When the method only exists via @method PHPDoc, return empty SpecifiedTypes so the type is not narrowed. Simplify ImpossibleCheckTypeHelper accordingly and remove the nested funcCallSpec approach. Co-Authored-By: Claude Opus 4.6 --- .../Comparison/ImpossibleCheckTypeHelper.php | 17 +++------- .../MethodExistsTypeSpecifyingExtension.php | 31 +++++++++++++------ ...mpossibleCheckTypeFunctionCallRuleTest.php | 16 +++------- .../Rules/Comparison/data/bug-6211.php | 6 ---- 4 files changed, 30 insertions(+), 40 deletions(-) diff --git a/src/Rules/Comparison/ImpossibleCheckTypeHelper.php b/src/Rules/Comparison/ImpossibleCheckTypeHelper.php index 3e55cc35b91..69d934aba8a 100644 --- a/src/Rules/Comparison/ImpossibleCheckTypeHelper.php +++ b/src/Rules/Comparison/ImpossibleCheckTypeHelper.php @@ -205,28 +205,18 @@ public function findSpecifiedType( $methodType = $this->treatPhpDocTypesAsCertain ? $scope->getType($methodArg) : $scope->getNativeType($methodArg); if ($methodType instanceof ConstantStringType) { - if ($scope->hasExpressionType($node)->yes()) { - return true; - } - if ($objectType instanceof ConstantStringType) { $objectType = new ObjectType($objectType->getValue()); } if ($objectType->getObjectClassNames() !== []) { if ($objectType->hasMethod($methodType->getValue())->yes()) { - $hasNonNativeMethod = false; foreach ($objectType->getObjectClassReflections() as $classReflection) { if (!$classReflection->hasNativeMethod($methodType->getValue())) { - $hasNonNativeMethod = true; - break; + return null; } } - if (!$hasNonNativeMethod) { - return true; - } - - return null; + return true; } if ($objectType->hasMethod($methodType->getValue())->no()) { @@ -245,8 +235,8 @@ public function findSpecifiedType( }); if ($genericType instanceof TypeWithClassName) { - $classReflection = $genericType->getClassReflection(); if ($genericType->hasMethod($methodType->getValue())->yes()) { + $classReflection = $genericType->getClassReflection(); if ( $classReflection !== null && $classReflection->hasNativeMethod($methodType->getValue()) @@ -257,6 +247,7 @@ public function findSpecifiedType( return null; } + $classReflection = $genericType->getClassReflection(); if ( $classReflection !== null && $classReflection->isFinal() diff --git a/src/Type/Php/MethodExistsTypeSpecifyingExtension.php b/src/Type/Php/MethodExistsTypeSpecifyingExtension.php index e1a90c48e6f..60ec71a0fe0 100644 --- a/src/Type/Php/MethodExistsTypeSpecifyingExtension.php +++ b/src/Type/Php/MethodExistsTypeSpecifyingExtension.php @@ -11,6 +11,7 @@ use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Reflection\FunctionReflection; +use PHPStan\Reflection\ReflectionProvider; use PHPStan\Type\Accessory\HasMethodType; use PHPStan\Type\ClassStringType; use PHPStan\Type\Constant\ConstantBooleanType; @@ -27,6 +28,10 @@ final class MethodExistsTypeSpecifyingExtension implements FunctionTypeSpecifyin private TypeSpecifier $typeSpecifier; + public function __construct(private ReflectionProvider $reflectionProvider) + { + } + public function setTypeSpecifier(TypeSpecifier $typeSpecifier): void { $this->typeSpecifier = $typeSpecifier; @@ -61,16 +66,18 @@ public function specifyTypes( ); } - $funcCallSpec = $this->typeSpecifier->create( - new FuncCall(new FullyQualified('method_exists'), $node->getRawArgs()), - new ConstantBooleanType(true), - $context, - $scope, - ); - $objectType = $scope->getType($args[0]->value); if ($objectType->isString()->yes()) { if ($objectType->isClassString()->yes()) { + foreach ($objectType->getConstantStrings() as $constantString) { + if ($this->reflectionProvider->hasClass($constantString->getValue())) { + $classReflection = $this->reflectionProvider->getClass($constantString->getValue()); + if ($classReflection->hasMethod($methodNameType->getValue()) && !$classReflection->hasNativeMethod($methodNameType->getValue())) { + return new SpecifiedTypes([], []); + } + } + } + return $this->typeSpecifier->create( $args[0]->value, new IntersectionType([ @@ -79,12 +86,18 @@ public function specifyTypes( ]), $context, $scope, - )->unionWith($funcCallSpec); + ); } return new SpecifiedTypes([], []); } + foreach ($objectType->getObjectClassReflections() as $classReflection) { + if ($classReflection->hasMethod($methodNameType->getValue()) && !$classReflection->hasNativeMethod($methodNameType->getValue())) { + return new SpecifiedTypes([], []); + } + } + return $this->typeSpecifier->create( $args[0]->value, new UnionType([ @@ -96,7 +109,7 @@ public function specifyTypes( ]), $context, $scope, - )->unionWith($funcCallSpec); + ); } } diff --git a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php index 5e4ca30dc7e..d6daef0fc92 100644 --- a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php @@ -1234,29 +1234,21 @@ public function testBug6211(): void { $this->treatPhpDocTypesAsCertain = true; $this->analyse([__DIR__ . '/data/bug-6211.php'], [ - [ - 'Call to function method_exists() with Bug6211\Hell and \'isTrue\' will always evaluate to true.', - 26, - ], - [ - 'Call to function method_exists() with \'Bug6211\\\\Hell\' and \'isTrue\' will always evaluate to true.', - 34, - ], [ 'Call to function method_exists() with Bug6211\Hell and \'test\' will always evaluate to true.', - 40, + 34, ], [ 'Call to function method_exists() with \'Bug6211\\\\Hell\' and \'test\' will always evaluate to true.', - 45, + 39, ], [ 'Call to function method_exists() with Bug6211\Bar and \'realMethod\' will always evaluate to true.', - 68, + 62, ], [ 'Call to function property_exists() with Bug6211\Baz and \'realProp\' will always evaluate to true.', - 93, + 87, ], ]); } diff --git a/tests/PHPStan/Rules/Comparison/data/bug-6211.php b/tests/PHPStan/Rules/Comparison/data/bug-6211.php index b38dbed2249..82c398fe66e 100644 --- a/tests/PHPStan/Rules/Comparison/data/bug-6211.php +++ b/tests/PHPStan/Rules/Comparison/data/bug-6211.php @@ -22,18 +22,12 @@ public function test(): bool // @method should not make method_exists always true if (\method_exists($hell, 'isTrue')) { - // nested method_exists should be reported - already specified to true - if (\method_exists($hell, 'isTrue')) { - } } // @method with class string should not make method_exists always true if (\method_exists(Hell::class, 'isTrue')) { - // nested method_exists should be reported - already specified to true - if (\method_exists(Hell::class, 'isTrue')) { - } } // native method should still be always true From b20d983ae8bf6cb9085c0283f066f173ab909798 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Fri, 15 May 2026 13:37:16 +0000 Subject: [PATCH 04/10] Report nested method_exists()/property_exists() as always-true for @method/@property When method_exists() or property_exists() checks a method/property that only exists via @method/@property PHPDoc annotation, the type specifying extensions now store the FuncCall expression as ConstantBooleanType(true) in scope instead of returning empty SpecifiedTypes. This enables ImpossibleCheckTypeHelper to detect nested (redundant) checks as always-true while still not reporting the outer check. An early check in findSpecifiedType() detects when a FuncCall expression already has a stored constant boolean type in scope and returns it directly, bypassing the special-case logic that would otherwise return null for @method-only methods. Co-Authored-By: Claude Opus 4.6 --- .../Comparison/ImpossibleCheckTypeHelper.php | 10 ++++++ .../MethodExistsTypeSpecifyingExtension.php | 10 ++++-- .../PropertyExistsTypeSpecifyingExtension.php | 7 +++- ...mpossibleCheckTypeFunctionCallRuleTest.php | 28 +++++++++++++++ .../Rules/Comparison/data/bug-6211.php | 35 +++++++++++++++++++ 5 files changed, 87 insertions(+), 3 deletions(-) diff --git a/src/Rules/Comparison/ImpossibleCheckTypeHelper.php b/src/Rules/Comparison/ImpossibleCheckTypeHelper.php index 69d934aba8a..b4dfc6f501e 100644 --- a/src/Rules/Comparison/ImpossibleCheckTypeHelper.php +++ b/src/Rules/Comparison/ImpossibleCheckTypeHelper.php @@ -58,6 +58,16 @@ public function findSpecifiedType( Expr $node, ): ?bool { + if ($node instanceof FuncCall && $scope->hasExpressionType($node)->yes()) { + $nodeType = $this->treatPhpDocTypesAsCertain ? $scope->getType($node) : $scope->getNativeType($node); + if ($nodeType->isTrue()->yes()) { + return true; + } + if ($nodeType->isFalse()->yes()) { + return false; + } + } + if ($node instanceof FuncCall) { if ($node->isFirstClassCallable()) { return null; diff --git a/src/Type/Php/MethodExistsTypeSpecifyingExtension.php b/src/Type/Php/MethodExistsTypeSpecifyingExtension.php index 60ec71a0fe0..f3834c042f4 100644 --- a/src/Type/Php/MethodExistsTypeSpecifyingExtension.php +++ b/src/Type/Php/MethodExistsTypeSpecifyingExtension.php @@ -67,13 +67,19 @@ public function specifyTypes( } $objectType = $scope->getType($args[0]->value); + $funcCallSpec = $this->typeSpecifier->create( + new FuncCall(new FullyQualified('method_exists'), $node->getRawArgs()), + new ConstantBooleanType(true), + $context, + $scope, + ); if ($objectType->isString()->yes()) { if ($objectType->isClassString()->yes()) { foreach ($objectType->getConstantStrings() as $constantString) { if ($this->reflectionProvider->hasClass($constantString->getValue())) { $classReflection = $this->reflectionProvider->getClass($constantString->getValue()); if ($classReflection->hasMethod($methodNameType->getValue()) && !$classReflection->hasNativeMethod($methodNameType->getValue())) { - return new SpecifiedTypes([], []); + return $funcCallSpec; } } } @@ -94,7 +100,7 @@ public function specifyTypes( foreach ($objectType->getObjectClassReflections() as $classReflection) { if ($classReflection->hasMethod($methodNameType->getValue()) && !$classReflection->hasNativeMethod($methodNameType->getValue())) { - return new SpecifiedTypes([], []); + return $funcCallSpec; } } diff --git a/src/Type/Php/PropertyExistsTypeSpecifyingExtension.php b/src/Type/Php/PropertyExistsTypeSpecifyingExtension.php index b299f6f14fd..c49addee4be 100644 --- a/src/Type/Php/PropertyExistsTypeSpecifyingExtension.php +++ b/src/Type/Php/PropertyExistsTypeSpecifyingExtension.php @@ -85,7 +85,12 @@ public function specifyTypes( $propertyReflection = $this->propertyReflectionFinder->findPropertyReflectionFromNode($propertyNode, $scope); if ($propertyReflection !== null) { if (!$propertyReflection->isNative()) { - return new SpecifiedTypes([], []); + return $this->typeSpecifier->create( + new FuncCall(new FullyQualified('property_exists'), $node->getRawArgs()), + new ConstantBooleanType(true), + $context, + $scope, + ); } } diff --git a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php index d6daef0fc92..4f886a46e4f 100644 --- a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php @@ -1250,6 +1250,34 @@ public function testBug6211(): void 'Call to function property_exists() with Bug6211\Baz and \'realProp\' will always evaluate to true.', 87, ], + [ + 'Call to function method_exists() with Bug6211\Hell and \'isTrue\' will always evaluate to true.', + 93, + ], + [ + 'Call to function method_exists() with \'Bug6211\\\\Hell\' and \'isTrue\' will always evaluate to true.', + 100, + ], + [ + 'Call to function method_exists() with Bug6211\Hell and \'test\' will always evaluate to true.', + 106, + ], + [ + 'Call to function method_exists() with Bug6211\Hell and \'test\' will always evaluate to true.', + 107, + ], + [ + 'Call to function property_exists() with Bug6211\Baz and \'magicProp\' will always evaluate to true.', + 114, + ], + [ + 'Call to function property_exists() with Bug6211\Baz and \'realProp\' will always evaluate to true.', + 120, + ], + [ + 'Call to function property_exists() with Bug6211\Baz and \'realProp\' will always evaluate to true.', + 121, + ], ]); } diff --git a/tests/PHPStan/Rules/Comparison/data/bug-6211.php b/tests/PHPStan/Rules/Comparison/data/bug-6211.php index 82c398fe66e..e65031134ee 100644 --- a/tests/PHPStan/Rules/Comparison/data/bug-6211.php +++ b/tests/PHPStan/Rules/Comparison/data/bug-6211.php @@ -87,3 +87,38 @@ public function __get(string $name): mixed if (\property_exists($baz, 'realProp')) { } + +// Nested method_exists with @method should report the inner as always-true +if (\method_exists($hell, 'isTrue')) { + if (\method_exists($hell, 'isTrue')) { // should be reported + + } +} + +// Nested method_exists with @method via class-string +if (\method_exists(Hell::class, 'isTrue')) { + if (\method_exists(Hell::class, 'isTrue')) { // should be reported + + } +} + +// Nested method_exists with native method (already always-true, inner is also) +if (\method_exists($hell, 'test')) { + if (\method_exists($hell, 'test')) { // should be reported + + } +} + +// Nested property_exists with @property should report the inner as always-true +if (\property_exists($baz, 'magicProp')) { + if (\property_exists($baz, 'magicProp')) { // should be reported + + } +} + +// Nested property_exists with native property (already always-true, inner is also) +if (\property_exists($baz, 'realProp')) { + if (\property_exists($baz, 'realProp')) { // should be reported + + } +} From e430f77296d6f958465f3240e0f297a4eed2c73d Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Fri, 15 May 2026 14:10:48 +0000 Subject: [PATCH 05/10] Handle @method on generic class-strings in MethodExistsTypeSpecifyingExtension The hasNativeMethod() check in ImpossibleCheckTypeHelper for generic class-strings (class-string) is still needed because the helper runs its own hasMethod() check before the TypeSpecifier flow. Without the guard, method_exists() with @method-only methods on generic class-strings would be incorrectly reported as always-true. This commit also adds the same hasNativeMethod() guard to MethodExistsTypeSpecifyingExtension for generic class-strings, matching the existing handling for constant strings and object types. Co-Authored-By: Claude Opus 4.6 --- .../Php/MethodExistsTypeSpecifyingExtension.php | 6 ++++++ .../ImpossibleCheckTypeFunctionCallRuleTest.php | 5 +++++ tests/PHPStan/Rules/Comparison/data/bug-6211.php | 15 +++++++++++++++ 3 files changed, 26 insertions(+) diff --git a/src/Type/Php/MethodExistsTypeSpecifyingExtension.php b/src/Type/Php/MethodExistsTypeSpecifyingExtension.php index f3834c042f4..3c191d3f330 100644 --- a/src/Type/Php/MethodExistsTypeSpecifyingExtension.php +++ b/src/Type/Php/MethodExistsTypeSpecifyingExtension.php @@ -84,6 +84,12 @@ public function specifyTypes( } } + foreach ($objectType->getClassStringObjectType()->getObjectClassReflections() as $classReflection) { + if ($classReflection->hasMethod($methodNameType->getValue()) && !$classReflection->hasNativeMethod($methodNameType->getValue())) { + return $funcCallSpec; + } + } + return $this->typeSpecifier->create( $args[0]->value, new IntersectionType([ diff --git a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php index 4f886a46e4f..0691c56ec4b 100644 --- a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php @@ -1278,6 +1278,11 @@ public function testBug6211(): void 'Call to function property_exists() with Bug6211\Baz and \'realProp\' will always evaluate to true.', 121, ], + [ + 'Call to function method_exists() with class-string and \'test\' will always evaluate to true.', + 136, + 'Because the type is coming from a PHPDoc, you can turn off this check by setting treatPhpDocTypesAsCertain: false in your %configurationFile%.', + ], ]); } diff --git a/tests/PHPStan/Rules/Comparison/data/bug-6211.php b/tests/PHPStan/Rules/Comparison/data/bug-6211.php index e65031134ee..5fa553a25ea 100644 --- a/tests/PHPStan/Rules/Comparison/data/bug-6211.php +++ b/tests/PHPStan/Rules/Comparison/data/bug-6211.php @@ -122,3 +122,18 @@ public function __get(string $name): mixed } } + +/** + * @param class-string $classString + */ +function testGenericClassString(string $classString): void { + // @method via generic class-string should not make method_exists always true + if (\method_exists($classString, 'isTrue')) { + + } + + // native method via generic class-string should still be always true + if (\method_exists($classString, 'test')) { + + } +} From 9530d1a0e96dd86408ee7a809765c07e71c75b27 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Fri, 15 May 2026 14:16:32 +0000 Subject: [PATCH 06/10] Extract createFuncCallSpec() private method to deduplicate FuncCall spec creation Co-Authored-By: Claude Opus 4.6 --- .../MethodExistsTypeSpecifyingExtension.php | 27 +++++++++---------- .../PropertyExistsTypeSpecifyingExtension.php | 24 ++++++++--------- 2 files changed, 25 insertions(+), 26 deletions(-) diff --git a/src/Type/Php/MethodExistsTypeSpecifyingExtension.php b/src/Type/Php/MethodExistsTypeSpecifyingExtension.php index 3c191d3f330..949cd2e4029 100644 --- a/src/Type/Php/MethodExistsTypeSpecifyingExtension.php +++ b/src/Type/Php/MethodExistsTypeSpecifyingExtension.php @@ -58,28 +58,17 @@ public function specifyTypes( $args = $node->getArgs(); $methodNameType = $scope->getType($args[1]->value); if (!$methodNameType instanceof ConstantStringType) { - return $this->typeSpecifier->create( - new FuncCall(new FullyQualified('method_exists'), $node->getRawArgs()), - new ConstantBooleanType(true), - $context, - $scope, - ); + return $this->createFuncCallSpec($node, $context, $scope); } $objectType = $scope->getType($args[0]->value); - $funcCallSpec = $this->typeSpecifier->create( - new FuncCall(new FullyQualified('method_exists'), $node->getRawArgs()), - new ConstantBooleanType(true), - $context, - $scope, - ); if ($objectType->isString()->yes()) { if ($objectType->isClassString()->yes()) { foreach ($objectType->getConstantStrings() as $constantString) { if ($this->reflectionProvider->hasClass($constantString->getValue())) { $classReflection = $this->reflectionProvider->getClass($constantString->getValue()); if ($classReflection->hasMethod($methodNameType->getValue()) && !$classReflection->hasNativeMethod($methodNameType->getValue())) { - return $funcCallSpec; + return $this->createFuncCallSpec($node, $context, $scope); } } } @@ -106,7 +95,7 @@ public function specifyTypes( foreach ($objectType->getObjectClassReflections() as $classReflection) { if ($classReflection->hasMethod($methodNameType->getValue()) && !$classReflection->hasNativeMethod($methodNameType->getValue())) { - return $funcCallSpec; + return $this->createFuncCallSpec($node, $context, $scope); } } @@ -124,4 +113,14 @@ public function specifyTypes( ); } + private function createFuncCallSpec(FuncCall $node, TypeSpecifierContext $context, Scope $scope): SpecifiedTypes + { + return $this->typeSpecifier->create( + new FuncCall(new FullyQualified('method_exists'), $node->getRawArgs()), + new ConstantBooleanType(true), + $context, + $scope, + ); + } + } diff --git a/src/Type/Php/PropertyExistsTypeSpecifyingExtension.php b/src/Type/Php/PropertyExistsTypeSpecifyingExtension.php index c49addee4be..3c6405295d8 100644 --- a/src/Type/Php/PropertyExistsTypeSpecifyingExtension.php +++ b/src/Type/Php/PropertyExistsTypeSpecifyingExtension.php @@ -58,12 +58,7 @@ public function specifyTypes( $args = $node->getArgs(); $propertyNameType = $scope->getType($args[1]->value); if (!$propertyNameType instanceof ConstantStringType) { - return $this->typeSpecifier->create( - new FuncCall(new FullyQualified('property_exists'), $node->getRawArgs()), - new ConstantBooleanType(true), - $context, - $scope, - ); + return $this->createFuncCallSpec($node, $context, $scope); } if ($propertyNameType->getValue() === '') { @@ -85,12 +80,7 @@ public function specifyTypes( $propertyReflection = $this->propertyReflectionFinder->findPropertyReflectionFromNode($propertyNode, $scope); if ($propertyReflection !== null) { if (!$propertyReflection->isNative()) { - return $this->typeSpecifier->create( - new FuncCall(new FullyQualified('property_exists'), $node->getRawArgs()), - new ConstantBooleanType(true), - $context, - $scope, - ); + return $this->createFuncCallSpec($node, $context, $scope); } } @@ -105,4 +95,14 @@ public function specifyTypes( ); } + private function createFuncCallSpec(FuncCall $node, TypeSpecifierContext $context, Scope $scope): SpecifiedTypes + { + return $this->typeSpecifier->create( + new FuncCall(new FullyQualified('property_exists'), $node->getRawArgs()), + new ConstantBooleanType(true), + $context, + $scope, + ); + } + } From 2529cc1c9cf24fe53bc8af322868d861e67f279b Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Fri, 15 May 2026 14:22:09 +0000 Subject: [PATCH 07/10] Fix missed $funcCallSpec reference in getClassStringObjectType() path Co-Authored-By: Claude Opus 4.6 --- src/Type/Php/MethodExistsTypeSpecifyingExtension.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Type/Php/MethodExistsTypeSpecifyingExtension.php b/src/Type/Php/MethodExistsTypeSpecifyingExtension.php index 949cd2e4029..9ec69cdd1d6 100644 --- a/src/Type/Php/MethodExistsTypeSpecifyingExtension.php +++ b/src/Type/Php/MethodExistsTypeSpecifyingExtension.php @@ -75,7 +75,7 @@ public function specifyTypes( foreach ($objectType->getClassStringObjectType()->getObjectClassReflections() as $classReflection) { if ($classReflection->hasMethod($methodNameType->getValue()) && !$classReflection->hasNativeMethod($methodNameType->getValue())) { - return $funcCallSpec; + return $this->createFuncCallSpec($node, $context, $scope); } } From 91bed6ec8594260c0203ea76490397ccf2bfbd20 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Fri, 15 May 2026 14:23:30 +0000 Subject: [PATCH 08/10] Remove redundant early check from ImpossibleCheckTypeHelper, refactor funcCallSpec creation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The early $scope->hasExpressionType() check in findSpecifiedType() was unnecessary — nested method_exists()/property_exists() for @method/@property annotations are already caught by IfConstantConditionRule through the funcCallSpec stored in scope. This matches how nested checks with variable method names are already handled. Extract funcCallSpec creation into private createFuncCallSpec() methods in both MethodExistsTypeSpecifyingExtension and PropertyExistsTypeSpecifyingExtension to avoid duplication and eager creation. Co-Authored-By: Claude Opus 4.6 --- src/Rules/Comparison/ImpossibleCheckTypeHelper.php | 10 ---------- .../ImpossibleCheckTypeFunctionCallRuleTest.php | 12 ------------ 2 files changed, 22 deletions(-) diff --git a/src/Rules/Comparison/ImpossibleCheckTypeHelper.php b/src/Rules/Comparison/ImpossibleCheckTypeHelper.php index b4dfc6f501e..69d934aba8a 100644 --- a/src/Rules/Comparison/ImpossibleCheckTypeHelper.php +++ b/src/Rules/Comparison/ImpossibleCheckTypeHelper.php @@ -58,16 +58,6 @@ public function findSpecifiedType( Expr $node, ): ?bool { - if ($node instanceof FuncCall && $scope->hasExpressionType($node)->yes()) { - $nodeType = $this->treatPhpDocTypesAsCertain ? $scope->getType($node) : $scope->getNativeType($node); - if ($nodeType->isTrue()->yes()) { - return true; - } - if ($nodeType->isFalse()->yes()) { - return false; - } - } - if ($node instanceof FuncCall) { if ($node->isFirstClassCallable()) { return null; diff --git a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php index 0691c56ec4b..02c61941e13 100644 --- a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php @@ -1250,14 +1250,6 @@ public function testBug6211(): void 'Call to function property_exists() with Bug6211\Baz and \'realProp\' will always evaluate to true.', 87, ], - [ - 'Call to function method_exists() with Bug6211\Hell and \'isTrue\' will always evaluate to true.', - 93, - ], - [ - 'Call to function method_exists() with \'Bug6211\\\\Hell\' and \'isTrue\' will always evaluate to true.', - 100, - ], [ 'Call to function method_exists() with Bug6211\Hell and \'test\' will always evaluate to true.', 106, @@ -1266,10 +1258,6 @@ public function testBug6211(): void 'Call to function method_exists() with Bug6211\Hell and \'test\' will always evaluate to true.', 107, ], - [ - 'Call to function property_exists() with Bug6211\Baz and \'magicProp\' will always evaluate to true.', - 114, - ], [ 'Call to function property_exists() with Bug6211\Baz and \'realProp\' will always evaluate to true.', 120, From 7838b269461d7fa8f60b4cff5e47a90aee27d879 Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Fri, 15 May 2026 16:53:02 +0200 Subject: [PATCH 09/10] Add tests --- .../IfConstantConditionRuleTest.php | 19 +++++++++++++++++++ .../Rules/Comparison/data/bug-6211.php | 10 +++++----- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/tests/PHPStan/Rules/Comparison/IfConstantConditionRuleTest.php b/tests/PHPStan/Rules/Comparison/IfConstantConditionRuleTest.php index 0861db6f447..5a847ad0293 100644 --- a/tests/PHPStan/Rules/Comparison/IfConstantConditionRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/IfConstantConditionRuleTest.php @@ -236,6 +236,25 @@ public function testBug6822(): void $this->analyse([__DIR__ . '/../../Analyser/nsrt/bug-6822.php'], []); } + public function testBug6211(): void + { + $this->treatPhpDocTypesAsCertain = true; + $this->analyse([__DIR__ . '/data/bug-6211.php'], [ + [ + 'If condition is always true.', + 93, + ], + [ + 'If condition is always true.', + 100, + ], + [ + 'If condition is always true.', + 114, + ], + ]); + } + public function testBug5020(): void { $this->treatPhpDocTypesAsCertain = true; diff --git a/tests/PHPStan/Rules/Comparison/data/bug-6211.php b/tests/PHPStan/Rules/Comparison/data/bug-6211.php index 5fa553a25ea..9d5b806db5b 100644 --- a/tests/PHPStan/Rules/Comparison/data/bug-6211.php +++ b/tests/PHPStan/Rules/Comparison/data/bug-6211.php @@ -90,35 +90,35 @@ public function __get(string $name): mixed // Nested method_exists with @method should report the inner as always-true if (\method_exists($hell, 'isTrue')) { - if (\method_exists($hell, 'isTrue')) { // should be reported + if (\method_exists($hell, 'isTrue')) { // if condition always true } } // Nested method_exists with @method via class-string if (\method_exists(Hell::class, 'isTrue')) { - if (\method_exists(Hell::class, 'isTrue')) { // should be reported + if (\method_exists(Hell::class, 'isTrue')) { // if condition always true } } // Nested method_exists with native method (already always-true, inner is also) if (\method_exists($hell, 'test')) { - if (\method_exists($hell, 'test')) { // should be reported + if (\method_exists($hell, 'test')) { } } // Nested property_exists with @property should report the inner as always-true if (\property_exists($baz, 'magicProp')) { - if (\property_exists($baz, 'magicProp')) { // should be reported + if (\property_exists($baz, 'magicProp')) { // if condition always true } } // Nested property_exists with native property (already always-true, inner is also) if (\property_exists($baz, 'realProp')) { - if (\property_exists($baz, 'realProp')) { // should be reported + if (\property_exists($baz, 'realProp')) { } } From 69c96b830433a723ccd5f2626aee6e24bf49e43f Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Sat, 16 May 2026 09:25:55 +0000 Subject: [PATCH 10/10] Use early exit in MethodExistsTypeSpecifyingExtension to reduce nesting Co-Authored-By: Claude Opus 4.6 --- src/Type/Php/MethodExistsTypeSpecifyingExtension.php | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/Type/Php/MethodExistsTypeSpecifyingExtension.php b/src/Type/Php/MethodExistsTypeSpecifyingExtension.php index 9ec69cdd1d6..32ad358ba8a 100644 --- a/src/Type/Php/MethodExistsTypeSpecifyingExtension.php +++ b/src/Type/Php/MethodExistsTypeSpecifyingExtension.php @@ -65,11 +65,13 @@ public function specifyTypes( if ($objectType->isString()->yes()) { if ($objectType->isClassString()->yes()) { foreach ($objectType->getConstantStrings() as $constantString) { - if ($this->reflectionProvider->hasClass($constantString->getValue())) { - $classReflection = $this->reflectionProvider->getClass($constantString->getValue()); - if ($classReflection->hasMethod($methodNameType->getValue()) && !$classReflection->hasNativeMethod($methodNameType->getValue())) { - return $this->createFuncCallSpec($node, $context, $scope); - } + if (!$this->reflectionProvider->hasClass($constantString->getValue())) { + continue; + } + + $classReflection = $this->reflectionProvider->getClass($constantString->getValue()); + if ($classReflection->hasMethod($methodNameType->getValue()) && !$classReflection->hasNativeMethod($methodNameType->getValue())) { + return $this->createFuncCallSpec($node, $context, $scope); } }