From 275d81e9015c98f405ba2e62eb8974b3fd60c9f5 Mon Sep 17 00:00:00 2001 From: phpstan-bot <79867460+phpstan-bot@users.noreply.github.com> Date: Wed, 29 Apr 2026 05:45:45 +0000 Subject: [PATCH 1/8] Check `isFirstClassCallable()` before calling `getArgs()` on nested `FuncCall` nodes - Fix crash when using first-class callable syntax with array_key_first, array_key_last, array_rand, and array_search (e.g. `$fn = array_key_last(...)`) - Add `!$expr->isFirstClassCallable()` guards in TypeSpecifier.php for all patterns that check function names on nested FuncCall nodes: array_key_first/last, array_rand, array_search, count/sizeof, strlen/mb_strlen, preg_match, gettype, get_parent_class, get_class, get_debug_type, trim/ltrim/rtrim - Add same guards in NodeScopeResolver.php for array_keys in foreach and count/sizeof in for-loop conditions - Add same guards in AssignHandler.php for count/sizeof, array_key_first/last, and array_search in array dim fetch list-preservation checks - Add same guards in NonexistentOffsetInArrayDimFetchRule.php for array_rand and count/sizeof patterns --- src/Analyser/ExprHandler/AssignHandler.php | 3 ++ src/Analyser/NodeScopeResolver.php | 3 ++ src/Analyser/TypeSpecifier.php | 15 +++++++ .../NonexistentOffsetInArrayDimFetchRule.php | 2 + tests/PHPStan/Analyser/nsrt/bug-14550.php | 43 +++++++++++++++++++ 5 files changed, 66 insertions(+) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-14550.php diff --git a/src/Analyser/ExprHandler/AssignHandler.php b/src/Analyser/ExprHandler/AssignHandler.php index 5d2a084ec04..618b81edd1c 100644 --- a/src/Analyser/ExprHandler/AssignHandler.php +++ b/src/Analyser/ExprHandler/AssignHandler.php @@ -1221,6 +1221,7 @@ private function shouldKeepList(ArrayDimFetch $arrayDimFetch, Scope $scope, Type && $arrayDimFetch->dim->right instanceof Node\Scalar\Int_ && $arrayDimFetch->dim->left instanceof Expr\FuncCall && $arrayDimFetch->dim->left->name instanceof Name + && !$arrayDimFetch->dim->left->isFirstClassCallable() && in_array($arrayDimFetch->dim->left->name->toLowerString(), ['count', 'sizeof'], true) && count($arrayDimFetch->dim->left->getArgs()) === 1 // could support COUNT_RECURSIVE, COUNT_NORMAL && $this->isSameVariable($arrayDimFetch->var, $arrayDimFetch->dim->left->getArgs()[0]->value) @@ -1231,6 +1232,7 @@ private function shouldKeepList(ArrayDimFetch $arrayDimFetch, Scope $scope, Type } elseif ( // keep list for $list[array_key_last($list)] and $list[array_key_first($list)] assignments $arrayDimFetch->dim instanceof Expr\FuncCall && $arrayDimFetch->dim->name instanceof Name + && !$arrayDimFetch->dim->isFirstClassCallable() && in_array($arrayDimFetch->dim->name->toLowerString(), ['array_key_last', 'array_key_first'], true) && count($arrayDimFetch->dim->getArgs()) >= 1 && $this->isSameVariable($arrayDimFetch->var, $arrayDimFetch->dim->getArgs()[0]->value) @@ -1239,6 +1241,7 @@ private function shouldKeepList(ArrayDimFetch $arrayDimFetch, Scope $scope, Type } elseif ( // keep list for $list[array_search($needle, $list)] assignments $arrayDimFetch->dim instanceof Expr\FuncCall && $arrayDimFetch->dim->name instanceof Name + && !$arrayDimFetch->dim->isFirstClassCallable() && $arrayDimFetch->dim->name->toLowerString() === 'array_search' && count($arrayDimFetch->dim->getArgs()) >= 1 && $this->isSameVariable($arrayDimFetch->var, $arrayDimFetch->dim->getArgs()[1]->value) diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 5090d044028..cbed5105aeb 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -4141,6 +4141,7 @@ private function enterForeach(MutatingScope $scope, ExpressionResultStorage $sto if ( $stmt->expr instanceof FuncCall && $stmt->expr->name instanceof Name + && !$stmt->expr->isFirstClassCallable() && $stmt->expr->name->toLowerString() === 'array_keys' && $stmt->valueVar instanceof Variable ) { @@ -4816,6 +4817,7 @@ private function inferForLoopExpressions(For_ $stmt, Expr $lastCondExpr, Mutatin && $lastCondExpr->left instanceof Variable && $lastCondExpr->right instanceof FuncCall && $lastCondExpr->right->name instanceof Name + && !$lastCondExpr->right->isFirstClassCallable() && in_array($lastCondExpr->right->name->toLowerString(), ['count', 'sizeof'], true) && count($lastCondExpr->right->getArgs()) > 0 && $lastCondExpr->right->getArgs()[0]->value instanceof Variable @@ -4840,6 +4842,7 @@ private function inferForLoopExpressions(For_ $stmt, Expr $lastCondExpr, Mutatin && $lastCondExpr->right instanceof Variable && $lastCondExpr->left instanceof FuncCall && $lastCondExpr->left->name instanceof Name + && !$lastCondExpr->left->isFirstClassCallable() && in_array($lastCondExpr->left->name->toLowerString(), ['count', 'sizeof'], true) && count($lastCondExpr->left->getArgs()) > 0 && $lastCondExpr->left->getArgs()[0]->value instanceof Variable diff --git a/src/Analyser/TypeSpecifier.php b/src/Analyser/TypeSpecifier.php index 24903779b3f..61837183ee3 100644 --- a/src/Analyser/TypeSpecifier.php +++ b/src/Analyser/TypeSpecifier.php @@ -247,6 +247,7 @@ public function specifyTypesInCondition( if ( $expr->left instanceof FuncCall && $expr->left->name instanceof Name + && !$expr->left->isFirstClassCallable() && in_array(strtolower((string) $expr->left->name), ['count', 'sizeof', 'strlen', 'mb_strlen', 'preg_match'], true) && count($expr->left->getArgs()) >= 1 && ( @@ -275,6 +276,7 @@ public function specifyTypesInCondition( !$context->null() && $expr->right instanceof FuncCall && $expr->right->name instanceof Name + && !$expr->right->isFirstClassCallable() && in_array(strtolower((string) $expr->right->name), ['count', 'sizeof'], true) && count($expr->right->getArgs()) >= 1 && $leftType->isInteger()->yes() @@ -378,6 +380,7 @@ public function specifyTypesInCondition( && $expr->right instanceof Expr\BinaryOp\Minus && $expr->right->left instanceof FuncCall && $expr->right->left->name instanceof Name + && !$expr->right->left->isFirstClassCallable() && in_array(strtolower((string) $expr->right->left->name), ['count', 'sizeof'], true) && count($expr->right->left->getArgs()) >= 1 // constant offsets are handled via HasOffsetType/HasOffsetValueType @@ -404,6 +407,7 @@ public function specifyTypesInCondition( !$context->null() && $expr->right instanceof FuncCall && $expr->right->name instanceof Name + && !$expr->right->isFirstClassCallable() && in_array(strtolower((string) $expr->right->name), ['preg_match'], true) && count($expr->right->getArgs()) >= 3 && ( @@ -421,6 +425,7 @@ public function specifyTypesInCondition( !$context->null() && $expr->right instanceof FuncCall && $expr->right->name instanceof Name + && !$expr->right->isFirstClassCallable() && in_array(strtolower((string) $expr->right->name), ['strlen', 'mb_strlen'], true) && count($expr->right->getArgs()) === 1 && $leftType->isInteger()->yes() @@ -825,6 +830,7 @@ public function specifyTypesInCondition( if ( $expr->expr instanceof FuncCall && $expr->expr->name instanceof Name + && !$expr->expr->isFirstClassCallable() && in_array($expr->expr->name->toLowerString(), ['array_key_first', 'array_key_last'], true) && count($expr->expr->getArgs()) >= 1 ) { @@ -881,6 +887,7 @@ public function specifyTypesInCondition( if ( $expr->expr instanceof FuncCall && $expr->expr->name instanceof Name + && !$expr->expr->isFirstClassCallable() && in_array($expr->expr->name->toLowerString(), ['array_rand'], true) && count($expr->expr->getArgs()) >= 1 ) { @@ -911,6 +918,7 @@ public function specifyTypesInCondition( $expr->expr instanceof Expr\BinaryOp\Minus && $expr->expr->left instanceof FuncCall && $expr->expr->left->name instanceof Name + && !$expr->expr->left->isFirstClassCallable() && $expr->expr->right instanceof Node\Scalar\Int_ && $expr->expr->right->value === 1 && in_array($expr->expr->left->name->toLowerString(), ['count', 'sizeof'], true) @@ -938,6 +946,7 @@ public function specifyTypesInCondition( if ( $expr->expr instanceof FuncCall && $expr->expr->name instanceof Name + && !$expr->expr->isFirstClassCallable() && $expr->expr->name->toLowerString() === 'array_search' && count($expr->expr->getArgs()) >= 2 ) { @@ -1596,6 +1605,7 @@ private function specifyTypesForConstantStringBinaryExpression( if ( $exprNode instanceof FuncCall && $exprNode->name instanceof Name + && !$exprNode->isFirstClassCallable() && strtolower($exprNode->name->toString()) === 'gettype' && isset($exprNode->getArgs()[0]) ) { @@ -1636,6 +1646,7 @@ private function specifyTypesForConstantStringBinaryExpression( $context->true() && $exprNode instanceof FuncCall && $exprNode->name instanceof Name + && !$exprNode->isFirstClassCallable() && strtolower((string) $exprNode->name) === 'get_parent_class' && isset($exprNode->getArgs()[0]) ) { @@ -1673,6 +1684,7 @@ private function specifyTypesForConstantStringBinaryExpression( $context->false() && $exprNode instanceof FuncCall && $exprNode->name instanceof Name + && !$exprNode->isFirstClassCallable() && in_array(strtolower((string) $exprNode->name), [ 'trim', 'ltrim', 'rtrim', 'chop', 'mb_trim', 'mb_ltrim', 'mb_rtrim', @@ -2750,6 +2762,7 @@ public function resolveEqual(Expr\BinaryOp\Equal $expr, Scope $scope, TypeSpecif if ( $exprNode instanceof FuncCall && $exprNode->name instanceof Name + && !$exprNode->isFirstClassCallable() && in_array(strtolower($exprNode->name->toString()), ['gettype', 'get_class', 'get_debug_type'], true) && isset($exprNode->getArgs()[0]) && $constantType->isString()->yes() @@ -2897,6 +2910,7 @@ private function resolveNormalizedIdentical(Expr\BinaryOp\Identical $expr, Scope if ( !$context->null() && $unwrappedLeftExpr instanceof FuncCall + && !$unwrappedLeftExpr->isFirstClassCallable() && count($unwrappedLeftExpr->getArgs()) >= 1 && $unwrappedLeftExpr->name instanceof Name && in_array(strtolower((string) $unwrappedLeftExpr->name), ['count', 'sizeof'], true) @@ -2907,6 +2921,7 @@ private function resolveNormalizedIdentical(Expr\BinaryOp\Identical $expr, Scope $context->true() && $unwrappedRightExpr instanceof FuncCall && $unwrappedRightExpr->name instanceof Name + && !$unwrappedRightExpr->isFirstClassCallable() && in_array($unwrappedRightExpr->name->toLowerString(), ['count', 'sizeof'], true) && count($unwrappedRightExpr->getArgs()) >= 1 ) { diff --git a/src/Rules/Arrays/NonexistentOffsetInArrayDimFetchRule.php b/src/Rules/Arrays/NonexistentOffsetInArrayDimFetchRule.php index 9a4947d5216..952ffd347da 100644 --- a/src/Rules/Arrays/NonexistentOffsetInArrayDimFetchRule.php +++ b/src/Rules/Arrays/NonexistentOffsetInArrayDimFetchRule.php @@ -134,6 +134,7 @@ public function processNode(Node $node, Scope $scope): array if ( $node->dim instanceof Node\Expr\FuncCall && $node->dim->name instanceof Node\Name + && !$node->dim->isFirstClassCallable() && $node->dim->name->toLowerString() === 'array_rand' && count($node->dim->getArgs()) >= 1 ) { @@ -162,6 +163,7 @@ public function processNode(Node $node, Scope $scope): array $node->dim instanceof Node\Expr\BinaryOp\Minus && $node->dim->left instanceof Node\Expr\FuncCall && $node->dim->left->name instanceof Node\Name + && !$node->dim->left->isFirstClassCallable() && in_array($node->dim->left->name->toLowerString(), ['count', 'sizeof'], true) && count($node->dim->left->getArgs()) >= 1 && $node->dim->right instanceof Node\Scalar\Int_ diff --git a/tests/PHPStan/Analyser/nsrt/bug-14550.php b/tests/PHPStan/Analyser/nsrt/bug-14550.php new file mode 100644 index 00000000000..880d1be1ba8 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-14550.php @@ -0,0 +1,43 @@ += 8.1 + +declare(strict_types = 1); + +namespace Bug14550; + +use function PHPStan\Testing\assertType; + +/** + * @param list $list + */ +function crashArrayKeyFirst(array $list): void +{ + $fn = array_key_first(...); + assertType('Closure(array): (int|string|null)', $fn); +} + +/** + * @param list $list + */ +function crashArrayKeyLast(array $list): void +{ + $fn = array_key_last(...); + assertType('Closure(array): (int|string|null)', $fn); +} + +/** + * @param list $list + */ +function crashArrayRand(array $list): void +{ + $fn = array_rand(...); + assertType('(Closure(non-empty-array): (int|string))|(Closure(non-empty-array, int<1, max>): (array|int|string))', $fn); +} + +/** + * @param list $list + */ +function crashArraySearch(array $list, string $s): void +{ + $fn = array_search(...); + assertType('Closure(mixed, array, bool=): (int|string|false)', $fn); +} From 9445f33282489b37909cc1ffcbae22016ebd92a3 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Wed, 29 Apr 2026 06:14:57 +0000 Subject: [PATCH 2/8] Add tests for all affected functions in bug-14550 regression test Cover first-class callable syntax for count, sizeof, strlen, mb_strlen, preg_match, gettype, get_class, get_debug_type, get_parent_class, trim, ltrim, rtrim, and array_keys. Co-Authored-By: Claude Opus 4.6 --- tests/PHPStan/Analyser/nsrt/bug-14550.php | 78 +++++++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/tests/PHPStan/Analyser/nsrt/bug-14550.php b/tests/PHPStan/Analyser/nsrt/bug-14550.php index 880d1be1ba8..6ffa972f0eb 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-14550.php +++ b/tests/PHPStan/Analyser/nsrt/bug-14550.php @@ -41,3 +41,81 @@ function crashArraySearch(array $list, string $s): void $fn = array_search(...); assertType('Closure(mixed, array, bool=): (int|string|false)', $fn); } + +function crashCount(): void +{ + $fn = count(...); + assertType('Closure(array|Countable, 0|1=): int<0, max>', $fn); +} + +function crashSizeof(): void +{ + $fn = sizeof(...); + assertType('Closure(array|Countable, int=): int', $fn); +} + +function crashStrlen(): void +{ + $fn = strlen(...); + assertType('Closure(string): int<0, max>', $fn); +} + +function crashMbStrlen(): void +{ + $fn = mb_strlen(...); + assertType('Closure(string, string|null=): int<0, max>', $fn); +} + +function crashPregMatch(): void +{ + $fn = preg_match(...); + assertType('Closure(string, string, array|null=, TFlags=, int=): (0|1|false)', $fn); +} + +function crashGettype(): void +{ + $fn = gettype(...); + assertType('Closure(mixed): string', $fn); +} + +function crashGetClass(): void +{ + $fn = get_class(...); + assertType('Closure(object=): class-string', $fn); +} + +function crashGetDebugType(): void +{ + $fn = get_debug_type(...); + assertType('Closure(mixed): string', $fn); +} + +function crashGetParentClass(): void +{ + $fn = get_parent_class(...); + assertType('Closure(object|string=): (class-string|false)', $fn); +} + +function crashTrim(): void +{ + $fn = trim(...); + assertType('Closure(string, string=): string', $fn); +} + +function crashLtrim(): void +{ + $fn = ltrim(...); + assertType('Closure(string, string=): string', $fn); +} + +function crashRtrim(): void +{ + $fn = rtrim(...); + assertType('Closure(string, string=): string', $fn); +} + +function crashArrayKeys(): void +{ + $fn = array_keys(...); + assertType('Closure(array, mixed=, bool=): list', $fn); +} From 18b34d40126929974afa6b8d5a9ddb5f4c1b36b5 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Wed, 29 Apr 2026 06:18:35 +0000 Subject: [PATCH 3/8] Add isFirstClassCallable() guards in resolveNormalizedIdentical and expand tests Add missing guards for strlen/mb_strlen, array_key_first/array_key_last, get_class/get_debug_type, and substr/strstr/etc patterns in resolveNormalizedIdentical that were not covered by the initial fix. Expand tests to cover all affected functions: strlen, mb_strlen, count, sizeof, preg_match, gettype, get_class, get_debug_type, get_parent_class, trim, ltrim, rtrim, array_keys (foreach), and count (for-loop). Tests exercise both assignment and comparison/equality code paths. Co-Authored-By: Claude Opus 4.6 --- src/Analyser/TypeSpecifier.php | 6 +- tests/PHPStan/Analyser/nsrt/bug-14550.php | 84 +++++++++++++++++------ 2 files changed, 68 insertions(+), 22 deletions(-) diff --git a/src/Analyser/TypeSpecifier.php b/src/Analyser/TypeSpecifier.php index 61837183ee3..1eacf25dd4a 100644 --- a/src/Analyser/TypeSpecifier.php +++ b/src/Analyser/TypeSpecifier.php @@ -2996,9 +2996,10 @@ private function resolveNormalizedIdentical(Expr\BinaryOp\Identical $expr, Scope if ( !$context->null() && $unwrappedLeftExpr instanceof FuncCall - && count($unwrappedLeftExpr->getArgs()) === 1 && $unwrappedLeftExpr->name instanceof Name + && !$unwrappedLeftExpr->isFirstClassCallable() && in_array(strtolower((string) $unwrappedLeftExpr->name), ['strlen', 'mb_strlen'], true) + && count($unwrappedLeftExpr->getArgs()) === 1 && $rightType->isInteger()->yes() ) { if (IntegerRangeType::fromInterval(null, -1)->isSuperTypeOf($rightType)->yes()) { @@ -3034,6 +3035,7 @@ private function resolveNormalizedIdentical(Expr\BinaryOp\Identical $expr, Scope if ( $unwrappedLeftExpr instanceof FuncCall && $unwrappedLeftExpr->name instanceof Name + && !$unwrappedLeftExpr->isFirstClassCallable() && in_array($unwrappedLeftExpr->name->toLowerString(), ['array_key_first', 'array_key_last'], true) && isset($unwrappedLeftExpr->getArgs()[0]) && $rightType->isNull()->yes() @@ -3065,6 +3067,7 @@ private function resolveNormalizedIdentical(Expr\BinaryOp\Identical $expr, Scope $context->true() && $unwrappedLeftExpr instanceof FuncCall && $unwrappedLeftExpr->name instanceof Name + && !$unwrappedLeftExpr->isFirstClassCallable() && in_array(strtolower($unwrappedLeftExpr->name->toString()), ['get_class', 'get_debug_type'], true) && isset($unwrappedLeftExpr->getArgs()[0]) ) { @@ -3090,6 +3093,7 @@ private function resolveNormalizedIdentical(Expr\BinaryOp\Identical $expr, Scope $context->truthy() && $unwrappedLeftExpr instanceof FuncCall && $unwrappedLeftExpr->name instanceof Name + && !$unwrappedLeftExpr->isFirstClassCallable() && in_array(strtolower($unwrappedLeftExpr->name->toString()), [ 'substr', 'strstr', 'stristr', 'strchr', 'strrchr', 'strtolower', 'strtoupper', 'ucfirst', 'lcfirst', 'mb_substr', 'mb_strstr', 'mb_stristr', 'mb_strchr', 'mb_strrchr', 'mb_strtolower', 'mb_strtoupper', 'mb_ucfirst', 'mb_lcfirst', diff --git a/tests/PHPStan/Analyser/nsrt/bug-14550.php b/tests/PHPStan/Analyser/nsrt/bug-14550.php index 6ffa972f0eb..5a1d33c7ed8 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-14550.php +++ b/tests/PHPStan/Analyser/nsrt/bug-14550.php @@ -42,80 +42,122 @@ function crashArraySearch(array $list, string $s): void assertType('Closure(mixed, array, bool=): (int|string|false)', $fn); } -function crashCount(): void +function testStrlen(): void { - $fn = count(...); - assertType('Closure(array|Countable, 0|1=): int<0, max>', $fn); + $fn = strlen(...); + assertType('Closure(string): int<0, max>', $fn); + + if (strlen(...) < 1) {} + if (0 < strlen(...)) {} } -function crashSizeof(): void +function testMbStrlen(): void { - $fn = sizeof(...); - assertType('Closure(array|Countable, int=): int', $fn); + $fn = mb_strlen(...); + assertType('Closure(string, string|null=): int<0, max>', $fn); + + if (mb_strlen(...) < 1) {} + if (0 < mb_strlen(...)) {} } -function crashStrlen(): void +function testCount(): void { - $fn = strlen(...); - assertType('Closure(string): int<0, max>', $fn); + $fn = count(...); + assertType('Closure(array|Countable, 0|1=): int<0, max>', $fn); + + if (count(...) < 1) {} + if (0 < count(...)) {} + if (count(...) === 0) {} } -function crashMbStrlen(): void +function testSizeof(): void { - $fn = mb_strlen(...); - assertType('Closure(string, string|null=): int<0, max>', $fn); + $fn = sizeof(...); + assertType('Closure(array|Countable, int=): int', $fn); } -function crashPregMatch(): void +function testPregMatch(): void { $fn = preg_match(...); assertType('Closure(string, string, array|null=, TFlags=, int=): (0|1|false)', $fn); + + if (preg_match(...) < 1) {} + if (1 <= preg_match(...)) {} } -function crashGettype(): void +function testGettype(): void { $fn = gettype(...); assertType('Closure(mixed): string', $fn); + + if (gettype(...) === 'string') {} + if (gettype(...) == 'string') {} } -function crashGetClass(): void +function testGetClass(): void { $fn = get_class(...); assertType('Closure(object=): class-string', $fn); + + if (get_class(...) == 'stdClass') {} } -function crashGetDebugType(): void +function testGetDebugType(): void { $fn = get_debug_type(...); assertType('Closure(mixed): string', $fn); + + if (get_debug_type(...) == 'string') {} } -function crashGetParentClass(): void +function testGetParentClass(): void { $fn = get_parent_class(...); assertType('Closure(object|string=): (class-string|false)', $fn); + + if (get_parent_class(...) === 'stdClass') {} } -function crashTrim(): void +function testTrim(): void { $fn = trim(...); assertType('Closure(string, string=): string', $fn); + + if (trim(...) !== '') {} } -function crashLtrim(): void +function testLtrim(): void { $fn = ltrim(...); assertType('Closure(string, string=): string', $fn); + + if (ltrim(...) !== '') {} } -function crashRtrim(): void +function testRtrim(): void { $fn = rtrim(...); assertType('Closure(string, string=): string', $fn); + + if (rtrim(...) !== '') {} } -function crashArrayKeys(): void +/** + * @param list $list + */ +function testArrayKeysInForeach(array $list): void { + foreach (array_keys(...) as $key) { + } $fn = array_keys(...); assertType('Closure(array, mixed=, bool=): list', $fn); } + +/** + * @param list $list + */ +function testCountInForLoop(array $list): void +{ + for ($i = 0; $i < count(...); $i++) { + } +} From 810f7d3e35e9edcbce550eb9d063b83934362dec Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Wed, 29 Apr 2026 08:30:44 +0200 Subject: [PATCH 4/8] fix tests --- tests/PHPStan/Analyser/nsrt/bug-14550.php | 37 ----------------------- 1 file changed, 37 deletions(-) diff --git a/tests/PHPStan/Analyser/nsrt/bug-14550.php b/tests/PHPStan/Analyser/nsrt/bug-14550.php index 5a1d33c7ed8..83a7773fc5c 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-14550.php +++ b/tests/PHPStan/Analyser/nsrt/bug-14550.php @@ -46,28 +46,18 @@ function testStrlen(): void { $fn = strlen(...); assertType('Closure(string): int<0, max>', $fn); - - if (strlen(...) < 1) {} - if (0 < strlen(...)) {} } function testMbStrlen(): void { $fn = mb_strlen(...); assertType('Closure(string, string|null=): int<0, max>', $fn); - - if (mb_strlen(...) < 1) {} - if (0 < mb_strlen(...)) {} } function testCount(): void { $fn = count(...); assertType('Closure(array|Countable, 0|1=): int<0, max>', $fn); - - if (count(...) < 1) {} - if (0 < count(...)) {} - if (count(...) === 0) {} } function testSizeof(): void @@ -80,66 +70,48 @@ function testPregMatch(): void { $fn = preg_match(...); assertType('Closure(string, string, array|null=, TFlags=, int=): (0|1|false)', $fn); - - if (preg_match(...) < 1) {} - if (1 <= preg_match(...)) {} } function testGettype(): void { $fn = gettype(...); assertType('Closure(mixed): string', $fn); - - if (gettype(...) === 'string') {} - if (gettype(...) == 'string') {} } function testGetClass(): void { $fn = get_class(...); assertType('Closure(object=): class-string', $fn); - - if (get_class(...) == 'stdClass') {} } function testGetDebugType(): void { $fn = get_debug_type(...); assertType('Closure(mixed): string', $fn); - - if (get_debug_type(...) == 'string') {} } function testGetParentClass(): void { $fn = get_parent_class(...); assertType('Closure(object|string=): (class-string|false)', $fn); - - if (get_parent_class(...) === 'stdClass') {} } function testTrim(): void { $fn = trim(...); assertType('Closure(string, string=): string', $fn); - - if (trim(...) !== '') {} } function testLtrim(): void { $fn = ltrim(...); assertType('Closure(string, string=): string', $fn); - - if (ltrim(...) !== '') {} } function testRtrim(): void { $fn = rtrim(...); assertType('Closure(string, string=): string', $fn); - - if (rtrim(...) !== '') {} } /** @@ -152,12 +124,3 @@ function testArrayKeysInForeach(array $list): void $fn = array_keys(...); assertType('Closure(array, mixed=, bool=): list', $fn); } - -/** - * @param list $list - */ -function testCountInForLoop(array $list): void -{ - for ($i = 0; $i < count(...); $i++) { - } -} From ffe4ab4964dfe2e44f58368f3ad2896001aad1a4 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Wed, 29 Apr 2026 08:32:39 +0200 Subject: [PATCH 5/8] cleanup --- src/Analyser/ExprHandler/AssignHandler.php | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/Analyser/ExprHandler/AssignHandler.php b/src/Analyser/ExprHandler/AssignHandler.php index 618b81edd1c..5d2a084ec04 100644 --- a/src/Analyser/ExprHandler/AssignHandler.php +++ b/src/Analyser/ExprHandler/AssignHandler.php @@ -1221,7 +1221,6 @@ private function shouldKeepList(ArrayDimFetch $arrayDimFetch, Scope $scope, Type && $arrayDimFetch->dim->right instanceof Node\Scalar\Int_ && $arrayDimFetch->dim->left instanceof Expr\FuncCall && $arrayDimFetch->dim->left->name instanceof Name - && !$arrayDimFetch->dim->left->isFirstClassCallable() && in_array($arrayDimFetch->dim->left->name->toLowerString(), ['count', 'sizeof'], true) && count($arrayDimFetch->dim->left->getArgs()) === 1 // could support COUNT_RECURSIVE, COUNT_NORMAL && $this->isSameVariable($arrayDimFetch->var, $arrayDimFetch->dim->left->getArgs()[0]->value) @@ -1232,7 +1231,6 @@ private function shouldKeepList(ArrayDimFetch $arrayDimFetch, Scope $scope, Type } elseif ( // keep list for $list[array_key_last($list)] and $list[array_key_first($list)] assignments $arrayDimFetch->dim instanceof Expr\FuncCall && $arrayDimFetch->dim->name instanceof Name - && !$arrayDimFetch->dim->isFirstClassCallable() && in_array($arrayDimFetch->dim->name->toLowerString(), ['array_key_last', 'array_key_first'], true) && count($arrayDimFetch->dim->getArgs()) >= 1 && $this->isSameVariable($arrayDimFetch->var, $arrayDimFetch->dim->getArgs()[0]->value) @@ -1241,7 +1239,6 @@ private function shouldKeepList(ArrayDimFetch $arrayDimFetch, Scope $scope, Type } elseif ( // keep list for $list[array_search($needle, $list)] assignments $arrayDimFetch->dim instanceof Expr\FuncCall && $arrayDimFetch->dim->name instanceof Name - && !$arrayDimFetch->dim->isFirstClassCallable() && $arrayDimFetch->dim->name->toLowerString() === 'array_search' && count($arrayDimFetch->dim->getArgs()) >= 1 && $this->isSameVariable($arrayDimFetch->var, $arrayDimFetch->dim->getArgs()[1]->value) From a45a0fde6fe29365c08dc76062d976f1fdb0fb0e Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Wed, 29 Apr 2026 08:34:08 +0200 Subject: [PATCH 6/8] cleanup --- src/Rules/Arrays/NonexistentOffsetInArrayDimFetchRule.php | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/Rules/Arrays/NonexistentOffsetInArrayDimFetchRule.php b/src/Rules/Arrays/NonexistentOffsetInArrayDimFetchRule.php index 952ffd347da..9a4947d5216 100644 --- a/src/Rules/Arrays/NonexistentOffsetInArrayDimFetchRule.php +++ b/src/Rules/Arrays/NonexistentOffsetInArrayDimFetchRule.php @@ -134,7 +134,6 @@ public function processNode(Node $node, Scope $scope): array if ( $node->dim instanceof Node\Expr\FuncCall && $node->dim->name instanceof Node\Name - && !$node->dim->isFirstClassCallable() && $node->dim->name->toLowerString() === 'array_rand' && count($node->dim->getArgs()) >= 1 ) { @@ -163,7 +162,6 @@ public function processNode(Node $node, Scope $scope): array $node->dim instanceof Node\Expr\BinaryOp\Minus && $node->dim->left instanceof Node\Expr\FuncCall && $node->dim->left->name instanceof Node\Name - && !$node->dim->left->isFirstClassCallable() && in_array($node->dim->left->name->toLowerString(), ['count', 'sizeof'], true) && count($node->dim->left->getArgs()) >= 1 && $node->dim->right instanceof Node\Scalar\Int_ From dff735b3e8484f881259fbdc5cfacafb6ff6e090 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Wed, 29 Apr 2026 07:04:24 +0000 Subject: [PATCH 7/8] Rewrite tests to exercise actual guarded code paths Most tests were simple `$fn = someFunc(...); assertType(...)` patterns that never triggered the assertion because standalone assignments only exercise the TypeSpecifier assign patterns (array_key_first, array_key_last, array_rand, count-1). Functions like strlen, count, gettype, trim etc. need their first-class callables inside if-conditions to exercise the comparison, identical, and string-equality guards. Each test now uses the specific expression pattern that would crash without the corresponding isFirstClassCallable() guard: - Comparisons: `if (count(...) < 1)`, `if (0 < strlen(...))` - Identicals: `if (count(...) === 0)`, `if (strlen(...) === 0)` - Null checks: `if (array_key_first(...) !== null)` - String equality: `if (gettype(...) === 'string')`, `if (trim(...) !== '')` - Conditions: `if ($key = array_search(...))` - Foreach: `foreach (array_keys(...) as $key)` - For loops: `for ($i = 0; $i < count(...); $i++)` Co-Authored-By: Claude Opus 4.6 --- tests/PHPStan/Analyser/nsrt/bug-14550.php | 158 +++++++++++++--------- 1 file changed, 96 insertions(+), 62 deletions(-) diff --git a/tests/PHPStan/Analyser/nsrt/bug-14550.php b/tests/PHPStan/Analyser/nsrt/bug-14550.php index 83a7773fc5c..a980eaa38a9 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-14550.php +++ b/tests/PHPStan/Analyser/nsrt/bug-14550.php @@ -6,121 +6,155 @@ use function PHPStan\Testing\assertType; -/** - * @param list $list - */ -function crashArrayKeyFirst(array $list): void +// Standalone assignments trigger TypeSpecifier via NodeScopeResolver null-context call +function testArrayKeyFirstAssign(): void { $fn = array_key_first(...); assertType('Closure(array): (int|string|null)', $fn); } -/** - * @param list $list - */ -function crashArrayKeyLast(array $list): void +function testArrayKeyLastAssign(): void { $fn = array_key_last(...); assertType('Closure(array): (int|string|null)', $fn); } -/** - * @param list $list - */ -function crashArrayRand(array $list): void +function testArrayRandAssign(): void { $fn = array_rand(...); assertType('(Closure(non-empty-array): (int|string))|(Closure(non-empty-array, int<1, max>): (array|int|string))', $fn); } -/** - * @param list $list - */ -function crashArraySearch(array $list, string $s): void +function testCountMinusOneAssign(): void { - $fn = array_search(...); - assertType('Closure(mixed, array, bool=): (int|string|false)', $fn); + $idx = count(...) - 1; + assertType('Closure(array|Countable, 0|1=): int<0, max>', count(...)); } -function testStrlen(): void +// array_search guard needs true context, so it must be in a condition +function testArraySearchInCondition(): void { - $fn = strlen(...); - assertType('Closure(string): int<0, max>', $fn); + if ($key = array_search(...)) { + assertType('Closure(mixed, array, bool=): (int|string|false)', $key); + } } -function testMbStrlen(): void +// Comparison guards in TypeSpecifier (Smaller/SmallerOrEqual) +function testCountInComparisons(): void { - $fn = mb_strlen(...); - assertType('Closure(string, string|null=): int<0, max>', $fn); + if (count(...) < 1) {} + if (0 < count(...)) {} + assertType('Closure(array|Countable, 0|1=): int<0, max>', count(...)); } -function testCount(): void +function testSizeofInComparisons(): void { - $fn = count(...); - assertType('Closure(array|Countable, 0|1=): int<0, max>', $fn); + if (sizeof(...) < 1) {} + if (0 < sizeof(...)) {} + assertType('Closure(array|Countable, int=): int', sizeof(...)); } -function testSizeof(): void +function testCountMinusOneInComparison(): void { - $fn = sizeof(...); - assertType('Closure(array|Countable, int=): int', $fn); + $i = 0; + if ($i < count(...) - 1) {} + assertType('Closure(array|Countable, 0|1=): int<0, max>', count(...)); } -function testPregMatch(): void +function testStrlenInComparisons(): void { - $fn = preg_match(...); - assertType('Closure(string, string, array|null=, TFlags=, int=): (0|1|false)', $fn); + if (strlen(...) < 1) {} + if (0 < strlen(...)) {} + assertType('Closure(string): int<0, max>', strlen(...)); } -function testGettype(): void +function testMbStrlenInComparisons(): void { - $fn = gettype(...); - assertType('Closure(mixed): string', $fn); + if (mb_strlen(...) < 1) {} + if (0 < mb_strlen(...)) {} + assertType('Closure(string, string|null=): int<0, max>', mb_strlen(...)); } -function testGetClass(): void +function testPregMatchInComparisons(): void { - $fn = get_class(...); - assertType('Closure(object=): class-string', $fn); + if (preg_match(...) < 1) {} + if (0 < preg_match(...)) {} + assertType('Closure(string, string, array|null=, TFlags=, int=): (0|1|false)', preg_match(...)); } -function testGetDebugType(): void +// Identical/NotIdentical guards in resolveNormalizedIdentical +function testCountIdentical(): void { - $fn = get_debug_type(...); - assertType('Closure(mixed): string', $fn); + if (count(...) === 0) {} + assertType('Closure(array|Countable, 0|1=): int<0, max>', count(...)); } -function testGetParentClass(): void +function testStrlenIdentical(): void { - $fn = get_parent_class(...); - assertType('Closure(object|string=): (class-string|false)', $fn); + if (strlen(...) === 0) {} + if (mb_strlen(...) === 0) {} + assertType('Closure(string): int<0, max>', strlen(...)); } -function testTrim(): void +function testArrayKeyFirstNullComparison(): void { - $fn = trim(...); - assertType('Closure(string, string=): string', $fn); + if (array_key_first(...) !== null) {} + if (array_key_last(...) !== null) {} + assertType('Closure(array): (int|string|null)', array_key_first(...)); } -function testLtrim(): void +function testGetClassIdentical(): void { - $fn = ltrim(...); - assertType('Closure(string, string=): string', $fn); + if (get_class(...) === 'stdClass') {} + if (get_debug_type(...) === 'string') {} + assertType('Closure(object=): class-string', get_class(...)); } -function testRtrim(): void +function testStringFuncIdentical(): void { - $fn = rtrim(...); - assertType('Closure(string, string=): string', $fn); + if (strtolower(...) === 'test') {} + assertType('Closure(string): lowercase-string', strtolower(...)); } -/** - * @param list $list - */ -function testArrayKeysInForeach(array $list): void +// String equality guards in specifyTypesForConstantStringBinaryExpression +function testGettypeEquality(): void { - foreach (array_keys(...) as $key) { - } - $fn = array_keys(...); - assertType('Closure(array, mixed=, bool=): list', $fn); + if (gettype(...) === 'string') {} + if (gettype(...) == 'string') {} + assertType('Closure(mixed): string', gettype(...)); +} + +function testGetClassEquality(): void +{ + if (get_class(...) == 'stdClass') {} + if (get_debug_type(...) == 'string') {} + assertType('Closure(object=): class-string', get_class(...)); +} + +function testGetParentClassEquality(): void +{ + if (get_parent_class(...) === 'stdClass') {} + assertType('Closure(object|string=): (class-string|false)', get_parent_class(...)); +} + +function testTrimEquality(): void +{ + if (trim(...) !== '') {} + if (ltrim(...) !== '') {} + if (rtrim(...) !== '') {} + assertType('Closure(string, string=): string', trim(...)); +} + +// NodeScopeResolver guards +function testArrayKeysInForeach(): void +{ + foreach (array_keys(...) as $key) {} + assertType('Closure(array, mixed=, bool=): list', array_keys(...)); +} + +function testCountInForLoop(): void +{ + for ($i = 0; $i < count(...); $i++) {} + for ($i = 0; count(...) > $i; $i++) {} + assertType('Closure(array|Countable, 0|1=): int<0, max>', count(...)); } From ce2e74329e51118d453554837a511f16311a3ca3 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Wed, 29 Apr 2026 07:30:08 +0000 Subject: [PATCH 8/8] Convert bug-14550 test from NSRT to AnalyserIntegrationTest Since this is a crash bug, an AnalyserIntegrationTest is more appropriate than an NSRT test. The test verifies the analysis completes without crashing on first-class callables in various TypeSpecifier code paths. Co-Authored-By: Claude Opus 4.6 --- .../Analyser/AnalyserIntegrationTest.php | 7 +++++ .../Analyser/{nsrt => data}/bug-14550.php | 30 ------------------- 2 files changed, 7 insertions(+), 30 deletions(-) rename tests/PHPStan/Analyser/{nsrt => data}/bug-14550.php (51%) diff --git a/tests/PHPStan/Analyser/AnalyserIntegrationTest.php b/tests/PHPStan/Analyser/AnalyserIntegrationTest.php index 135aad26f8b..7522b5475f8 100644 --- a/tests/PHPStan/Analyser/AnalyserIntegrationTest.php +++ b/tests/PHPStan/Analyser/AnalyserIntegrationTest.php @@ -1550,6 +1550,13 @@ public function testBug14542(): void $this->assertNoErrors($errors); } + public function testBug14550(): void + { + // crash + $errors = $this->runAnalyse(__DIR__ . '/data/bug-14550.php'); + $this->assertNotEmpty($errors); + } + /** * @param string[]|null $allAnalysedFiles * @return list diff --git a/tests/PHPStan/Analyser/nsrt/bug-14550.php b/tests/PHPStan/Analyser/data/bug-14550.php similarity index 51% rename from tests/PHPStan/Analyser/nsrt/bug-14550.php rename to tests/PHPStan/Analyser/data/bug-14550.php index a980eaa38a9..95b5ecb4ac2 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-14550.php +++ b/tests/PHPStan/Analyser/data/bug-14550.php @@ -4,137 +4,111 @@ namespace Bug14550; -use function PHPStan\Testing\assertType; - -// Standalone assignments trigger TypeSpecifier via NodeScopeResolver null-context call function testArrayKeyFirstAssign(): void { $fn = array_key_first(...); - assertType('Closure(array): (int|string|null)', $fn); } function testArrayKeyLastAssign(): void { $fn = array_key_last(...); - assertType('Closure(array): (int|string|null)', $fn); } function testArrayRandAssign(): void { $fn = array_rand(...); - assertType('(Closure(non-empty-array): (int|string))|(Closure(non-empty-array, int<1, max>): (array|int|string))', $fn); } function testCountMinusOneAssign(): void { $idx = count(...) - 1; - assertType('Closure(array|Countable, 0|1=): int<0, max>', count(...)); } -// array_search guard needs true context, so it must be in a condition function testArraySearchInCondition(): void { if ($key = array_search(...)) { - assertType('Closure(mixed, array, bool=): (int|string|false)', $key); } } -// Comparison guards in TypeSpecifier (Smaller/SmallerOrEqual) function testCountInComparisons(): void { if (count(...) < 1) {} if (0 < count(...)) {} - assertType('Closure(array|Countable, 0|1=): int<0, max>', count(...)); } function testSizeofInComparisons(): void { if (sizeof(...) < 1) {} if (0 < sizeof(...)) {} - assertType('Closure(array|Countable, int=): int', sizeof(...)); } function testCountMinusOneInComparison(): void { $i = 0; if ($i < count(...) - 1) {} - assertType('Closure(array|Countable, 0|1=): int<0, max>', count(...)); } function testStrlenInComparisons(): void { if (strlen(...) < 1) {} if (0 < strlen(...)) {} - assertType('Closure(string): int<0, max>', strlen(...)); } function testMbStrlenInComparisons(): void { if (mb_strlen(...) < 1) {} if (0 < mb_strlen(...)) {} - assertType('Closure(string, string|null=): int<0, max>', mb_strlen(...)); } function testPregMatchInComparisons(): void { if (preg_match(...) < 1) {} if (0 < preg_match(...)) {} - assertType('Closure(string, string, array|null=, TFlags=, int=): (0|1|false)', preg_match(...)); } -// Identical/NotIdentical guards in resolveNormalizedIdentical function testCountIdentical(): void { if (count(...) === 0) {} - assertType('Closure(array|Countable, 0|1=): int<0, max>', count(...)); } function testStrlenIdentical(): void { if (strlen(...) === 0) {} if (mb_strlen(...) === 0) {} - assertType('Closure(string): int<0, max>', strlen(...)); } function testArrayKeyFirstNullComparison(): void { if (array_key_first(...) !== null) {} if (array_key_last(...) !== null) {} - assertType('Closure(array): (int|string|null)', array_key_first(...)); } function testGetClassIdentical(): void { if (get_class(...) === 'stdClass') {} if (get_debug_type(...) === 'string') {} - assertType('Closure(object=): class-string', get_class(...)); } function testStringFuncIdentical(): void { if (strtolower(...) === 'test') {} - assertType('Closure(string): lowercase-string', strtolower(...)); } -// String equality guards in specifyTypesForConstantStringBinaryExpression function testGettypeEquality(): void { if (gettype(...) === 'string') {} if (gettype(...) == 'string') {} - assertType('Closure(mixed): string', gettype(...)); } function testGetClassEquality(): void { if (get_class(...) == 'stdClass') {} if (get_debug_type(...) == 'string') {} - assertType('Closure(object=): class-string', get_class(...)); } function testGetParentClassEquality(): void { if (get_parent_class(...) === 'stdClass') {} - assertType('Closure(object|string=): (class-string|false)', get_parent_class(...)); } function testTrimEquality(): void @@ -142,19 +116,15 @@ function testTrimEquality(): void if (trim(...) !== '') {} if (ltrim(...) !== '') {} if (rtrim(...) !== '') {} - assertType('Closure(string, string=): string', trim(...)); } -// NodeScopeResolver guards function testArrayKeysInForeach(): void { foreach (array_keys(...) as $key) {} - assertType('Closure(array, mixed=, bool=): list', array_keys(...)); } function testCountInForLoop(): void { for ($i = 0; $i < count(...); $i++) {} for ($i = 0; count(...) > $i; $i++) {} - assertType('Closure(array|Countable, 0|1=): int<0, max>', count(...)); }