From e4cc99fdf208630ce3229557e490f53d3015598f Mon Sep 17 00:00:00 2001 From: phpstan-bot <79867460+phpstan-bot@users.noreply.github.com> Date: Thu, 23 Apr 2026 18:35:46 +0000 Subject: [PATCH 1/8] Use `getType()` instead of `getNativeType()` for loop iteration detection when `treatPhpDocTypesAsCertain` is false MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Change `for` loop `$isIterableAtLeastOnce` to always use `getType()` for condition evaluation, matching `foreach` behavior which already uses `getType()` unconditionally - Apply the same fix to `while` loop `$beforeCondBooleanType` and `$condBooleanType` (used for `$isIterableAtLeastOnce` and `$alwaysIterates`) - Apply the same fix to `do-while` loop `$condBooleanType` (used for `$alwaysIterates`) - The `for` loop's own `$alwaysIterates` already used `getType()` unconditionally, making the `$isIterableAtLeastOnce` switch inconsistent - `foreach` was already correct — it uses `$scope->getType()` for `isIterableAtLeastOnce()` detection - `if/elseif` left unchanged — those control branch reachability rather than scope merging --- src/Analyser/NodeScopeResolver.php | 8 +-- tests/PHPStan/Analyser/Bug14522Test.php | 36 +++++++++++ tests/PHPStan/Analyser/bug-14522.neon | 2 + tests/PHPStan/Analyser/data/bug-14522.php | 53 ++++++++++++++++ tests/PHPStan/Analyser/nsrt/bug-14522.php | 73 +++++++++++++++++++++++ 5 files changed, 168 insertions(+), 4 deletions(-) create mode 100644 tests/PHPStan/Analyser/Bug14522Test.php create mode 100644 tests/PHPStan/Analyser/bug-14522.neon create mode 100644 tests/PHPStan/Analyser/data/bug-14522.php create mode 100644 tests/PHPStan/Analyser/nsrt/bug-14522.php diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index e910d88ba01..dc6323b7a32 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -1442,7 +1442,7 @@ public function processStmtNode( $originalStorage = $storage; $storage = $originalStorage->duplicate(); $condResult = $this->processExprNode($stmt, $stmt->cond, $scope, $storage, new NoopNodeCallback(), ExpressionContext::createDeep()); - $beforeCondBooleanType = ($this->treatPhpDocTypesAsCertain ? $scope->getType($stmt->cond) : $scope->getNativeType($stmt->cond))->toBoolean(); + $beforeCondBooleanType = $scope->getType($stmt->cond)->toBoolean(); $condScope = $condResult->getFalseyScope(); if (!$context->isTopLevel() && $beforeCondBooleanType->isFalse()->yes()) { if (!$this->polluteScopeWithLoopInitialAssignments) { @@ -1493,7 +1493,7 @@ public function processStmtNode( $alwaysIterates = false; $neverIterates = false; if ($context->isTopLevel()) { - $condBooleanType = ($this->treatPhpDocTypesAsCertain ? $bodyScopeMaybeRan->getType($stmt->cond) : $bodyScopeMaybeRan->getNativeType($stmt->cond))->toBoolean(); + $condBooleanType = $bodyScopeMaybeRan->getType($stmt->cond)->toBoolean(); $alwaysIterates = $condBooleanType->isTrue()->yes(); $neverIterates = $condBooleanType->isFalse()->yes(); } @@ -1591,7 +1591,7 @@ public function processStmtNode( $alwaysIterates = false; if ($context->isTopLevel()) { - $condBooleanType = ($this->treatPhpDocTypesAsCertain ? $bodyScope->getType($stmt->cond) : $bodyScope->getNativeType($stmt->cond))->toBoolean(); + $condBooleanType = $bodyScope->getType($stmt->cond)->toBoolean(); $alwaysIterates = $condBooleanType->isTrue()->yes(); } @@ -1662,7 +1662,7 @@ public function processStmtNode( // only the last condition expression is relevant whether the loop continues // see https://www.php.net/manual/en/control-structures.for.php if ($condExpr === $lastCondExpr) { - $condTruthiness = ($this->treatPhpDocTypesAsCertain ? $condResultScope->getType($condExpr) : $condResultScope->getNativeType($condExpr))->toBoolean(); + $condTruthiness = $condResultScope->getType($condExpr)->toBoolean(); $isIterableAtLeastOnce = $isIterableAtLeastOnce->and($condTruthiness->isTrue()); } diff --git a/tests/PHPStan/Analyser/Bug14522Test.php b/tests/PHPStan/Analyser/Bug14522Test.php new file mode 100644 index 00000000000..7f2e4a61933 --- /dev/null +++ b/tests/PHPStan/Analyser/Bug14522Test.php @@ -0,0 +1,36 @@ +assertFileAsserts($assertType, $file, ...$args); + } + + public static function getAdditionalConfigFiles(): array + { + return [ + __DIR__ . '/bug-14522.neon', + ]; + } + +} diff --git a/tests/PHPStan/Analyser/bug-14522.neon b/tests/PHPStan/Analyser/bug-14522.neon new file mode 100644 index 00000000000..c551b84f1f6 --- /dev/null +++ b/tests/PHPStan/Analyser/bug-14522.neon @@ -0,0 +1,2 @@ +parameters: + treatPhpDocTypesAsCertain: false diff --git a/tests/PHPStan/Analyser/data/bug-14522.php b/tests/PHPStan/Analyser/data/bug-14522.php new file mode 100644 index 00000000000..1514d050458 --- /dev/null +++ b/tests/PHPStan/Analyser/data/bug-14522.php @@ -0,0 +1,53 @@ + + */ +function getBackoffTime(int $retryCount, int $maxBackoff): int +{ + $retryCount = max(0, $retryCount); + $maxBackoff = max(1, $maxBackoff); + + $total = 0; + for ($i = 0; $i <= $retryCount; ++$i) { + $total += min(2 ** $i, $maxBackoff); + } + assertType('int<1, max>', $total); + return $total; +} + +/** @param int<0, max> $n */ +function simpleForLoopAlwaysEnters(int $n): void +{ + $total = 0; + for ($i = 0; $i <= $n; $i++) { + $total++; + } + assertType('int<1, max>', $total); +} + +function forLoopWithMaxAlwaysEnters(int $n): void +{ + $n = max(0, $n); + $total = 0; + for ($i = 0; $i <= $n; $i++) { + $total++; + } + assertType('int<1, max>', $total); +} + +function whileLoopAlwaysEnters(int $n): void +{ + $n = max(0, $n); + $i = 0; + $total = 0; + while ($i <= $n) { + $total++; + $i++; + } + assertType('int<1, max>', $total); +} diff --git a/tests/PHPStan/Analyser/nsrt/bug-14522.php b/tests/PHPStan/Analyser/nsrt/bug-14522.php new file mode 100644 index 00000000000..ccd55a84473 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-14522.php @@ -0,0 +1,73 @@ + + */ +function getBackoffTime(int $retryCount, int $maxBackoff): int +{ + $retryCount = max(0, $retryCount); + $maxBackoff = max(1, $maxBackoff); + + $total = 0; + for ($i = 0; $i <= $retryCount; ++$i) { + $total += min(2 ** $i, $maxBackoff); + } + assertType('int<1, max>', $total); + return $total; +} + +function simpleForLoopAlwaysEnters(int $n): void +{ + $n = max(0, $n); + $total = 0; + for ($i = 0; $i <= $n; $i++) { + $total++; + } + assertType('int<1, max>', $total); +} + +function forLoopNeverEnters(): void +{ + $total = 0; + for ($i = 0; $i < 0; $i++) { + $total++; + } + assertType('0', $total); +} + +function forLoopMaybeEnters(int $n): void +{ + $total = 0; + for ($i = 0; $i < $n; $i++) { + $total++; + } + assertType('int<0, max>', $total); +} + +function whileLoopAlwaysEnters(int $n): void +{ + $n = max(0, $n); + $i = 0; + $total = 0; + while ($i <= $n) { + $total++; + $i++; + } + assertType('int<1, max>', $total); +} + +function whileLoopMaybeEnters(int $n): void +{ + $i = 0; + $total = 0; + while ($i < $n) { + $total++; + $i++; + } + assertType('int<0, max>', $total); +} From 773cd84967f759c11a7cf0a754a5eb3a6f7759a3 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Wed, 20 May 2026 16:21:41 +0000 Subject: [PATCH 2/8] Consult dynamic return type extensions for native function return types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Instead of bypassing dynamic extensions when resolving native types in FuncCallHandler, let them run and fall back to the signature's native return type only when no extension handles the call. This fixes the root cause of the bug: functions like max() and min() returned mixed as their native type because the MinMaxFunctionReturnTypeExtension was never consulted in native-types-promoted mode. The NodeScopeResolver changes from the previous commit are reverted since they are no longer needed — the loop iteration detection now gets precise native types from the fixed FuncCallHandler. Co-Authored-By: Claude Opus 4.6 --- src/Analyser/ExprHandler/FuncCallHandler.php | 7 ++++--- src/Analyser/NodeScopeResolver.php | 8 ++++---- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/src/Analyser/ExprHandler/FuncCallHandler.php b/src/Analyser/ExprHandler/FuncCallHandler.php index 920676948ab..21d9d09c1cb 100644 --- a/src/Analyser/ExprHandler/FuncCallHandler.php +++ b/src/Analyser/ExprHandler/FuncCallHandler.php @@ -750,9 +750,6 @@ public function resolveType(MutatingScope $scope, Expr $expr): Type } $functionReflection = $this->reflectionProvider->getFunction($expr->name, $scope); - if ($scope->nativeTypesPromoted) { - return ParametersAcceptorSelector::combineAcceptors($functionReflection->getVariants())->getNativeReturnType(); - } if ($functionReflection->getName() === 'call_user_func') { $result = ArgumentsNormalizer::reorderCallUserFuncArguments($expr, $scope); @@ -810,6 +807,10 @@ public function resolveType(MutatingScope $scope, Expr $expr): Type } } + if ($scope->nativeTypesPromoted) { + return ParametersAcceptorSelector::combineAcceptors($functionReflection->getVariants())->getNativeReturnType(); + } + return VoidToNullTypeTransformer::transform($parametersAcceptor->getReturnType(), $expr); } diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index dc6323b7a32..e910d88ba01 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -1442,7 +1442,7 @@ public function processStmtNode( $originalStorage = $storage; $storage = $originalStorage->duplicate(); $condResult = $this->processExprNode($stmt, $stmt->cond, $scope, $storage, new NoopNodeCallback(), ExpressionContext::createDeep()); - $beforeCondBooleanType = $scope->getType($stmt->cond)->toBoolean(); + $beforeCondBooleanType = ($this->treatPhpDocTypesAsCertain ? $scope->getType($stmt->cond) : $scope->getNativeType($stmt->cond))->toBoolean(); $condScope = $condResult->getFalseyScope(); if (!$context->isTopLevel() && $beforeCondBooleanType->isFalse()->yes()) { if (!$this->polluteScopeWithLoopInitialAssignments) { @@ -1493,7 +1493,7 @@ public function processStmtNode( $alwaysIterates = false; $neverIterates = false; if ($context->isTopLevel()) { - $condBooleanType = $bodyScopeMaybeRan->getType($stmt->cond)->toBoolean(); + $condBooleanType = ($this->treatPhpDocTypesAsCertain ? $bodyScopeMaybeRan->getType($stmt->cond) : $bodyScopeMaybeRan->getNativeType($stmt->cond))->toBoolean(); $alwaysIterates = $condBooleanType->isTrue()->yes(); $neverIterates = $condBooleanType->isFalse()->yes(); } @@ -1591,7 +1591,7 @@ public function processStmtNode( $alwaysIterates = false; if ($context->isTopLevel()) { - $condBooleanType = $bodyScope->getType($stmt->cond)->toBoolean(); + $condBooleanType = ($this->treatPhpDocTypesAsCertain ? $bodyScope->getType($stmt->cond) : $bodyScope->getNativeType($stmt->cond))->toBoolean(); $alwaysIterates = $condBooleanType->isTrue()->yes(); } @@ -1662,7 +1662,7 @@ public function processStmtNode( // only the last condition expression is relevant whether the loop continues // see https://www.php.net/manual/en/control-structures.for.php if ($condExpr === $lastCondExpr) { - $condTruthiness = $condResultScope->getType($condExpr)->toBoolean(); + $condTruthiness = ($this->treatPhpDocTypesAsCertain ? $condResultScope->getType($condExpr) : $condResultScope->getNativeType($condExpr))->toBoolean(); $isIterableAtLeastOnce = $isIterableAtLeastOnce->and($condTruthiness->isTrue()); } From 82d015c44b7f90532f1277c5526e44fa152fdb95 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Wed, 20 May 2026 16:21:54 +0000 Subject: [PATCH 3/8] Update tests and baseline for more precise native function return types Dynamic return type extensions now contribute to native types, making them more precise: - random_int(0, 100) native type: int -> int<0, 100> - max(0, int) native type: mixed -> int<0, max> - range(0, 10) native type: array -> array{0, 1, ...} - array_replace/array_chunk with constant args: precise native types - get_class() native type: string -> class-string<...> - strlen/mb_str_split with constant args: precise native types The "Because the type is coming from a PHPDoc" tip is removed in cases where native and PHPDoc types now agree, since the determination no longer depends on PHPDoc. Co-Authored-By: Claude Opus 4.6 --- phpstan-baseline.neon | 8 ++++---- tests/PHPStan/Analyser/data/bug-14522.php | 15 +++++---------- tests/PHPStan/Analyser/data/bug-9307.php | 2 +- tests/PHPStan/Analyser/nsrt/bug-14522.php | 4 ++++ tests/PHPStan/Analyser/nsrt/bug-8956.php | 6 +++--- tests/PHPStan/Analyser/nsrt/native-types.php | 2 +- .../ImpossibleCheckTypeFunctionCallRuleTest.php | 6 ------ ...StrictComparisonOfDifferentTypesRuleTest.php | 17 ----------------- ...TernaryOperatorConstantConditionRuleTest.php | 11 ++++++++++- .../Rules/Debug/DumpNativeTypeRuleTest.php | 2 +- 10 files changed, 29 insertions(+), 44 deletions(-) diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 8616d227c86..13a76337064 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -13,8 +13,8 @@ parameters: path: src/Analyser/AnalyserResultFinalizer.php - - rawMessage: PHPDoc tag @var with type int|string is not subtype of type string. - identifier: varTag.type + rawMessage: PHPDoc tag @var with type int|string is not subtype of native type string. + identifier: varTag.nativeType count: 1 path: src/Analyser/ArgumentsNormalizer.php @@ -1054,8 +1054,8 @@ parameters: path: src/Type/Constant/ConstantStringType.php - - rawMessage: PHPDoc tag @var with type int|string is not subtype of type string. - identifier: varTag.type + rawMessage: PHPDoc tag @var with type int|string is not subtype of native type string. + identifier: varTag.nativeType count: 1 path: src/Type/Constant/ConstantStringType.php diff --git a/tests/PHPStan/Analyser/data/bug-14522.php b/tests/PHPStan/Analyser/data/bug-14522.php index 1514d050458..d940dba54e1 100644 --- a/tests/PHPStan/Analyser/data/bug-14522.php +++ b/tests/PHPStan/Analyser/data/bug-14522.php @@ -3,6 +3,7 @@ namespace Bug14522; use function PHPStan\Testing\assertType; +use function PHPStan\Testing\assertNativeType; /** * @return int<1, max> @@ -10,7 +11,9 @@ function getBackoffTime(int $retryCount, int $maxBackoff): int { $retryCount = max(0, $retryCount); + assertNativeType('int<0, max>', $retryCount); $maxBackoff = max(1, $maxBackoff); + assertNativeType('int<1, max>', $maxBackoff); $total = 0; for ($i = 0; $i <= $retryCount; ++$i) { @@ -20,19 +23,10 @@ function getBackoffTime(int $retryCount, int $maxBackoff): int return $total; } -/** @param int<0, max> $n */ -function simpleForLoopAlwaysEnters(int $n): void -{ - $total = 0; - for ($i = 0; $i <= $n; $i++) { - $total++; - } - assertType('int<1, max>', $total); -} - function forLoopWithMaxAlwaysEnters(int $n): void { $n = max(0, $n); + assertNativeType('int<0, max>', $n); $total = 0; for ($i = 0; $i <= $n; $i++) { $total++; @@ -43,6 +37,7 @@ function forLoopWithMaxAlwaysEnters(int $n): void function whileLoopAlwaysEnters(int $n): void { $n = max(0, $n); + assertNativeType('int<0, max>', $n); $i = 0; $total = 0; while ($i <= $n) { diff --git a/tests/PHPStan/Analyser/data/bug-9307.php b/tests/PHPStan/Analyser/data/bug-9307.php index 69158aa7591..0b6ce3becc5 100644 --- a/tests/PHPStan/Analyser/data/bug-9307.php +++ b/tests/PHPStan/Analyser/data/bug-9307.php @@ -31,7 +31,7 @@ public function test(): void } } - assertType('array<*ERROR*>', $objects); // could be array + assertType('array', $objects); $this->acceptObjects($objects); } diff --git a/tests/PHPStan/Analyser/nsrt/bug-14522.php b/tests/PHPStan/Analyser/nsrt/bug-14522.php index ccd55a84473..470334a8528 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-14522.php +++ b/tests/PHPStan/Analyser/nsrt/bug-14522.php @@ -11,7 +11,9 @@ function getBackoffTime(int $retryCount, int $maxBackoff): int { $retryCount = max(0, $retryCount); + assertNativeType('int<0, max>', $retryCount); $maxBackoff = max(1, $maxBackoff); + assertNativeType('int<1, max>', $maxBackoff); $total = 0; for ($i = 0; $i <= $retryCount; ++$i) { @@ -24,6 +26,7 @@ function getBackoffTime(int $retryCount, int $maxBackoff): int function simpleForLoopAlwaysEnters(int $n): void { $n = max(0, $n); + assertNativeType('int<0, max>', $n); $total = 0; for ($i = 0; $i <= $n; $i++) { $total++; @@ -52,6 +55,7 @@ function forLoopMaybeEnters(int $n): void function whileLoopAlwaysEnters(int $n): void { $n = max(0, $n); + assertNativeType('int<0, max>', $n); $i = 0; $total = 0; while ($i <= $n) { diff --git a/tests/PHPStan/Analyser/nsrt/bug-8956.php b/tests/PHPStan/Analyser/nsrt/bug-8956.php index 15ba7b8dfdb..37781951755 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-8956.php +++ b/tests/PHPStan/Analyser/nsrt/bug-8956.php @@ -12,10 +12,10 @@ public function doFoo(): void { foreach (array_chunk(range(0, 10), 60) as $chunk) { assertType('array{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10}', $chunk); - assertNativeType('array', $chunk); + assertNativeType('array{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10}', $chunk); foreach ($chunk as $val) { assertType('0|1|2|3|4|5|6|7|8|9|10', $val); - assertNativeType('mixed', $val); + assertNativeType('0|1|2|3|4|5|6|7|8|9|10', $val); } } } @@ -23,7 +23,7 @@ public function doFoo(): void public function doBar(): void { assertType('array{array{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10}}', array_chunk(range(0, 10), 60)); - assertNativeType('list', array_chunk(range(0, 10), 60)); + assertNativeType('array{array{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10}}', array_chunk(range(0, 10), 60)); } } diff --git a/tests/PHPStan/Analyser/nsrt/native-types.php b/tests/PHPStan/Analyser/nsrt/native-types.php index e9b121b8130..8638049b04a 100644 --- a/tests/PHPStan/Analyser/nsrt/native-types.php +++ b/tests/PHPStan/Analyser/nsrt/native-types.php @@ -334,7 +334,7 @@ public function doFoo(): void { $a = array_replace([1, 2, 3], [4, 5, 6]); assertType('array{4, 5, 6}', $a); - assertNativeType('array', $a); + assertNativeType('array{4, 5, 6}', $a); } } diff --git a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php index 2c63aec4647..7d6455150e6 100644 --- a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php @@ -198,17 +198,14 @@ public function testImpossibleCheckTypeFunctionCall(): void [ 'Call to function method_exists() with \'CheckTypeFunctionCall\\\\MethodExistsWithTrait\' and \'method\' will always evaluate to true.', 659, - 'Because the type is coming from a PHPDoc, you can turn off this check by setting treatPhpDocTypesAsCertain: false in your %configurationFile%.', ], [ 'Call to function method_exists() with \'CheckTypeFunctionCall\\\\MethodExistsWithTrait\' and \'someAnother\' will always evaluate to true.', 662, - 'Because the type is coming from a PHPDoc, you can turn off this check by setting treatPhpDocTypesAsCertain: false in your %configurationFile%.', ], [ 'Call to function method_exists() with \'CheckTypeFunctionCall\\\\MethodExistsWithTrait\' and \'unknown\' will always evaluate to false.', 665, - 'Because the type is coming from a PHPDoc, you can turn off this check by setting treatPhpDocTypesAsCertain: false in your %configurationFile%.', ], [ 'Call to function method_exists() with \'CheckTypeFunctionCall\\\\MethodExistsWithTrait\' and \'method\' will always evaluate to true.', @@ -958,8 +955,6 @@ public function testBug4890b(): void public function testBug10502(): void { - $tipText = 'Because the type is coming from a PHPDoc, you can turn off this check by setting treatPhpDocTypesAsCertain: false in your %configurationFile%.'; - $this->treatPhpDocTypesAsCertain = true; $this->analyse([__DIR__ . '/data/bug-10502.php'], [ [ @@ -969,7 +964,6 @@ public function testBug10502(): void [ "Call to function is_callable() with array{1: 'count', 0: ArrayObject} will always evaluate to true.", 24, - $tipText, ], ]); } diff --git a/tests/PHPStan/Rules/Comparison/StrictComparisonOfDifferentTypesRuleTest.php b/tests/PHPStan/Rules/Comparison/StrictComparisonOfDifferentTypesRuleTest.php index 9a057cd5392..ec9eb838f39 100644 --- a/tests/PHPStan/Rules/Comparison/StrictComparisonOfDifferentTypesRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/StrictComparisonOfDifferentTypesRuleTest.php @@ -199,7 +199,6 @@ public function testStrictComparison(): void [ 'Strict comparison using === between int<0, 1> and 100 will always evaluate to false.', 622, - $tipText, ], [ 'Strict comparison using === between 100 and \'foo\' will always evaluate to false.', @@ -444,7 +443,6 @@ public function testBug7555(): void [ 'Strict comparison using === between 2 and 2 will always evaluate to true.', 11, - 'Because the type is coming from a PHPDoc, you can turn off this check by setting treatPhpDocTypesAsCertain: false in your %configurationFile%.', ], ]); } @@ -493,13 +491,10 @@ public function testBug6181(): void public function testBug2851b(): void { - $tipText = 'Because the type is coming from a PHPDoc, you can turn off this check by setting treatPhpDocTypesAsCertain: false in your %configurationFile%.'; - $this->analyse([__DIR__ . '/data/bug-2851b.php'], [ [ 'Strict comparison using === between 0 and 0 will always evaluate to true.', 21, - $tipText, ], ]); } @@ -570,17 +565,14 @@ public function testBug4242(): void public function testBug3633(): void { - $tipText = 'Because the type is coming from a PHPDoc, you can turn off this check by setting treatPhpDocTypesAsCertain: false in your %configurationFile%.'; $this->analyse([__DIR__ . '/data/bug-3633.php'], [ [ 'Strict comparison using === between class-string<$this(Bug3633\HelloWorld)> and \'Bug3633\\\OtherClass\' will always evaluate to false.', 37, - $tipText, ], [ 'Strict comparison using === between \'Bug3633\\\HelloWorld\' and \'Bug3633\\\HelloWorld\' will always evaluate to true.', 41, - $tipText, ], [ 'Strict comparison using === between \'Bug3633\\\HelloWorld\' and \'Bug3633\\\OtherClass\' will always evaluate to false.', @@ -589,47 +581,38 @@ public function testBug3633(): void [ 'Strict comparison using === between class-string<$this(Bug3633\OtherClass)> and \'Bug3633\\\HelloWorld\' will always evaluate to false.', 64, - $tipText, ], [ 'Strict comparison using === between \'Bug3633\\\OtherClass\' and \'Bug3633\\\HelloWorld\' will always evaluate to false.', 71, - $tipText, ], [ 'Strict comparison using === between \'Bug3633\\\OtherClass\' and \'Bug3633\\\OtherClass\' will always evaluate to true.', 74, - $tipText, ], [ 'Strict comparison using === between class-string<$this(Bug3633\FinalClass)> and \'Bug3633\\\HelloWorld\' will always evaluate to false.', 93, - $tipText, ], [ 'Strict comparison using === between class-string<$this(Bug3633\FinalClass)> and \'Bug3633\\\OtherClass\' will always evaluate to false.', 96, - $tipText, ], [ 'Strict comparison using === between \'Bug3633\\\FinalClass\' and \'Bug3633\\\FinalClass\' will always evaluate to true.', 102, - $tipText, ], [ 'Strict comparison using === between \'Bug3633\\\FinalClass\' and \'Bug3633\\\HelloWorld\' will always evaluate to false.', 106, - $tipText, ], [ 'Strict comparison using === between \'Bug3633\\\FinalClass\' and \'Bug3633\\\OtherClass\' will always evaluate to false.', 109, - $tipText, ], [ 'Strict comparison using !== between \'Bug3633\\\FinalClass\' and \'Bug3633\\\FinalClass\' will always evaluate to false.', 112, - $tipText, ], [ 'Strict comparison using === between \'Bug3633\\\FinalClass\' and \'Bug3633\\\FinalClass\' will always evaluate to true.', diff --git a/tests/PHPStan/Rules/Comparison/TernaryOperatorConstantConditionRuleTest.php b/tests/PHPStan/Rules/Comparison/TernaryOperatorConstantConditionRuleTest.php index 300484a89b7..97ea2abc806 100644 --- a/tests/PHPStan/Rules/Comparison/TernaryOperatorConstantConditionRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/TernaryOperatorConstantConditionRuleTest.php @@ -97,7 +97,16 @@ public function testReportPhpDoc(): void public function testBug7580(): void { $this->treatPhpDocTypesAsCertain = false; - $this->analyse([__DIR__ . '/data/bug-7580.php'], []); + $this->analyse([__DIR__ . '/data/bug-7580.php'], [ + [ + 'Ternary operator condition is always false.', + 6, + ], + [ + 'Ternary operator condition is always true.', + 9, + ], + ]); } public function testBug3370(): void diff --git a/tests/PHPStan/Rules/Debug/DumpNativeTypeRuleTest.php b/tests/PHPStan/Rules/Debug/DumpNativeTypeRuleTest.php index bfdd614b081..1fe7ba75acc 100644 --- a/tests/PHPStan/Rules/Debug/DumpNativeTypeRuleTest.php +++ b/tests/PHPStan/Rules/Debug/DumpNativeTypeRuleTest.php @@ -42,7 +42,7 @@ public function testBug14508(): void { $this->analyse([__DIR__ . '/data/bug-14508-native.php'], [ [ - 'Dumped type #1: int', + 'Dumped type #1: int<0, 100>', 10, ], [ From cd7a43c91a14b47aef861ef7d53be871f7a340a6 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Wed, 20 May 2026 16:41:47 +0000 Subject: [PATCH 4/8] Add test for max() with bounded PHPDoc range vs native type Verifies that max(0, $retryCount) where $retryCount is int<-2, 2> produces int<0, 2> for PHPDoc type and int<0, max> for native type. Co-Authored-By: Claude Opus 4.6 --- tests/PHPStan/Analyser/nsrt/bug-14522.php | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/PHPStan/Analyser/nsrt/bug-14522.php b/tests/PHPStan/Analyser/nsrt/bug-14522.php index 470334a8528..90fd33cdde7 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-14522.php +++ b/tests/PHPStan/Analyser/nsrt/bug-14522.php @@ -75,3 +75,11 @@ function whileLoopMaybeEnters(int $n): void } assertType('int<0, max>', $total); } + +/** @param int<-2, 2> $retryCount */ +function maxWithBoundedRange(int $retryCount): void +{ + $result = max(0, $retryCount); + assertType('int<0, 2>', $result); + assertNativeType('int<0, max>', $result); +} From 456c6b19e3e5c5cac5f271b92bf690fdb57ee4c2 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Wed, 20 May 2026 16:53:03 +0000 Subject: [PATCH 5/8] Remove unrelated loop iteration tests from bug-14522 The fix is about consulting dynamic return type extensions for native function return types, not about loop iteration detection. Remove the standalone for/while loop test cases that were testing loop behavior rather than the native type fix. Co-Authored-By: Claude Opus 4.6 --- tests/PHPStan/Analyser/data/bug-14522.php | 24 ---------- tests/PHPStan/Analyser/nsrt/bug-14522.php | 53 ----------------------- 2 files changed, 77 deletions(-) diff --git a/tests/PHPStan/Analyser/data/bug-14522.php b/tests/PHPStan/Analyser/data/bug-14522.php index d940dba54e1..112af204fa0 100644 --- a/tests/PHPStan/Analyser/data/bug-14522.php +++ b/tests/PHPStan/Analyser/data/bug-14522.php @@ -22,27 +22,3 @@ function getBackoffTime(int $retryCount, int $maxBackoff): int assertType('int<1, max>', $total); return $total; } - -function forLoopWithMaxAlwaysEnters(int $n): void -{ - $n = max(0, $n); - assertNativeType('int<0, max>', $n); - $total = 0; - for ($i = 0; $i <= $n; $i++) { - $total++; - } - assertType('int<1, max>', $total); -} - -function whileLoopAlwaysEnters(int $n): void -{ - $n = max(0, $n); - assertNativeType('int<0, max>', $n); - $i = 0; - $total = 0; - while ($i <= $n) { - $total++; - $i++; - } - assertType('int<1, max>', $total); -} diff --git a/tests/PHPStan/Analyser/nsrt/bug-14522.php b/tests/PHPStan/Analyser/nsrt/bug-14522.php index 90fd33cdde7..87d4a01f304 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-14522.php +++ b/tests/PHPStan/Analyser/nsrt/bug-14522.php @@ -23,59 +23,6 @@ function getBackoffTime(int $retryCount, int $maxBackoff): int return $total; } -function simpleForLoopAlwaysEnters(int $n): void -{ - $n = max(0, $n); - assertNativeType('int<0, max>', $n); - $total = 0; - for ($i = 0; $i <= $n; $i++) { - $total++; - } - assertType('int<1, max>', $total); -} - -function forLoopNeverEnters(): void -{ - $total = 0; - for ($i = 0; $i < 0; $i++) { - $total++; - } - assertType('0', $total); -} - -function forLoopMaybeEnters(int $n): void -{ - $total = 0; - for ($i = 0; $i < $n; $i++) { - $total++; - } - assertType('int<0, max>', $total); -} - -function whileLoopAlwaysEnters(int $n): void -{ - $n = max(0, $n); - assertNativeType('int<0, max>', $n); - $i = 0; - $total = 0; - while ($i <= $n) { - $total++; - $i++; - } - assertType('int<1, max>', $total); -} - -function whileLoopMaybeEnters(int $n): void -{ - $i = 0; - $total = 0; - while ($i < $n) { - $total++; - $i++; - } - assertType('int<0, max>', $total); -} - /** @param int<-2, 2> $retryCount */ function maxWithBoundedRange(int $retryCount): void { From 4bed587efd3ac2e69f3f824c3bc49d7f548a8049 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Wed, 20 May 2026 17:09:47 +0000 Subject: [PATCH 6/8] Add non-regression test for phpstan/phpstan#13273 Tests that array_key_exists correctly narrows to true/false when used with a variable holding the result of get_defined_vars(). Co-Authored-By: Claude Opus 4.6 --- tests/PHPStan/Analyser/nsrt/bug-13273.php | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-13273.php diff --git a/tests/PHPStan/Analyser/nsrt/bug-13273.php b/tests/PHPStan/Analyser/nsrt/bug-13273.php new file mode 100644 index 00000000000..11088a8bd98 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-13273.php @@ -0,0 +1,15 @@ + Date: Wed, 20 May 2026 17:13:52 +0000 Subject: [PATCH 7/8] Consolidate duplicate bug-14522 test into single file The Bug14522 namespace was defined in both data/ and nsrt/ directories. Point Bug14522Test to nsrt/bug-14522.php and remove the duplicate data/ file. Co-Authored-By: Claude Opus 4.6 --- tests/PHPStan/Analyser/Bug14522Test.php | 2 +- tests/PHPStan/Analyser/data/bug-14522.php | 24 ----------------------- 2 files changed, 1 insertion(+), 25 deletions(-) delete mode 100644 tests/PHPStan/Analyser/data/bug-14522.php diff --git a/tests/PHPStan/Analyser/Bug14522Test.php b/tests/PHPStan/Analyser/Bug14522Test.php index 7f2e4a61933..455894159a8 100644 --- a/tests/PHPStan/Analyser/Bug14522Test.php +++ b/tests/PHPStan/Analyser/Bug14522Test.php @@ -10,7 +10,7 @@ class Bug14522Test extends TypeInferenceTestCase public static function dataFileAsserts(): iterable { - yield from self::gatherAssertTypes(__DIR__ . '/data/bug-14522.php'); + yield from self::gatherAssertTypes(__DIR__ . '/nsrt/bug-14522.php'); } /** diff --git a/tests/PHPStan/Analyser/data/bug-14522.php b/tests/PHPStan/Analyser/data/bug-14522.php deleted file mode 100644 index 112af204fa0..00000000000 --- a/tests/PHPStan/Analyser/data/bug-14522.php +++ /dev/null @@ -1,24 +0,0 @@ - - */ -function getBackoffTime(int $retryCount, int $maxBackoff): int -{ - $retryCount = max(0, $retryCount); - assertNativeType('int<0, max>', $retryCount); - $maxBackoff = max(1, $maxBackoff); - assertNativeType('int<1, max>', $maxBackoff); - - $total = 0; - for ($i = 0; $i <= $retryCount; ++$i) { - $total += min(2 ** $i, $maxBackoff); - } - assertType('int<1, max>', $total); - return $total; -} From ce0866024d8ddfe92efc5ea029fe6951a97ab4a9 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Wed, 20 May 2026 17:14:00 +0000 Subject: [PATCH 8/8] Add non-regression test for phpstan/phpstan#13273 Tests that get_defined_vars() returns precise native types and array_key_exists narrows correctly when result is stored in a variable. Co-Authored-By: Claude Opus 4.6 --- tests/PHPStan/Analyser/nsrt/bug-13273.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/PHPStan/Analyser/nsrt/bug-13273.php b/tests/PHPStan/Analyser/nsrt/bug-13273.php index 11088a8bd98..c9b2497e33b 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-13273.php +++ b/tests/PHPStan/Analyser/nsrt/bug-13273.php @@ -3,6 +3,7 @@ namespace Bug13273; use function PHPStan\Testing\assertType; +use function PHPStan\Testing\assertNativeType; function test(int $param): void { @@ -12,4 +13,7 @@ function test(int $param): void assertType('true', array_key_exists('param', $vars)); assertType('true', array_key_exists('local', $vars)); assertType('false', array_key_exists('nonexistent', $vars)); + assertNativeType('true', array_key_exists('param', $vars)); + assertNativeType('true', array_key_exists('local', $vars)); + assertNativeType('false', array_key_exists('nonexistent', $vars)); }