From 22cae7546bd75ae47454a6f560a04fc31232bc3a Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Sat, 9 May 2026 10:17:43 +0200 Subject: [PATCH 1/3] Test result-cache restore does not trigger reflection (#5617) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Petr Morávek --- .github/workflows/e2e-tests.yml | 5 ++++ .../.gitignore | 2 ++ .../composer.json | 17 +++++++++++++ .../lib/ThrowingSourceLocator.php | 25 +++++++++++++++++++ .../phpstan.neon | 10 ++++++++ .../src/foo.php | 4 +++ 6 files changed, 63 insertions(+) create mode 100644 e2e/result-cache-restore-without-reflection/.gitignore create mode 100644 e2e/result-cache-restore-without-reflection/composer.json create mode 100644 e2e/result-cache-restore-without-reflection/lib/ThrowingSourceLocator.php create mode 100644 e2e/result-cache-restore-without-reflection/phpstan.neon create mode 100644 e2e/result-cache-restore-without-reflection/src/foo.php diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 821be9f3779..9f4e46ed86b 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -301,6 +301,11 @@ jobs: echo "$OUTPUT" ../bashunit -a matches "Note: Using configuration file .+phpstan.neon." "$OUTPUT" ../bashunit -a contains 'Result cache not used because the metadata do not match: metaExtensions' "$OUTPUT" + - script: | + cd e2e/result-cache-restore-without-reflection + composer install + ../../bin/phpstan -vvv + ../../bin/phpstan -vvv - script: | cd e2e/bug-12606 export CONFIGTEST=test diff --git a/e2e/result-cache-restore-without-reflection/.gitignore b/e2e/result-cache-restore-without-reflection/.gitignore new file mode 100644 index 00000000000..8b7ef350326 --- /dev/null +++ b/e2e/result-cache-restore-without-reflection/.gitignore @@ -0,0 +1,2 @@ +/vendor +composer.lock diff --git a/e2e/result-cache-restore-without-reflection/composer.json b/e2e/result-cache-restore-without-reflection/composer.json new file mode 100644 index 00000000000..e21da9a5a48 --- /dev/null +++ b/e2e/result-cache-restore-without-reflection/composer.json @@ -0,0 +1,17 @@ +{ + "require-dev": { + "phpstan/phpstan-symfony": "@dev", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan-doctrine": "@dev" + }, + "autoload": { + "classmap": [ + "lib/" + ] + }, + "config": { + "allow-plugins": { + "phpstan/extension-installer": true + } + } +} diff --git a/e2e/result-cache-restore-without-reflection/lib/ThrowingSourceLocator.php b/e2e/result-cache-restore-without-reflection/lib/ThrowingSourceLocator.php new file mode 100644 index 00000000000..197e78e755d --- /dev/null +++ b/e2e/result-cache-restore-without-reflection/lib/ThrowingSourceLocator.php @@ -0,0 +1,25 @@ + Date: Sat, 9 May 2026 10:24:13 +0200 Subject: [PATCH 2/3] Test result-cache restore does not trigger reflection in all 1st party extensions (#5618) --- .../composer.json | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/e2e/result-cache-restore-without-reflection/composer.json b/e2e/result-cache-restore-without-reflection/composer.json index e21da9a5a48..c0d4ee43781 100644 --- a/e2e/result-cache-restore-without-reflection/composer.json +++ b/e2e/result-cache-restore-without-reflection/composer.json @@ -2,7 +2,14 @@ "require-dev": { "phpstan/phpstan-symfony": "@dev", "phpstan/extension-installer": "^1.4", - "phpstan/phpstan-doctrine": "@dev" + "phpstan/phpstan-doctrine": "@dev", + "phpstan/phpstan-beberlei-assert": "@dev", + "phpstan/phpstan-phpunit": "@dev", + "phpstan/phpstan-webmozart-assert": "@dev", + "phpstan/phpstan-mockery": "@dev", + "phpstan/phpstan-nette": "@dev", + "phpstan/phpstan-dibi": "@dev", + "php-standard-library/phpstan-extension": "@dev" }, "autoload": { "classmap": [ From ecf16daaf71f3572097dfba090eb5163b64acfcc Mon Sep 17 00:00:00 2001 From: phpstan-bot <79867460+phpstan-bot@users.noreply.github.com> Date: Sat, 9 May 2026 10:42:19 +0200 Subject: [PATCH 3/3] Mark `class_exists`, `interface_exists`, `trait_exists`, and `enum_exists` as having no side effects in function metadata (#5607) Co-authored-by: VincentLanglet <9052536+VincentLanglet@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 --- src/Node/Expr/AlwaysRememberedExpr.php | 1 + ...sExistsFunctionTypeSpecifyingExtension.php | 9 ++-- ...member-possibly-impure-function-values.php | 47 +++++++++++++++++++ ...otRememberPossiblyImpureValuesRuleTest.php | 35 ++++++++++++++ tests/PHPStan/Rules/Classes/data/bug-8579.php | 13 +++++ .../doNotRememberPossiblyImpureValues.neon | 2 + 6 files changed, 104 insertions(+), 3 deletions(-) create mode 100644 tests/PHPStan/Rules/Classes/InstantiationDoNotRememberPossiblyImpureValuesRuleTest.php create mode 100644 tests/PHPStan/Rules/Classes/data/bug-8579.php create mode 100644 tests/PHPStan/Rules/Classes/doNotRememberPossiblyImpureValues.neon diff --git a/src/Node/Expr/AlwaysRememberedExpr.php b/src/Node/Expr/AlwaysRememberedExpr.php index 71f40b97244..f389fac356b 100644 --- a/src/Node/Expr/AlwaysRememberedExpr.php +++ b/src/Node/Expr/AlwaysRememberedExpr.php @@ -7,6 +7,7 @@ use PHPStan\Node\VirtualNode; use PHPStan\Type\Type; +/** Wraps an expression so its type is always remembered in the scope, bypassing impurity checks. */ final class AlwaysRememberedExpr extends Expr implements VirtualNode { diff --git a/src/Type/Php/ClassExistsFunctionTypeSpecifyingExtension.php b/src/Type/Php/ClassExistsFunctionTypeSpecifyingExtension.php index 8265a598b1f..06c1aad5937 100644 --- a/src/Type/Php/ClassExistsFunctionTypeSpecifyingExtension.php +++ b/src/Type/Php/ClassExistsFunctionTypeSpecifyingExtension.php @@ -12,7 +12,9 @@ use PHPStan\Analyser\TypeSpecifierAwareExtension; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; +use PHPStan\Node\Expr\AlwaysRememberedExpr; use PHPStan\Reflection\FunctionReflection; +use PHPStan\Type\BooleanType; use PHPStan\Type\ClassStringType; use PHPStan\Type\Constant\ConstantBooleanType; use PHPStan\Type\Constant\ConstantStringType; @@ -47,10 +49,11 @@ public function specifyTypes(FunctionReflection $functionReflection, FuncCall $n $args = $node->getArgs(); $argType = $scope->getType($args[0]->value); if ($argType instanceof ConstantStringType) { + $funcCall = new FuncCall(new FullyQualified('class_exists'), [ + new Arg(new String_(ltrim($argType->getValue(), '\\'))), + ]); return $this->typeSpecifier->create( - new FuncCall(new FullyQualified('class_exists'), [ - new Arg(new String_(ltrim($argType->getValue(), '\\'))), - ]), + new AlwaysRememberedExpr($funcCall, new BooleanType(), new BooleanType()), new ConstantBooleanType(true), $context, $scope, diff --git a/tests/PHPStan/Analyser/data/do-not-remember-possibly-impure-function-values.php b/tests/PHPStan/Analyser/data/do-not-remember-possibly-impure-function-values.php index 5adbbc52200..158ab83dec7 100644 --- a/tests/PHPStan/Analyser/data/do-not-remember-possibly-impure-function-values.php +++ b/tests/PHPStan/Analyser/data/do-not-remember-possibly-impure-function-values.php @@ -109,3 +109,50 @@ function test(): void assertType('int', impure()); } } + +function testClassExistsRemembered(): void +{ + if (\class_exists('Bug8579RememberedA')) { + assertType('true', \class_exists('Bug8579RememberedA')); + } else { + assertType('bool', \class_exists('Bug8579RememberedA')); + } + + assertType('bool', \class_exists('Bug8579RememberedA')); +} + +function testClassExistsFalseNotRemembered(): void +{ + if (!\class_exists('Bug8579FalseNotRememberedA')) { + assertType('bool', \class_exists('Bug8579FalseNotRememberedA')); + } + + assertType('bool', \class_exists('Bug8579FalseNotRememberedA')); +} + +function testInterfaceExistsFalseNotRemembered(): void +{ + if (!\interface_exists('Bug8579FalseNotRememberedC')) { + assertType('bool', \interface_exists('Bug8579FalseNotRememberedC')); + } + + assertType('bool', \interface_exists('Bug8579FalseNotRememberedC')); +} + +function testTraitExistsFalseNotRemembered(): void +{ + if (!\trait_exists('Bug8579FalseNotRememberedD')) { + assertType('bool', \trait_exists('Bug8579FalseNotRememberedD')); + } + + assertType('bool', \trait_exists('Bug8579FalseNotRememberedD')); +} + +function testEnumExistsFalseNotRemembered(): void +{ + if (!\enum_exists('Bug8579FalseNotRememberedE')) { + assertType('bool', \enum_exists('Bug8579FalseNotRememberedE')); + } + + assertType('bool', \enum_exists('Bug8579FalseNotRememberedE')); +} diff --git a/tests/PHPStan/Rules/Classes/InstantiationDoNotRememberPossiblyImpureValuesRuleTest.php b/tests/PHPStan/Rules/Classes/InstantiationDoNotRememberPossiblyImpureValuesRuleTest.php new file mode 100644 index 00000000000..d40092f3932 --- /dev/null +++ b/tests/PHPStan/Rules/Classes/InstantiationDoNotRememberPossiblyImpureValuesRuleTest.php @@ -0,0 +1,35 @@ + + */ +class InstantiationDoNotRememberPossiblyImpureValuesRuleTest extends RuleTestCase +{ + + protected function getRule(): Rule + { + return self::getContainer()->getByType(InstantiationRule::class); + } + + public function testBug8579(): void + { + $this->analyse([__DIR__ . '/data/bug-8579.php'], []); + } + + public static function getAdditionalConfigFiles(): array + { + return array_merge( + parent::getAdditionalConfigFiles(), + [ + __DIR__ . '/doNotRememberPossiblyImpureValues.neon', + ], + ); + } + +} diff --git a/tests/PHPStan/Rules/Classes/data/bug-8579.php b/tests/PHPStan/Rules/Classes/data/bug-8579.php new file mode 100644 index 00000000000..018ab54678c --- /dev/null +++ b/tests/PHPStan/Rules/Classes/data/bug-8579.php @@ -0,0 +1,13 @@ +