From e3b163abdb17aa155857dc0143bf98020e39cfa9 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 93bb2b8b5d3..beed144382c 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -1655,7 +1655,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) { @@ -1706,7 +1706,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(); } @@ -1804,7 +1804,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(); } @@ -1875,7 +1875,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 2de7a8a9c1d9a72943672062813d1021cdcfa6e6 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 2e308bb43ee..a9ef5064867 100644 --- a/src/Analyser/ExprHandler/FuncCallHandler.php +++ b/src/Analyser/ExprHandler/FuncCallHandler.php @@ -782,9 +782,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); @@ -842,6 +839,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 beed144382c..93bb2b8b5d3 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -1655,7 +1655,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) { @@ -1706,7 +1706,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(); } @@ -1804,7 +1804,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(); } @@ -1875,7 +1875,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 82e2cc9350bf96161dcccc097e199b82a63f47df 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 b33f771b9c6..8b4f803bc4d 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 @@ -1066,8 +1066,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 00306e738b0..85d03f877b2 100644 --- a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php @@ -196,17 +196,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.', @@ -956,8 +953,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'], [ [ @@ -967,7 +962,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 9c8e1f25fc9..63b785b8617 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 4cf6c399ea7..4f72150dd97 100644 --- a/tests/PHPStan/Rules/Comparison/TernaryOperatorConstantConditionRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/TernaryOperatorConstantConditionRuleTest.php @@ -96,7 +96,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 0f1fe3c5f7da3aee7ad88e8243f247fec4dd4ebe 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 4d5b9886b242ce311750ef1ebdd39ede0375368c 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 62ca925e70360cacc6e5cf45cb4d8440829a7e2e 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 6f456470260f30c4e2841172fecabbcef8754a7e 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)); }