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] 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); +}