From c17f62127bc036e3ab6448bc5291eb9d85059334 Mon Sep 17 00:00:00 2001 From: Jan Nedbal Date: Wed, 2 Sep 2026 12:11:31 +0200 Subject: [PATCH 1/2] Compare conditional expressions in MutatingScope::equals() The loop handlers call equals() to detect an unchanged entry scope. Since 2.2.10 they skip the verification pass on an unchanged entry and replay the recorded pass instead of the final walk. Both shortcuts assume that equal scopes walk identically. But equals() ignored the conditional expressions, and those narrow types when a later condition is specified. A while loop nested in another loop shows the gap. The entry of the first pass still holds "if $toCurrent is int<1, max> then $items is non-empty-list" from the preceding if/else merge. The body invalidates it, so the second entry lacks it, but the expression types match and the second entry compares equal. The first pass is replayed, and array_shift($items) inside the loop is reported as never null. Co-Authored-By: Claude Code --- src/Analyser/MutatingScope.php | 50 ++++++++++++++++- .../loop-fixpoint-conditional-expressions.php | 56 +++++++++++++++++++ 2 files changed, 105 insertions(+), 1 deletion(-) create mode 100644 tests/PHPStan/Analyser/nsrt/loop-fixpoint-conditional-expressions.php diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 59e7d30a0dd..9a90cb0cd19 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -4647,7 +4647,55 @@ public function equals(self $otherScope): bool if (!$this->compareVariableTypeHolders($this->expressionTypes, $otherScope->expressionTypes)) { return false; } - return $this->compareVariableTypeHolders($this->nativeExpressionTypes, $otherScope->nativeExpressionTypes); + if (!$this->compareVariableTypeHolders($this->nativeExpressionTypes, $otherScope->nativeExpressionTypes)) { + return false; + } + + return $this->compareConditionalExpressions($this->conditionalExpressions, $otherScope->conditionalExpressions); + } + + /** + * @param array $conditionalExpressions + * @param array $otherConditionalExpressions + */ + private function compareConditionalExpressions(array $conditionalExpressions, array $otherConditionalExpressions): bool + { + if (count($conditionalExpressions) !== count($otherConditionalExpressions)) { + return false; + } + foreach ($conditionalExpressions as $exprString => $holders) { + if (!isset($otherConditionalExpressions[$exprString])) { + return false; + } + $otherHolders = $otherConditionalExpressions[$exprString]; + if (count($holders) !== count($otherHolders)) { + return false; + } + foreach ($holders as $key => $holder) { + if (!isset($otherHolders[$key])) { + return false; + } + $otherHolder = $otherHolders[$key]; + if (!$holder->getTypeHolder()->equals($otherHolder->getTypeHolder())) { + return false; + } + $conditions = $holder->getConditionExpressionTypeHolders(); + $otherConditions = $otherHolder->getConditionExpressionTypeHolders(); + if (count($conditions) !== count($otherConditions)) { + return false; + } + foreach ($conditions as $conditionExprString => $conditionHolder) { + if (!isset($otherConditions[$conditionExprString])) { + return false; + } + if (!$conditionHolder->equals($otherConditions[$conditionExprString])) { + return false; + } + } + } + } + + return true; } /** diff --git a/tests/PHPStan/Analyser/nsrt/loop-fixpoint-conditional-expressions.php b/tests/PHPStan/Analyser/nsrt/loop-fixpoint-conditional-expressions.php new file mode 100644 index 00000000000..395d19e7a8d --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/loop-fixpoint-conditional-expressions.php @@ -0,0 +1,56 @@ + */ + abstract public function getItems(): array; + + public function run(int $quantityToAllocate, int $unitsThatFit): void + { + $items = $this->getItems(); + if ($quantityToAllocate <= 0) { + return; + } + if ($unitsThatFit <= 0) { + return; + } + + do { + $unitsAdded = min($unitsThatFit, $quantityToAllocate); + $toCurrent = 0; + if ($items !== []) { + $unitsAdded = min($unitsAdded, count($items)); + $toCurrent = $unitsAdded; + } + + while ($toCurrent > 0) { + assertType('list', $items); + $item = array_shift($items); + assertType('LoopFixpointConditionalExpressions\Item|null', $item); + if ($item === null) { + throw new LogicException(); + } + $item->assign(); + $toCurrent--; + } + + $quantityToAllocate -= $unitsAdded; + } while ($quantityToAllocate > 0); + } + +} From 2f64ac522877c079db184abf8de4d954433697db Mon Sep 17 00:00:00 2001 From: Jan Nedbal Date: Wed, 2 Sep 2026 12:41:34 +0200 Subject: [PATCH 2/2] Reduce the loop fixpoint regression fixture to its essential shape Co-Authored-By: Claude Code --- .../loop-fixpoint-conditional-expressions.php | 35 ++++--------------- 1 file changed, 6 insertions(+), 29 deletions(-) diff --git a/tests/PHPStan/Analyser/nsrt/loop-fixpoint-conditional-expressions.php b/tests/PHPStan/Analyser/nsrt/loop-fixpoint-conditional-expressions.php index 395d19e7a8d..4fbeb2b3d6b 100644 --- a/tests/PHPStan/Analyser/nsrt/loop-fixpoint-conditional-expressions.php +++ b/tests/PHPStan/Analyser/nsrt/loop-fixpoint-conditional-expressions.php @@ -2,55 +2,32 @@ namespace LoopFixpointConditionalExpressions; -use LogicException; use function PHPStan\Testing\assertType; final class Item { - public function assign(): void - { - } - } -abstract class Foo +class Foo { - /** @return list */ - abstract public function getItems(): array; - - public function run(int $quantityToAllocate, int $unitsThatFit): void + /** @param list $items */ + public function run(array $items): void { - $items = $this->getItems(); - if ($quantityToAllocate <= 0) { - return; - } - if ($unitsThatFit <= 0) { - return; - } - - do { - $unitsAdded = min($unitsThatFit, $quantityToAllocate); + foreach ([1, 2] as $_) { $toCurrent = 0; if ($items !== []) { - $unitsAdded = min($unitsAdded, count($items)); - $toCurrent = $unitsAdded; + $toCurrent = count($items); } while ($toCurrent > 0) { assertType('list', $items); $item = array_shift($items); assertType('LoopFixpointConditionalExpressions\Item|null', $item); - if ($item === null) { - throw new LogicException(); - } - $item->assign(); $toCurrent--; } - - $quantityToAllocate -= $unitsAdded; - } while ($quantityToAllocate > 0); + } } }