From d04d50cb9758131e586c8bc5859682b86cad2b97 Mon Sep 17 00:00:00 2001 From: Jan Nedbal Date: Mon, 25 May 2026 09:43:13 +0200 Subject: [PATCH 1/2] 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 00306e738b..073d30331f 100644 --- a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php @@ -284,6 +284,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 d9ad04eb52a57e265d8a8dd95bbb9d3fb0a25362 Mon Sep 17 00:00:00 2001 From: Jan Nedbal Date: Mon, 25 May 2026 13:33:17 +0200 Subject: [PATCH 2/2] Retrigger CI