From 2da7358ffc9e1d06c41d6c1b0f6da84ae04c5cd3 Mon Sep 17 00:00:00 2001 From: Jan Nedbal Date: Mon, 25 May 2026 09:43:13 +0200 Subject: [PATCH 1/6] Report impossible class_exists/interface_exists/trait_exists/enum_exists Detect via reflection when the constant-string argument names a struct of the wrong kind (e.g. class_exists() on an interface name) and narrow the argument to never in the truthy context. Drop the ImpossibleCheckTypeHelper bailout for these four functions so the rule can pick up the impossibility, while still suppressing "always true" since runtime autoload can fail. Closes https://github.com/phpstan/phpstan/issues/14683 --- .../Comparison/ImpossibleCheckTypeHelper.php | 38 +++++++++---- ...sExistsFunctionTypeSpecifyingExtension.php | 54 +++++++++++++++++++ tests/PHPStan/Analyser/nsrt/bug-14683.php | 48 +++++++++++++++++ ...mpossibleCheckTypeFunctionCallRuleTest.php | 19 +++++++ ...check-type-function-call-struct-exists.php | 41 ++++++++++++++ 5 files changed, 191 insertions(+), 9 deletions(-) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-14683.php create mode 100644 tests/PHPStan/Rules/Comparison/data/check-type-function-call-struct-exists.php diff --git a/src/Rules/Comparison/ImpossibleCheckTypeHelper.php b/src/Rules/Comparison/ImpossibleCheckTypeHelper.php index 0a1621c842..6b4a685502 100644 --- a/src/Rules/Comparison/ImpossibleCheckTypeHelper.php +++ b/src/Rules/Comparison/ImpossibleCheckTypeHelper.php @@ -58,6 +58,7 @@ public function findSpecifiedType( Expr $node, ): ?bool { + $onlyReportFalse = false; if ($node instanceof FuncCall) { if ($node->isFirstClassCallable()) { return null; @@ -66,6 +67,17 @@ public function findSpecifiedType( $argsCount = count($args); if ($node->name instanceof Node\Name) { $functionName = strtolower((string) $node->name); + if (in_array($functionName, [ + 'class_exists', + 'interface_exists', + 'trait_exists', + 'enum_exists', + ], true)) { + // Runtime autoload can always fail, so do not report "always true" for these. + // "Always false" is still reportable when the type specifier proves impossibility + // (e.g. class_exists() on a constant string that names an interface). + $onlyReportFalse = true; + } if ($functionName === 'assert' && $argsCount >= 1) { $arg = $args[0]->value; $assertValue = ($this->treatPhpDocTypesAsCertain ? $scope->getType($arg) : $scope->getNativeType($arg))->toBoolean(); @@ -76,13 +88,7 @@ public function findSpecifiedType( return $assertValueIsTrue; } - if (in_array($functionName, [ - 'class_exists', - 'interface_exists', - 'trait_exists', - 'enum_exists', - 'function_exists', - ], true)) { + if ($functionName === 'function_exists') { return null; } if (in_array($functionName, ['count', 'sizeof'], true)) { @@ -284,7 +290,12 @@ public function findSpecifiedType( $rootExprType = $this->treatPhpDocTypesAsCertain ? $scope->getType($rootExpr) : $scope->getNativeType($rootExpr); if ($rootExprType instanceof ConstantBooleanType) { - return $rootExprType->getValue(); + $value = $rootExprType->getValue(); + if ($onlyReportFalse && $value === true) { + return null; + } + + return $value; } return null; @@ -362,7 +373,16 @@ public function findSpecifiedType( } $result = TrinaryLogic::createYes()->and(...$results); - return $result->maybe() ? null : $result->yes(); + if ($result->maybe()) { + return null; + } + + $value = $result->yes(); + if ($onlyReportFalse && $value === true) { + return null; + } + + return $value; } private static function isSpecified(Scope $scope, Expr $node, Expr $expr): bool diff --git a/src/Type/Php/ClassExistsFunctionTypeSpecifyingExtension.php b/src/Type/Php/ClassExistsFunctionTypeSpecifyingExtension.php index b70e6ac15e..84c37b4e1c 100644 --- a/src/Type/Php/ClassExistsFunctionTypeSpecifyingExtension.php +++ b/src/Type/Php/ClassExistsFunctionTypeSpecifyingExtension.php @@ -13,14 +13,18 @@ use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Node\Expr\AlwaysRememberedExpr; +use PHPStan\Reflection\ClassReflection; use PHPStan\Reflection\FunctionReflection; +use PHPStan\Reflection\ReflectionProvider; use PHPStan\ShouldNotHappenException; use PHPStan\Type\BooleanType; use PHPStan\Type\ClassStringType; use PHPStan\Type\Constant\ConstantBooleanType; use PHPStan\Type\FunctionTypeSpecifyingExtension; use PHPStan\Type\Generic\GenericClassStringType; +use PHPStan\Type\NeverType; use PHPStan\Type\ObjectType; +use PHPStan\Type\Type; use function count; use function in_array; use function ltrim; @@ -31,6 +35,10 @@ final class ClassExistsFunctionTypeSpecifyingExtension implements FunctionTypeSp private TypeSpecifier $typeSpecifier; + public function __construct(private ReflectionProvider $reflectionProvider) + { + } + public function isFunctionSupported( FunctionReflection $functionReflection, FuncCall $node, @@ -49,6 +57,16 @@ public function specifyTypes(FunctionReflection $functionReflection, FuncCall $n { $args = $node->getArgs(); $argType = $scope->getType($args[0]->value); + $functionName = $functionReflection->getName(); + + if ($this->isCallAlwaysFalse($functionName, $argType)) { + return $this->typeSpecifier->create( + $args[0]->value, + new NeverType(), + $context, + $scope, + ); + } // class_exists() will only assure one of the classes to exist. $constantStrings = $argType->getConstantStrings(); @@ -101,4 +119,40 @@ public function setTypeSpecifier(TypeSpecifier $typeSpecifier): void $this->typeSpecifier = $typeSpecifier; } + private function isCallAlwaysFalse(string $functionName, Type $argType): bool + { + $constantStrings = $argType->getConstantStrings(); + if ($constantStrings === []) { + return false; + } + + foreach ($constantStrings as $constantString) { + $name = ltrim($constantString->getValue(), '\\'); + if (!$this->reflectionProvider->hasClass($name)) { + return false; + } + if ($this->matchesFunctionKind($functionName, $this->reflectionProvider->getClass($name))) { + return false; + } + } + + return true; + } + + private function matchesFunctionKind(string $functionName, ClassReflection $reflection): bool + { + switch ($functionName) { + case 'class_exists': + return !$reflection->isInterface() && !$reflection->isTrait(); + case 'interface_exists': + return $reflection->isInterface(); + case 'trait_exists': + return $reflection->isTrait(); + case 'enum_exists': + return $reflection->isEnum(); + default: + return true; + } + } + } diff --git a/tests/PHPStan/Analyser/nsrt/bug-14683.php b/tests/PHPStan/Analyser/nsrt/bug-14683.php new file mode 100644 index 0000000000..b3200dfd83 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-14683.php @@ -0,0 +1,48 @@ += 8.1 + +declare(strict_types = 1); + +namespace Bug14683; + +use function PHPStan\Testing\assertType; + +interface SomeInterface {} +class SomeClass {} +trait SomeTrait {} +enum SomeEnum {} + +function classExistsOnConstantStringInterface(): void +{ + if (class_exists(SomeInterface::class)) { + assertType('*NEVER*', SomeInterface::class); + } +} + +function classExistsOnConstantStringTrait(): void +{ + if (class_exists(SomeTrait::class)) { + assertType('*NEVER*', SomeTrait::class); + } +} + +function interfaceExistsOnConstantStringClass(): void +{ + if (interface_exists(SomeClass::class)) { + assertType('*NEVER*', SomeClass::class); + } +} + +function enumExistsOnConstantStringEnum(): void +{ + if (enum_exists(SomeEnum::class)) { + assertType('\'Bug14683\\\\SomeEnum\'', SomeEnum::class); + } +} + +function classExistsOnEnumConstantString(): void +{ + // enums are classes in PHP, so this is NOT impossible + if (class_exists(SomeEnum::class)) { + assertType('\'Bug14683\\\\SomeEnum\'', SomeEnum::class); + } +} diff --git a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php index a72250dfc0..8cf7ea1ba1 100644 --- a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php @@ -290,6 +290,25 @@ public function testBug7898(): void $this->analyse([__DIR__ . '/data/bug-7898.php'], []); } + #[RequiresPhp('>= 8.1.0')] + public function testStructExists(): void + { + $this->treatPhpDocTypesAsCertain = true; + $this->analyse([__DIR__ . '/data/check-type-function-call-struct-exists.php'], [ + ['Call to function class_exists() with \'CheckTypeFunctionCall\\\\_Interface\' will always evaluate to false.', 23], + ['Call to function class_exists() with \'CheckTypeFunctionCall\\\\_Trait\' will always evaluate to false.', 25], + ['Call to function interface_exists() with \'CheckTypeFunctionCall\\\\_Enum\' will always evaluate to false.', 27], + ['Call to function interface_exists() with \'CheckTypeFunctionCall\\\\_Class\' will always evaluate to false.', 29], + ['Call to function interface_exists() with \'CheckTypeFunctionCall\\\\_Trait\' will always evaluate to false.', 30], + ['Call to function trait_exists() with \'CheckTypeFunctionCall\\\\_Enum\' will always evaluate to false.', 32], + ['Call to function trait_exists() with \'CheckTypeFunctionCall\\\\_Interface\' will always evaluate to false.', 33], + ['Call to function trait_exists() with \'CheckTypeFunctionCall\\\\_Class\' will always evaluate to false.', 34], + ['Call to function enum_exists() with \'CheckTypeFunctionCall\\\\_Interface\' will always evaluate to false.', 38], + ['Call to function enum_exists() with \'CheckTypeFunctionCall\\\\_Class\' will always evaluate to false.', 39], + ['Call to function enum_exists() with \'CheckTypeFunctionCall\\\\_Trait\' will always evaluate to false.', 40], + ]); + } + public function testDoNotReportTypesFromPhpDocs(): void { $this->treatPhpDocTypesAsCertain = false; diff --git a/tests/PHPStan/Rules/Comparison/data/check-type-function-call-struct-exists.php b/tests/PHPStan/Rules/Comparison/data/check-type-function-call-struct-exists.php new file mode 100644 index 0000000000..285d6f59f2 --- /dev/null +++ b/tests/PHPStan/Rules/Comparison/data/check-type-function-call-struct-exists.php @@ -0,0 +1,41 @@ += 8.1 + +namespace CheckTypeFunctionCall; + +// see https://3v4l.org/V9nmf + +interface _Interface {} +class _Class {} +trait _Trait {} +enum _Enum {} + +class_exists($mixed); +interface_exists($mixed); +enum_exists($mixed); +trait_exists($mixed); + +class_exists(); +interface_exists(); +enum_exists(); +trait_exists(); + +class_exists(_Enum::class); +class_exists(_Interface::class); // always false +class_exists(_Class::class); +class_exists(_Trait::class); // always false + +interface_exists(_Enum::class); // always false +interface_exists(_Interface::class); +interface_exists(_Class::class); // always false +interface_exists(_Trait::class); // always false + +trait_exists(_Enum::class); // always false +trait_exists(_Interface::class); // always false +trait_exists(_Class::class); // always false +trait_exists(_Trait::class); + +enum_exists(_Enum::class); +enum_exists(_Interface::class); // always false +enum_exists(_Class::class); // always false +enum_exists(_Trait::class); // always false + From 9a08e13c1363d91ee2a7c10df0c8fa7baaba7910 Mon Sep 17 00:00:00 2001 From: Jan Nedbal Date: Mon, 25 May 2026 13:33:17 +0200 Subject: [PATCH 2/6] Retrigger CI From d201db630ee4a9e169ab158b22cfa7f3c1c1164e Mon Sep 17 00:00:00 2001 From: Jan Nedbal Date: Mon, 1 Jun 2026 09:28:31 +0200 Subject: [PATCH 3/6] Extract autoload-existence suppression into decideSpecifiedTypeWasFound Replace the early-declared $onlyReportFalse flag with a method that post-processes the candidate result at each return site, deciding based on the expression whether the "always true" result must be suppressed. Co-Authored-By: Claude Code --- .../Comparison/ImpossibleCheckTypeHelper.php | 52 +++++++++++-------- 1 file changed, 29 insertions(+), 23 deletions(-) diff --git a/src/Rules/Comparison/ImpossibleCheckTypeHelper.php b/src/Rules/Comparison/ImpossibleCheckTypeHelper.php index 6b4a685502..454f5e8e22 100644 --- a/src/Rules/Comparison/ImpossibleCheckTypeHelper.php +++ b/src/Rules/Comparison/ImpossibleCheckTypeHelper.php @@ -58,7 +58,6 @@ public function findSpecifiedType( Expr $node, ): ?bool { - $onlyReportFalse = false; if ($node instanceof FuncCall) { if ($node->isFirstClassCallable()) { return null; @@ -67,17 +66,6 @@ public function findSpecifiedType( $argsCount = count($args); if ($node->name instanceof Node\Name) { $functionName = strtolower((string) $node->name); - if (in_array($functionName, [ - 'class_exists', - 'interface_exists', - 'trait_exists', - 'enum_exists', - ], true)) { - // Runtime autoload can always fail, so do not report "always true" for these. - // "Always false" is still reportable when the type specifier proves impossibility - // (e.g. class_exists() on a constant string that names an interface). - $onlyReportFalse = true; - } if ($functionName === 'assert' && $argsCount >= 1) { $arg = $args[0]->value; $assertValue = ($this->treatPhpDocTypesAsCertain ? $scope->getType($arg) : $scope->getNativeType($arg))->toBoolean(); @@ -290,12 +278,7 @@ public function findSpecifiedType( $rootExprType = $this->treatPhpDocTypesAsCertain ? $scope->getType($rootExpr) : $scope->getNativeType($rootExpr); if ($rootExprType instanceof ConstantBooleanType) { - $value = $rootExprType->getValue(); - if ($onlyReportFalse && $value === true) { - return null; - } - - return $value; + return $this->decideSpecifiedTypeWasFound($node, $rootExprType->getValue()); } return null; @@ -373,16 +356,39 @@ public function findSpecifiedType( } $result = TrinaryLogic::createYes()->and(...$results); - if ($result->maybe()) { + + return $this->decideSpecifiedTypeWasFound($node, $result->maybe() ? null : $result->yes()); + } + + /** + * Post-processes a candidate result to decide whether it should be reported. + * + * For class_exists()/interface_exists()/trait_exists()/enum_exists() the "always true" + * result is suppressed, because runtime autoload can always fail. "Always false" is still + * reported when the type specifier proves impossibility (e.g. class_exists() on a constant + * string that names an interface). + */ + private function decideSpecifiedTypeWasFound(Expr $node, ?bool $result): ?bool + { + if ($result === true && $this->isAutoloadableExistenceCheck($node)) { return null; } - $value = $result->yes(); - if ($onlyReportFalse && $value === true) { - return null; + return $result; + } + + private function isAutoloadableExistenceCheck(Expr $node): bool + { + if (!$node instanceof FuncCall || !$node->name instanceof Node\Name) { + return false; } - return $value; + return in_array(strtolower((string) $node->name), [ + 'class_exists', + 'interface_exists', + 'trait_exists', + 'enum_exists', + ], true); } private static function isSpecified(Scope $scope, Expr $node, Expr $expr): bool From ba761bdc70d7f19bbc626b706ac719858752a259 Mon Sep 17 00:00:00 2001 From: Jan Nedbal Date: Mon, 1 Jun 2026 09:41:26 +0200 Subject: [PATCH 4/6] Inline isAutoloadableExistenceCheck into decideSpecifiedTypeWasFound Co-Authored-By: Claude Code --- .../Comparison/ImpossibleCheckTypeHelper.php | 26 ++++++++----------- 1 file changed, 11 insertions(+), 15 deletions(-) diff --git a/src/Rules/Comparison/ImpossibleCheckTypeHelper.php b/src/Rules/Comparison/ImpossibleCheckTypeHelper.php index 454f5e8e22..6474b4cd17 100644 --- a/src/Rules/Comparison/ImpossibleCheckTypeHelper.php +++ b/src/Rules/Comparison/ImpossibleCheckTypeHelper.php @@ -370,27 +370,23 @@ public function findSpecifiedType( */ private function decideSpecifiedTypeWasFound(Expr $node, ?bool $result): ?bool { - if ($result === true && $this->isAutoloadableExistenceCheck($node)) { + if ( + $result === true + && $node instanceof FuncCall + && $node->name instanceof Node\Name + && in_array(strtolower((string) $node->name), [ + 'class_exists', + 'interface_exists', + 'trait_exists', + 'enum_exists', + ], true) + ) { return null; } return $result; } - private function isAutoloadableExistenceCheck(Expr $node): bool - { - if (!$node instanceof FuncCall || !$node->name instanceof Node\Name) { - return false; - } - - return in_array(strtolower((string) $node->name), [ - 'class_exists', - 'interface_exists', - 'trait_exists', - 'enum_exists', - ], true); - } - private static function isSpecified(Scope $scope, Expr $node, Expr $expr): bool { if ($expr === $node) { From 04ec4583b7eaef7919d35bc561236cce1f6685a2 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Tue, 2 Jun 2026 07:47:51 +0200 Subject: [PATCH 5/6] simplify --- .../Comparison/ImpossibleCheckTypeHelper.php | 63 ++++++++++--------- 1 file changed, 34 insertions(+), 29 deletions(-) diff --git a/src/Rules/Comparison/ImpossibleCheckTypeHelper.php b/src/Rules/Comparison/ImpossibleCheckTypeHelper.php index 6474b4cd17..69d59ceffb 100644 --- a/src/Rules/Comparison/ImpossibleCheckTypeHelper.php +++ b/src/Rules/Comparison/ImpossibleCheckTypeHelper.php @@ -57,6 +57,38 @@ public function findSpecifiedType( Scope $scope, Expr $node, ): ?bool + { + $specifiedValue = $this->getSpecifiedType($scope, $node); + + /** + * Post-processes a candidate result to decide whether it should be reported. + * + * For class_exists()/interface_exists()/trait_exists()/enum_exists() the "always true" + * result is suppressed, because runtime autoload can always fail. "Always false" is still + * reported when the type specifier proves impossibility (e.g. class_exists() on a constant + * string that names an interface). + */ + if ( + $specifiedValue === true + && $node instanceof FuncCall + && $node->name instanceof Node\Name + && in_array(strtolower((string) $node->name), [ + 'class_exists', + 'interface_exists', + 'trait_exists', + 'enum_exists', + ], true) + ) { + return null; + } + + return $specifiedValue; + } + + private function getSpecifiedType( + Scope $scope, + Expr $node, + ): ?bool { if ($node instanceof FuncCall) { if ($node->isFirstClassCallable()) { @@ -278,7 +310,7 @@ public function findSpecifiedType( $rootExprType = $this->treatPhpDocTypesAsCertain ? $scope->getType($rootExpr) : $scope->getNativeType($rootExpr); if ($rootExprType instanceof ConstantBooleanType) { - return $this->decideSpecifiedTypeWasFound($node, $rootExprType->getValue()); + return $rootExprType->getValue(); } return null; @@ -357,34 +389,7 @@ public function findSpecifiedType( $result = TrinaryLogic::createYes()->and(...$results); - return $this->decideSpecifiedTypeWasFound($node, $result->maybe() ? null : $result->yes()); - } - - /** - * Post-processes a candidate result to decide whether it should be reported. - * - * For class_exists()/interface_exists()/trait_exists()/enum_exists() the "always true" - * result is suppressed, because runtime autoload can always fail. "Always false" is still - * reported when the type specifier proves impossibility (e.g. class_exists() on a constant - * string that names an interface). - */ - private function decideSpecifiedTypeWasFound(Expr $node, ?bool $result): ?bool - { - if ( - $result === true - && $node instanceof FuncCall - && $node->name instanceof Node\Name - && in_array(strtolower((string) $node->name), [ - 'class_exists', - 'interface_exists', - 'trait_exists', - 'enum_exists', - ], true) - ) { - return null; - } - - return $result; + return $result->maybe() ? null : $result->yes(); } private static function isSpecified(Scope $scope, Expr $node, Expr $expr): bool From 02178a6792617a9a759224c60285973d0207cb9d Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Tue, 2 Jun 2026 07:48:50 +0200 Subject: [PATCH 6/6] Update ImpossibleCheckTypeHelper.php --- src/Rules/Comparison/ImpossibleCheckTypeHelper.php | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/Rules/Comparison/ImpossibleCheckTypeHelper.php b/src/Rules/Comparison/ImpossibleCheckTypeHelper.php index 69d59ceffb..1c2298e97d 100644 --- a/src/Rules/Comparison/ImpossibleCheckTypeHelper.php +++ b/src/Rules/Comparison/ImpossibleCheckTypeHelper.php @@ -61,8 +61,6 @@ public function findSpecifiedType( $specifiedValue = $this->getSpecifiedType($scope, $node); /** - * Post-processes a candidate result to decide whether it should be reported. - * * For class_exists()/interface_exists()/trait_exists()/enum_exists() the "always true" * result is suppressed, because runtime autoload can always fail. "Always false" is still * reported when the type specifier proves impossibility (e.g. class_exists() on a constant