From 98dad9b28e0eb5d10444e16b569c56500ca83092 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 13 Apr 2026 23:39:56 +0000 Subject: [PATCH 1/2] Intersect stored `FuncCall` expression types with dynamically computed types in `MutatingScope::resolveType()` - When a FuncCall expression (e.g. `count($arr)`) has a stored expression type from a prior condition, `resolveType()` now also computes the dynamic type via the ExprHandler and returns the intersection of both. - This prevents stale stored types from overriding the correct type when function arguments have been narrowed after scope merging. - The bug occurred when `count($arr)` was used in a comparison (e.g. `count($arr) > 2`), storing `count($arr) = int<0, max>` after merge, and then `$arr` was narrowed to non-empty by a different condition. - The fix also covers analogous cases: `sizeof()`, `strlen()`, `mb_strlen()`, and any other FuncCall with stored expression types. --- src/Analyser/MutatingScope.php | 20 ++++++++- tests/PHPStan/Analyser/nsrt/bug-13750.php | 52 +++++++++++++++++++++++ 2 files changed, 71 insertions(+), 1 deletion(-) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-13750.php diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 122340cc27..79b0853230 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -973,7 +973,25 @@ private function resolveType(string $exprString, Expr $node): Type && !$node instanceof Expr\ArrowFunction && $this->hasExpressionType($node)->yes() ) { - return $this->expressionTypes[$exprString]->getType(); + $storedType = $this->expressionTypes[$exprString]->getType(); + + if ($node instanceof FuncCall) { + // Stored expression types for function calls can become stale + // when arguments are narrowed after scope merging. + // Intersect with the dynamically computed type to stay correct. + $this->resolvedTypes[$exprString] = $storedType; + foreach ($this->container->getServicesByTag(ExprHandler::EXTENSION_TAG) as $exprHandler) { + if (!$exprHandler->supports($node)) { + continue; + } + $dynamicType = $exprHandler->resolveType($this, $node); + unset($this->resolvedTypes[$exprString]); + return TypeCombinator::intersect($storedType, $dynamicType); + } + unset($this->resolvedTypes[$exprString]); + } + + return $storedType; } /** @var ExprHandler $exprHandler */ diff --git a/tests/PHPStan/Analyser/nsrt/bug-13750.php b/tests/PHPStan/Analyser/nsrt/bug-13750.php new file mode 100644 index 0000000000..30dc1a96cd --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-13750.php @@ -0,0 +1,52 @@ + $arr */ + public function forgetCount(array $arr): void + { + if (count($arr) > 2) { + assertType('non-empty-array', $arr); + assertType('int<3, max>', count($arr)); + } + assertType('array', $arr); + assertType('int<0, max>', count($arr)); + if (count($arr, COUNT_RECURSIVE) > 2) { + assertType('non-empty-array', $arr); + assertType('int<1, max>', count($arr)); + } + } + + /** @param array $arr */ + public function forgetCountDifferentNarrowing(array $arr): void + { + if (count($arr) > 2) { + } + if ($arr !== []) { + assertType('int<1, max>', count($arr)); + } + } + + /** @param array $arr */ + public function sizeofAfterSizeof(array $arr): void + { + if (sizeof($arr) > 2) { + } + if ($arr !== []) { + assertType('int<1, max>', sizeof($arr)); + } + } + + public function strlenAfterStrlen(string $str): void + { + if (strlen($str) > 5) { + } + if ($str !== '') { + assertType('int<1, max>', strlen($str)); + } + } +} From fba232daecf2755d3ca76003291daf5f38467944 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Tue, 14 Apr 2026 05:57:19 +0000 Subject: [PATCH 2/2] Move FuncCall type update from resolveType() to filterBySpecifiedTypes() Address review feedback: instead of re-calculating FuncCall types at type-resolving time, update stored expression types during AST evaluation when their arguments are narrowed. In filterBySpecifiedTypes(), after processing type specifications and conditional expressions, detect FuncCall entries whose arguments' types actually changed. For each, temporarily remove the stored type, compute the dynamic type from the narrowed arguments, then store the intersection. This preserves narrowing from both sources: conditions on the function result (e.g. count($arr) === 3) and conditions on the arguments (e.g. $arr !== []). Co-Authored-By: Claude Opus 4.6 --- src/Analyser/MutatingScope.php | 65 ++++++++++++++++++++++++---------- 1 file changed, 46 insertions(+), 19 deletions(-) diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 79b0853230..39e8c4bfaf 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -973,25 +973,7 @@ private function resolveType(string $exprString, Expr $node): Type && !$node instanceof Expr\ArrowFunction && $this->hasExpressionType($node)->yes() ) { - $storedType = $this->expressionTypes[$exprString]->getType(); - - if ($node instanceof FuncCall) { - // Stored expression types for function calls can become stale - // when arguments are narrowed after scope merging. - // Intersect with the dynamically computed type to stay correct. - $this->resolvedTypes[$exprString] = $storedType; - foreach ($this->container->getServicesByTag(ExprHandler::EXTENSION_TAG) as $exprHandler) { - if (!$exprHandler->supports($node)) { - continue; - } - $dynamicType = $exprHandler->resolveType($this, $node); - unset($this->resolvedTypes[$exprString]); - return TypeCombinator::intersect($storedType, $dynamicType); - } - unset($this->resolvedTypes[$exprString]); - } - - return $storedType; + return $this->expressionTypes[$exprString]->getType(); } /** @var ExprHandler $exprHandler */ @@ -3297,6 +3279,51 @@ public function filterBySpecifiedTypes(SpecifiedTypes $specifiedTypes): self } } + // Update stored FuncCall expression types whose arguments were narrowed. + // The stored type may be stale (from scope merging) while the argument + // types have been freshly narrowed. Intersect with the dynamically + // computed type so both sources of narrowing are preserved. + $funcCallsToUpdate = []; + foreach ($scope->expressionTypes as $exprString => $exprTypeHolder) { + if (array_key_exists($exprString, $specifiedExpressions) || array_key_exists($exprString, $conditions)) { + continue; + } + $expr = $exprTypeHolder->getExpr(); + if (!$expr instanceof FuncCall) { + continue; + } + foreach ($expr->getArgs() as $arg) { + $argKey = $this->getNodeKey($arg->value); + if (!array_key_exists($argKey, $specifiedExpressions)) { + continue; + } + $oldArgType = array_key_exists($argKey, $this->expressionTypes) + ? $this->expressionTypes[$argKey]->getType() + : null; + $newArgType = array_key_exists($argKey, $scope->expressionTypes) + ? $scope->expressionTypes[$argKey]->getType() + : null; + if ($oldArgType !== null && $newArgType !== null && $oldArgType->equals($newArgType)) { + continue; + } + $funcCallsToUpdate[$exprString] = $exprTypeHolder; + break; + } + } + + foreach ($funcCallsToUpdate as $exprString => $exprTypeHolder) { + $storedType = $exprTypeHolder->getType(); + unset($scope->expressionTypes[$exprString]); + unset($scope->nativeExpressionTypes[$exprString]); + unset($scope->resolvedTypes[$exprString]); + $dynamicType = $scope->getType($exprTypeHolder->getExpr()); + $scope->expressionTypes[$exprString] = new ExpressionTypeHolder( + $exprTypeHolder->getExpr(), + TypeCombinator::intersect($storedType, $dynamicType), + $exprTypeHolder->getCertainty(), + ); + } + return $scope->scopeFactory->create( $scope->context, $scope->isDeclareStrictTypes(),