From d57d06882072c3bc91122b95a12b8202929e89d4 Mon Sep 17 00:00:00 2001 From: phpstan-bot <79867460+phpstan-bot@users.noreply.github.com> Date: Mon, 13 Apr 2026 10:09:20 +0000 Subject: [PATCH] Fix phpstan/phpstan#14455: missing has-offset via conditional type - Added processBooleanNotSureSureConditionalTypes method to TypeSpecifier that creates conditional expression holders with sure-NOT-type conditions and sure-type results (cross-product of existing methods) - Called the new method in both BooleanAnd (falsey) and BooleanOr (truthy) conditional expression holder creation - New regression test in tests/PHPStan/Analyser/nsrt/bug-14455.php - Root cause: empty($arr['key']) && $type === 'filter' in an early return produced sure-NOT-types for $type and sure-types for $arr, but no existing method combined these to create the conditional holder needed for narrowing --- src/Analyser/TypeSpecifier.php | 71 +++++++++++++++++++++++ tests/PHPStan/Analyser/nsrt/bug-14455.php | 27 +++++++++ 2 files changed, 98 insertions(+) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-14455.php diff --git a/src/Analyser/TypeSpecifier.php b/src/Analyser/TypeSpecifier.php index 3d4475d593..67ce936fb4 100644 --- a/src/Analyser/TypeSpecifier.php +++ b/src/Analyser/TypeSpecifier.php @@ -741,6 +741,8 @@ public function specifyTypesInCondition( $this->processBooleanNotSureConditionalTypes($scope, $rightTypesForHolders, $leftTypesForHolders), $this->processBooleanSureConditionalTypes($scope, $leftTypesForHolders, $rightTypesForHolders), $this->processBooleanSureConditionalTypes($scope, $rightTypesForHolders, $leftTypesForHolders), + $this->processBooleanNotSureSureConditionalTypes($scope, $leftTypesForHolders, $rightTypesForHolders), + $this->processBooleanNotSureSureConditionalTypes($scope, $rightTypesForHolders, $leftTypesForHolders), ))->setRootExpr($expr); } @@ -790,6 +792,8 @@ public function specifyTypesInCondition( $this->processBooleanNotSureConditionalTypes($scope, $rightTypes, $leftTypes), $this->processBooleanSureConditionalTypes($scope, $leftTypes, $rightTypes), $this->processBooleanSureConditionalTypes($scope, $rightTypes, $leftTypes), + $this->processBooleanNotSureSureConditionalTypes($scope, $leftTypes, $rightTypes), + $this->processBooleanNotSureSureConditionalTypes($scope, $rightTypes, $leftTypes), ))->setRootExpr($expr); } @@ -1998,6 +2002,73 @@ private function processBooleanSureConditionalTypes(Scope $scope, SpecifiedTypes return []; } + /** + * @return array + */ + private function processBooleanNotSureSureConditionalTypes(Scope $scope, SpecifiedTypes $conditionTypes, SpecifiedTypes $resultTypes): array + { + $conditionExpressionTypes = []; + foreach ($conditionTypes->getSureNotTypes() as $exprString => [$expr, $type]) { + if (!$expr instanceof Expr\Variable) { + continue; + } + if (!is_string($expr->name)) { + continue; + } + + $conditionExpressionTypes[$exprString] = ExpressionTypeHolder::createYes( + $expr, + TypeCombinator::intersect($scope->getType($expr), $type), + ); + } + + if (count($conditionExpressionTypes) > 0) { + $holders = []; + foreach ($resultTypes->getSureTypes() as $exprString => [$expr, $type]) { + if (!$expr instanceof Expr\Variable) { + continue; + } + if (!is_string($expr->name)) { + continue; + } + + if (!isset($holders[$exprString])) { + $holders[$exprString] = []; + } + + $conditions = $conditionExpressionTypes; + foreach ($conditions as $conditionExprString => $conditionExprTypeHolder) { + $conditionExpr = $conditionExprTypeHolder->getExpr(); + if (!$conditionExpr instanceof Expr\Variable) { + continue; + } + if (!is_string($conditionExpr->name)) { + continue; + } + if ($conditionExpr->name !== $expr->name) { + continue; + } + + unset($conditions[$conditionExprString]); + } + + if (count($conditions) === 0) { + continue; + } + + $holder = new ConditionalExpressionHolder( + $conditions, + ExpressionTypeHolder::createYes($expr, TypeCombinator::intersect($scope->getType($expr), $type)), + ); + $holders[$exprString][$holder->getKey()] = $holder; + } + + return $holders; + } + + return []; + } + /** * Flatten a deep BooleanOr chain into leaf expressions and process them * without recursive filterByFalseyValue calls. This reduces O(n^2) to O(n) diff --git a/tests/PHPStan/Analyser/nsrt/bug-14455.php b/tests/PHPStan/Analyser/nsrt/bug-14455.php new file mode 100644 index 0000000000..046d55904d --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-14455.php @@ -0,0 +1,27 @@ + $aggregation + * @param non-falsy-string $type + */ +function testTriviallyTrueConditionSkipped(array $aggregation, string $type): void +{ + if (empty($aggregation['field']) && $type === 'filter') { + return; + } + + assertType("array", $aggregation); + assertType('non-falsy-string', $type); + + if ($type === 'filter') { + assertType("non-empty-array&hasOffset('field')", $aggregation); + } else { + assertType("array", $aggregation); + } + + assertType('non-falsy-string', $type); +}