From d7925b3309233a7a18ca2f499e9d0d07cf88eb9c Mon Sep 17 00:00:00 2001 From: ondrejmirtes <104888+ondrejmirtes@users.noreply.github.com> Date: Mon, 14 Sep 2026 10:06:51 +0000 Subject: [PATCH 1/4] Update PhpStorm stubs --- composer.json | 2 +- composer.lock | 14 +++++++------- resources/functionMetadata.php | 3 +++ 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/composer.json b/composer.json index 5d167be2ea..9f5025779b 100644 --- a/composer.json +++ b/composer.json @@ -26,7 +26,7 @@ "hoa/compiler": "3.17.08.08", "hoa/exception": "^1.0", "hoa/file": "1.17.07.11", - "jetbrains/phpstorm-stubs": "dev-master#709e512210784a7c0a677b3a89d35def844a59b9", + "jetbrains/phpstorm-stubs": "dev-master#e4f5f6c3de39f3bab3e9f3fca4b8cdb8b061e681", "nette/bootstrap": "^3.0", "nette/di": "^3.1.10", "nette/neon": "3.3.4", diff --git a/composer.lock b/composer.lock index 32703b3942..008a399526 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "db2851f465e721f4d9359ca5171e9ac5", + "content-hash": "1695e0a01d8cab6ccd93819aa3179bff", "packages": [ { "name": "clue/ndjson-react", @@ -1637,19 +1637,19 @@ "source": { "type": "git", "url": "https://github.com/JetBrains/phpstorm-stubs", - "reference": "709e512210784a7c0a677b3a89d35def844a59b9" + "reference": "e4f5f6c3de39f3bab3e9f3fca4b8cdb8b061e681" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/JetBrains/phpstorm-stubs/zipball/709e512210784a7c0a677b3a89d35def844a59b9", - "reference": "709e512210784a7c0a677b3a89d35def844a59b9", + "url": "https://api.github.com/repos/JetBrains/phpstorm-stubs/zipball/e4f5f6c3de39f3bab3e9f3fca4b8cdb8b061e681", + "reference": "e4f5f6c3de39f3bab3e9f3fca4b8cdb8b061e681", "shasum": "" }, "require-dev": { "friendsofphp/php-cs-fixer": "^v3.86", "nikic/php-parser": "^v5.6", - "phpdocumentor/reflection-docblock": "^5.6", - "phpunit/phpunit": "^12.3" + "phpdocumentor/reflection-docblock": "^6.0", + "phpunit/phpunit": "^13.2" }, "default-branch": true, "type": "library", @@ -1674,7 +1674,7 @@ "stubs", "type" ], - "time": "2026-06-12T13:19:10+00:00" + "time": "2026-09-07T18:07:02+00:00" }, { "name": "nette/bootstrap", diff --git a/resources/functionMetadata.php b/resources/functionMetadata.php index addae7ff10..794d9ce6c3 100644 --- a/resources/functionMetadata.php +++ b/resources/functionMetadata.php @@ -1105,6 +1105,7 @@ 'grapheme_stristr' => ['hasSideEffects' => false], 'grapheme_strlen' => ['hasSideEffects' => false], 'grapheme_strpos' => ['hasSideEffects' => false], + 'grapheme_strrev' => ['hasSideEffects' => false], 'grapheme_strripos' => ['hasSideEffects' => false], 'grapheme_strrpos' => ['hasSideEffects' => false], 'grapheme_strstr' => ['hasSideEffects' => false], @@ -1324,6 +1325,8 @@ 'locale_filter_matches' => ['hasSideEffects' => false], 'locale_get_all_variants' => ['hasSideEffects' => false], 'locale_get_default' => ['hasSideEffects' => false], + 'locale_get_display_keyword' => ['hasSideEffects' => false], + 'locale_get_display_keyword_value' => ['hasSideEffects' => false], 'locale_get_display_language' => ['hasSideEffects' => false], 'locale_get_display_name' => ['hasSideEffects' => false], 'locale_get_display_region' => ['hasSideEffects' => false], From 14db9d8560d8cbe48b586909a9a4854980121453 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Mon, 14 Sep 2026 13:09:54 +0200 Subject: [PATCH 2/4] Add ReflectionClassConstant constructor throw type extension PhpStorm stubs now declare @throws \Exception on ReflectionClassConstant::__construct. Mirror the ReflectionProperty extension so that constructing it for a known class constant is not treated as throwing. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01W2hMwFhyMX7JHn6oVKrTPD --- ...sConstantConstructorThrowTypeExtension.php | 69 +++++++++++++++++++ .../CatchWithUnthrownExceptionRuleTest.php | 19 +++++ .../Exceptions/data/unthrown-exception.php | 48 +++++++++++++ 3 files changed, 136 insertions(+) create mode 100644 src/Type/Php/ReflectionClassConstantConstructorThrowTypeExtension.php diff --git a/src/Type/Php/ReflectionClassConstantConstructorThrowTypeExtension.php b/src/Type/Php/ReflectionClassConstantConstructorThrowTypeExtension.php new file mode 100644 index 0000000000..2e805decf8 --- /dev/null +++ b/src/Type/Php/ReflectionClassConstantConstructorThrowTypeExtension.php @@ -0,0 +1,69 @@ +getName() === '__construct' && $methodReflection->getDeclaringClass()->getName() === ReflectionClassConstant::class; + } + + public function getThrowTypeFromStaticMethodCall(MethodReflection $methodReflection, StaticCall $methodCall, Scope $scope): ?Type + { + if (count($methodCall->getArgs()) < 2) { + return $methodReflection->getThrowType(); + } + + $valueType = $scope->getType($methodCall->getArgs()[0]->value); + $constantType = $scope->getType($methodCall->getArgs()[1]->value); + foreach ($valueType->getConstantStrings() as $constantString) { + if (!$this->reflectionProvider->hasClass($constantString->getValue())) { + return $methodReflection->getThrowType(); + } + + $classReflection = $this->reflectionProvider->getClass($constantString->getValue()); + foreach ($constantType->getConstantStrings() as $constantConstantString) { + if (!$classReflection->hasConstant($constantConstantString->getValue())) { + return $methodReflection->getThrowType(); + } + } + + $valueType = TypeCombinator::remove($valueType, $constantString); + } + + if (!$valueType instanceof NeverType) { + return $methodReflection->getThrowType(); + } + + // Look for non constantStrings value. + foreach ($constantType->getConstantStrings() as $constantConstantString) { + $constantType = TypeCombinator::remove($constantType, $constantConstantString); + } + + if (!$constantType instanceof NeverType) { + return $methodReflection->getThrowType(); + } + + return null; + } + +} diff --git a/tests/PHPStan/Rules/Exceptions/CatchWithUnthrownExceptionRuleTest.php b/tests/PHPStan/Rules/Exceptions/CatchWithUnthrownExceptionRuleTest.php index d77785142b..248c5d0b21 100644 --- a/tests/PHPStan/Rules/Exceptions/CatchWithUnthrownExceptionRuleTest.php +++ b/tests/PHPStan/Rules/Exceptions/CatchWithUnthrownExceptionRuleTest.php @@ -141,6 +141,10 @@ public function testRule(): void 'Dead catch - ArithmeticError is never thrown in the try block.', 762, ], + [ + 'Dead catch - Exception is never thrown in the try block.', + 802, + ], ]); } @@ -233,6 +237,10 @@ public function testRuleWithoutReportingUncheckedException(): void 'Dead catch - Exception is never thrown in the try block.', 555, ], + [ + 'Dead catch - Exception is never thrown in the try block.', + 802, + ], ]); } @@ -842,4 +850,15 @@ public function testBug9826(): void $this->analyse([__DIR__ . '/data/bug-9826.php'], []); } + #[RequiresPhp('>= 8.0.0')] + public function testTriggerErrorThrowType(): void + { + $this->analyse([__DIR__ . '/data/trigger-error-throw-type.php'], [ + [ + 'Dead catch - Exception is never thrown in the try block.', + 39, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Exceptions/data/unthrown-exception.php b/tests/PHPStan/Rules/Exceptions/data/unthrown-exception.php index 0327fcb086..07e5b65ee5 100644 --- a/tests/PHPStan/Rules/Exceptions/data/unthrown-exception.php +++ b/tests/PHPStan/Rules/Exceptions/data/unthrown-exception.php @@ -790,3 +790,51 @@ public function doFoo(int $int, int $negativeInt, int $positiveInt): void } } + +class TestReflectionClassConstant +{ + public const FOO = 1; + + public function doFoo(): void + { + try { + new \ReflectionClassConstant(self::class, 'FOO'); + } catch (\Exception $e) { + + } + } + + public function doBar(): void + { + try { + new \ReflectionClassConstant(self::class, 'BAR'); + } catch (\Exception $e) { + + } + try { + new \ReflectionClassConstant(\DateTime::class, 'BAR'); + } catch (\Exception $e) { + + } + } + + public function doBaz(string $string): void + { + try { + new \ReflectionClassConstant($string, $string); + } catch (\Exception $e) { + + } + try { + new \ReflectionClassConstant(self::class, $string); + } catch (\Exception $e) { + + } + try { + new \ReflectionClassConstant($string, 'FOO'); + } catch (\Exception $e) { + + } + } + +} From bb1df5b4a292b271d5836f82b9467053fae5c860 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Mon, 14 Sep 2026 13:09:54 +0200 Subject: [PATCH 3/4] Model trigger_error() throw type through the error handler PhpStorm stubs now declare @throws \ValueError on trigger_error(), which replaced the never-returning "may throw Throwable" treatment of E_USER_ERROR and the implicit throw point of the other levels. Keep both, and report ValueError only for an invalid error level on PHP 8. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01W2hMwFhyMX7JHn6oVKrTPD --- ...TriggerErrorFunctionThrowTypeExtension.php | 109 ++++++++++++++++++ .../AbilityToDisableImplicitThrowsTest.php | 23 ++++ .../data/trigger-error-throw-type.php | 42 +++++++ 3 files changed, 174 insertions(+) create mode 100644 src/Type/Php/TriggerErrorFunctionThrowTypeExtension.php create mode 100644 tests/PHPStan/Rules/Exceptions/data/trigger-error-throw-type.php diff --git a/src/Type/Php/TriggerErrorFunctionThrowTypeExtension.php b/src/Type/Php/TriggerErrorFunctionThrowTypeExtension.php new file mode 100644 index 0000000000..49c61fc264 --- /dev/null +++ b/src/Type/Php/TriggerErrorFunctionThrowTypeExtension.php @@ -0,0 +1,109 @@ +getName() === 'trigger_error'; + } + + public function getThrowTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $funcCall, Scope $scope): ?Type + { + $args = $funcCall->getArgs(); + if (count($args) === 0) { + return $functionReflection->getThrowType(); + } + + $errorHandlerThrowType = $this->implicitThrows ? new ObjectType(Throwable::class) : null; + if (count($args) === 1) { + return $errorHandlerThrowType; + } + + $errorLevels = $scope->getType($args[1]->value)->getConstantScalarValues(); + if (count($errorLevels) === 0) { + if ($errorHandlerThrowType !== null) { + return $errorHandlerThrowType; + } + + return $this->getInvalidErrorLevelThrowType(); + } + + $throwTypes = []; + foreach ($errorLevels as $errorLevel) { + if ($errorLevel === E_USER_ERROR) { + $throwTypes[] = new ObjectType(Throwable::class); + continue; + } + + if (is_int($errorLevel) && in_array($errorLevel, self::NON_FATAL_ERROR_LEVELS, true)) { + if ($errorHandlerThrowType !== null) { + $throwTypes[] = $errorHandlerThrowType; + } + continue; + } + + $invalidErrorLevelThrowType = $this->getInvalidErrorLevelThrowType(); + if ($invalidErrorLevelThrowType === null) { + continue; + } + + $throwTypes[] = $invalidErrorLevelThrowType; + } + + if (count($throwTypes) === 0) { + return null; + } + + return TypeCombinator::union(...$throwTypes); + } + + private function getInvalidErrorLevelThrowType(): ?Type + { + if (!$this->phpVersion->throwsValueErrorForInternalFunctions()) { + return null; + } + + return new ObjectType(ValueError::class); + } + +} diff --git a/tests/PHPStan/Rules/Exceptions/AbilityToDisableImplicitThrowsTest.php b/tests/PHPStan/Rules/Exceptions/AbilityToDisableImplicitThrowsTest.php index 7c7bda7514..cbf67ffed2 100644 --- a/tests/PHPStan/Rules/Exceptions/AbilityToDisableImplicitThrowsTest.php +++ b/tests/PHPStan/Rules/Exceptions/AbilityToDisableImplicitThrowsTest.php @@ -97,6 +97,29 @@ public function testBug7799(): void ]); } + #[RequiresPhp('>= 8.0.0')] + public function testTriggerErrorThrowType(): void + { + $this->analyse([__DIR__ . '/data/trigger-error-throw-type.php'], [ + [ + 'Dead catch - Exception is never thrown in the try block.', + 14, + ], + [ + 'Dead catch - Exception is never thrown in the try block.', + 19, + ], + [ + 'Dead catch - ValueError is never thrown in the try block.', + 34, + ], + [ + 'Dead catch - Exception is never thrown in the try block.', + 39, + ], + ]); + } + public static function getAdditionalConfigFiles(): array { return array_merge( diff --git a/tests/PHPStan/Rules/Exceptions/data/trigger-error-throw-type.php b/tests/PHPStan/Rules/Exceptions/data/trigger-error-throw-type.php new file mode 100644 index 0000000000..a50cd9a6ff --- /dev/null +++ b/tests/PHPStan/Rules/Exceptions/data/trigger-error-throw-type.php @@ -0,0 +1,42 @@ += 8.0 + +namespace TriggerErrorThrowType; + +function doFoo(int $level): void +{ + try { + trigger_error('foo', E_USER_ERROR); + } catch (\Exception $e) { + + } + try { + trigger_error('foo', E_USER_DEPRECATED); + } catch (\Exception $e) { + + } + try { + trigger_error('foo'); + } catch (\Exception $e) { + + } + try { + trigger_error('foo', 12345); + } catch (\ValueError $e) { + + } + try { + trigger_error('foo', $level); + } catch (\ValueError $e) { + + } + try { + trigger_error('foo', E_USER_WARNING); + } catch (\ValueError $e) { + + } + try { + trigger_error('foo', 12345); + } catch (\Exception $e) { + + } +} From 40a4b949922ebfb4c6f5314ab7985c14cd7555d4 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Mon, 14 Sep 2026 13:09:54 +0200 Subject: [PATCH 4/4] Do not treat possible Error throws as an effect of an expression statement Errors (ValueError, TypeError, DivisionByZeroError, ...) signal programmer mistakes, nobody calls an otherwise pure expression just to have one thrown. Explicit throw points whose type is entirely a subtype of Error therefore no longer prevent the NoopExpressionNode, so sprintf(), strpos(), intdiv(), $a / $b and similar on a separate line are reported again after PhpStorm stubs added @throws \ValueError to them. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01W2hMwFhyMX7JHn6oVKrTPD --- .../StmtHandler/ExpressionHandler.php | 8 ++++- tests/PHPStan/Rules/DeadCode/NoopRuleTest.php | 19 ++++++++++++ .../Rules/DeadCode/data/noop-error-throws.php | 12 +++++++ ...ionStatementWithoutSideEffectsRuleTest.php | 26 ++++++++++++++++ ...statement-no-side-effects-error-throws.php | 31 +++++++++++++++++++ ...hodStatementWithoutSideEffectsRuleTest.php | 24 -------------- ...hodStatementWithoutSideEffectsRuleTest.php | 29 ++++------------- 7 files changed, 101 insertions(+), 48 deletions(-) create mode 100644 tests/PHPStan/Rules/DeadCode/data/noop-error-throws.php create mode 100644 tests/PHPStan/Rules/Functions/data/function-call-statement-no-side-effects-error-throws.php diff --git a/src/Analyser/StmtHandler/ExpressionHandler.php b/src/Analyser/StmtHandler/ExpressionHandler.php index 9db8b02f08..010f871562 100644 --- a/src/Analyser/StmtHandler/ExpressionHandler.php +++ b/src/Analyser/StmtHandler/ExpressionHandler.php @@ -2,6 +2,7 @@ namespace PHPStan\Analyser\StmtHandler; +use Error; use PhpParser\Node; use PhpParser\Node\Expr; use PhpParser\Node\Stmt; @@ -22,6 +23,7 @@ use PHPStan\Node\PropertyAssignNode; use PHPStan\Node\VariableAssignNode; use PHPStan\Type\NeverType; +use PHPStan\Type\ObjectType; use function array_filter; use function count; @@ -73,7 +75,11 @@ public function processStmt( } $nodeScopeResolver->callNodeCallback($nodeCallback, $stmt, $entryScope, $storage); - $throwPoints = array_filter($result->getThrowPoints(), static fn ($throwPoint) => $throwPoint->isExplicit()); + // Errors signal programmer mistakes (ValueError, TypeError, DivisionByZeroError...), + // nobody calls an otherwise pure expression just to have them thrown, so they + // do not make the expression statement meaningful. + $errorType = new ObjectType(Error::class); + $throwPoints = array_filter($result->getThrowPoints(), static fn ($throwPoint) => $throwPoint->isExplicit() && !$errorType->isSuperTypeOf($throwPoint->getType())->yes()); if ( count($result->getImpurePoints()) === 0 && count($throwPoints) === 0 diff --git a/tests/PHPStan/Rules/DeadCode/NoopRuleTest.php b/tests/PHPStan/Rules/DeadCode/NoopRuleTest.php index 164e981fc9..e79cc53a5d 100644 --- a/tests/PHPStan/Rules/DeadCode/NoopRuleTest.php +++ b/tests/PHPStan/Rules/DeadCode/NoopRuleTest.php @@ -140,6 +140,25 @@ public function testRule(): void ]); } + #[RequiresPhp('>= 8.0.0')] + public function testErrorThrows(): void + { + $this->analyse([__DIR__ . '/data/noop-error-throws.php'], [ + [ + 'Expression "$a / $b" on a separate line does not do anything.', + 6, + ], + [ + 'Expression "$a % $b" on a separate line does not do anything.', + 7, + ], + [ + 'Expression "match ($a) {…" on a separate line does not do anything.', + 8, + ], + ]); + } + public function testNullsafe(): void { $this->analyse([__DIR__ . '/data/nullsafe-property-fetch-noop.php'], [ diff --git a/tests/PHPStan/Rules/DeadCode/data/noop-error-throws.php b/tests/PHPStan/Rules/DeadCode/data/noop-error-throws.php new file mode 100644 index 0000000000..3bd7e79000 --- /dev/null +++ b/tests/PHPStan/Rules/DeadCode/data/noop-error-throws.php @@ -0,0 +1,12 @@ += 8.0 + +namespace DeadCodeNoopErrorThrows; + +function (int $a, int $b, string $s) { + $a / $b; + $a % $b; + match ($a) { + 1 => 'a', + }; + new \DateTimeImmutable($s); +}; diff --git a/tests/PHPStan/Rules/Functions/CallToFunctionStatementWithoutSideEffectsRuleTest.php b/tests/PHPStan/Rules/Functions/CallToFunctionStatementWithoutSideEffectsRuleTest.php index 1e02b37441..f81bbcf329 100644 --- a/tests/PHPStan/Rules/Functions/CallToFunctionStatementWithoutSideEffectsRuleTest.php +++ b/tests/PHPStan/Rules/Functions/CallToFunctionStatementWithoutSideEffectsRuleTest.php @@ -113,6 +113,32 @@ public function testBug11101(): void ]); } + public function testErrorThrows(): void + { + $this->analyse([__DIR__ . '/data/function-call-statement-no-side-effects-error-throws.php'], [ + [ + 'Call to function sprintf() on a separate line has no effect.', + 22, + ], + [ + 'Call to function strpos() on a separate line has no effect.', + 23, + ], + [ + 'Call to function intdiv() on a separate line has no effect.', + 24, + ], + [ + 'Call to function array_combine() on a separate line has no effect.', + 25, + ], + [ + 'Call to function FunctionCallStatementNoSideEffectsErrorThrows\\pureAndThrowsError() on a separate line has no effect.', + 27, + ], + ]); + } + public function testBug4455(): void { require_once __DIR__ . '/data/bug-4455.php'; diff --git a/tests/PHPStan/Rules/Functions/data/function-call-statement-no-side-effects-error-throws.php b/tests/PHPStan/Rules/Functions/data/function-call-statement-no-side-effects-error-throws.php new file mode 100644 index 0000000000..236770dfdf --- /dev/null +++ b/tests/PHPStan/Rules/Functions/data/function-call-statement-no-side-effects-error-throws.php @@ -0,0 +1,31 @@ += 8.0.0')] public function testRule(): void - { - $this->analyse([__DIR__ . '/data/method-call-statement-no-side-effects.php'], [ - [ - 'Call to method DateTimeImmutable::modify() on a separate line has no effect.', - 15, - ], - [ - 'Call to method Exception::getCode() on a separate line has no effect.', - 21, - ], - [ - 'Call to method MethodCallStatementNoSideEffects\Bar::doPure() on a separate line has no effect.', - 63, - ], - [ - 'Call to method MethodCallStatementNoSideEffects\Bar::doPureWithThrowsVoid() on a separate line has no effect.', - 64, - ], - ]); - } - - #[RequiresPhp('< 8.0.0')] - public function testRulePhp7(): void { $this->analyse([__DIR__ . '/data/method-call-statement-no-side-effects.php'], [ [ diff --git a/tests/PHPStan/Rules/Methods/CallToStaticMethodStatementWithoutSideEffectsRuleTest.php b/tests/PHPStan/Rules/Methods/CallToStaticMethodStatementWithoutSideEffectsRuleTest.php index 4f3696de3e..d9310f5048 100644 --- a/tests/PHPStan/Rules/Methods/CallToStaticMethodStatementWithoutSideEffectsRuleTest.php +++ b/tests/PHPStan/Rules/Methods/CallToStaticMethodStatementWithoutSideEffectsRuleTest.php @@ -6,7 +6,6 @@ use PHPStan\Rules\RuleLevelHelper; use PHPStan\Testing\RuleTestCase; use PHPUnit\Framework\Attributes\RequiresPhp; -use const PHP_VERSION_ID; /** * @extends RuleTestCase @@ -32,19 +31,7 @@ protected function getRule(): Rule ); } - #[RequiresPhp('>= 8.0.0')] public function testRule(): void - { - $this->analyse([__DIR__ . '/data/static-method-call-statement-no-side-effects.php'], [ - [ - 'Call to method DateTime::format() on a separate line has no effect.', - 23, - ], - ]); - } - - #[RequiresPhp('< 8.0.0')] - public function testRulePhp7(): void { $this->analyse([__DIR__ . '/data/static-method-call-statement-no-side-effects.php'], [ [ @@ -122,16 +109,12 @@ public function testFirstClassCallables(): void public function testBug10819(): void { - $errors = []; - if (PHP_VERSION_ID < 80000) { - $errors = [ - [ - 'Call to static method DateTime::createFromFormat() on a separate line has no effect.', - 13, - ], - ]; - } - $this->analyse([__DIR__ . '/data/bug-10819.php'], $errors); + $this->analyse([__DIR__ . '/data/bug-10819.php'], [ + [ + 'Call to static method DateTime::createFromFormat() on a separate line has no effect.', + 13, + ], + ]); } public function testDynamicStaticCall(): void