From be1bfd465b1ad005764c00d6e6680492e919fe74 Mon Sep 17 00:00:00 2001 From: staabm <120441+staabm@users.noreply.github.com> Date: Thu, 19 Feb 2026 08:21:00 +0000 Subject: [PATCH 1/2] Infer offset after array_key_first/array_key_last with null check - Added conditional expression holders in NodeScopeResolver for $key = array_key_first($arr) / array_key_last($arr) assignments - When $key !== null is checked, the array is narrowed to non-empty and $arr[$key] is recognized as having the array's value type - New regression tests in tests/PHPStan/Rules/Arrays/data/bug-14081.php and tests/PHPStan/Analyser/nsrt/bug-14081.php - Updated bug-13546 test assertion to reflect improved narrowing Fixes phpstan/phpstan#14081 --- src/Analyser/NodeScopeResolver.php | 40 ++++++++++ tests/PHPStan/Analyser/nsrt/bug-13546.php | 2 +- tests/PHPStan/Analyser/nsrt/bug-14081.php | 40 ++++++++++ ...nexistentOffsetInArrayDimFetchRuleTest.php | 6 ++ tests/PHPStan/Rules/Arrays/data/bug-14081.php | 73 +++++++++++++++++++ 5 files changed, 160 insertions(+), 1 deletion(-) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-14081.php create mode 100644 tests/PHPStan/Rules/Arrays/data/bug-14081.php diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 0ee1be16519..d1519a24ce9 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -6108,6 +6108,46 @@ private function processAssignVar( $conditionalExpressions = $this->processSureNotTypesForConditionalExpressionsAfterAssign($scope, $var->name, $conditionalExpressions, $falseySpecifiedTypes, $falseyType); } + if ( + $assignedExpr instanceof FuncCall + && $assignedExpr->name instanceof Name + && in_array($assignedExpr->name->toLowerString(), ['array_key_first', 'array_key_last'], true) + && count($assignedExpr->getArgs()) >= 1 + ) { + $arrayArg = $assignedExpr->getArgs()[0]->value; + $arrayType = $scope->getType($arrayArg); + $nonNullType = TypeCombinator::removeNull($type); + if ( + $arrayArg instanceof Variable + && is_string($arrayArg->name) + && $arrayType->isArray()->yes() + && !$arrayType->isIterableAtLeastOnce()->yes() + && !$nonNullType instanceof NeverType + && !$type->equals($nonNullType) + ) { + $narrowedArrayType = TypeCombinator::intersect($arrayType, new NonEmptyArrayType()); + + $arrayExprString = '$' . $arrayArg->name; + $arrayHolder = new ConditionalExpressionHolder([ + '$' . $var->name => ExpressionTypeHolder::createYes(new Variable($var->name), $nonNullType), + ], ExpressionTypeHolder::createYes( + $arrayArg, + $narrowedArrayType, + )); + $conditionalExpressions[$arrayExprString][$arrayHolder->getKey()] = $arrayHolder; + + $dimFetch = new ArrayDimFetch($arrayArg, $var); + $dimFetchExprString = sprintf('$%s[$%s]', $arrayArg->name, $var->name); + $dimFetchHolder = new ConditionalExpressionHolder([ + '$' . $var->name => ExpressionTypeHolder::createYes(new Variable($var->name), $nonNullType), + ], ExpressionTypeHolder::createYes( + $dimFetch, + $narrowedArrayType->getIterableValueType(), + )); + $conditionalExpressions[$dimFetchExprString][$dimFetchHolder->getKey()] = $dimFetchHolder; + } + } + $this->callNodeCallback($nodeCallback, new VariableAssignNode($var, $assignedExpr), $scopeBeforeAssignEval, $storage); $scope = $scope->assignVariable($var->name, $type, $scope->getNativeType($assignedExpr), TrinaryLogic::createYes()); foreach ($conditionalExpressions as $exprString => $holders) { diff --git a/tests/PHPStan/Analyser/nsrt/bug-13546.php b/tests/PHPStan/Analyser/nsrt/bug-13546.php index 465689b211d..31b254844ba 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-13546.php +++ b/tests/PHPStan/Analyser/nsrt/bug-13546.php @@ -66,7 +66,7 @@ function mixedLast($mixed): void function firstInCondition(array $array) { if (($key = array_key_first($array)) !== null) { - assertType('list', $array); // could be 'non-empty-list' + assertType('non-empty-list', $array); return $array[$key]; } assertType('list', $array); diff --git a/tests/PHPStan/Analyser/nsrt/bug-14081.php b/tests/PHPStan/Analyser/nsrt/bug-14081.php new file mode 100644 index 00000000000..7b89d9f6947 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-14081.php @@ -0,0 +1,40 @@ + $list */ +function firstWithNullCheck(array $list): void +{ + $key = array_key_first($list); + if ($key !== null) { + assertType('non-empty-list', $list); + assertType('int<0, max>', $key); + assertType('string', $list[$key]); + } +} + +/** @param list $list */ +function lastWithNullCheck(array $list): void +{ + $key = array_key_last($list); + if ($key !== null) { + assertType('non-empty-list', $list); + assertType('int<0, max>', $key); + assertType('string', $list[$key]); + } +} + +/** @param array $map */ +function firstOnMapWithNullCheck(array $map): void +{ + $key = array_key_first($map); + if ($key !== null) { + assertType('non-empty-array', $map); + assertType('string', $key); + assertType('int', $map[$key]); + } +} diff --git a/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php b/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php index a641163fe1e..c092b462364 100644 --- a/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php +++ b/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php @@ -1145,4 +1145,10 @@ public function testBug11276(): void $this->analyse([__DIR__ . '/data/bug-11276.php'], []); } + public function testBug14081(): void + { + $this->reportPossiblyNonexistentGeneralArrayOffset = true; + $this->analyse([__DIR__ . '/data/bug-14081.php'], []); + } + } diff --git a/tests/PHPStan/Rules/Arrays/data/bug-14081.php b/tests/PHPStan/Rules/Arrays/data/bug-14081.php new file mode 100644 index 00000000000..59af6f2e224 --- /dev/null +++ b/tests/PHPStan/Rules/Arrays/data/bug-14081.php @@ -0,0 +1,73 @@ + $list + */ + public function firstWithNullCheck(array $list): string + { + $key = array_key_first($list); + if ($key !== null) { + return $list[$key]; + } + + return 'nothing'; + } + + /** + * @param list $list + */ + public function lastWithNullCheck(array $list): string + { + $key = array_key_last($list); + if ($key !== null) { + return $list[$key]; + } + + return 'nothing'; + } + + /** + * @param array $map + */ + public function firstOnMapWithNullCheck(array $map): int + { + $key = array_key_first($map); + if ($key !== null) { + return $map[$key]; + } + + return 0; + } + + /** + * @param array $map + */ + public function lastOnMapWithNullCheck(array $map): int + { + $key = array_key_last($map); + if ($key !== null) { + return $map[$key]; + } + + return 0; + } + + /** + * @param list $list + */ + public function nullCheckReversed(array $list): string + { + $key = array_key_first($list); + if (null !== $key) { + return $list[$key]; + } + + return 'nothing'; + } +} From 3a4428f35facb78fadf5058fbd903dbcdc565381 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Thu, 19 Feb 2026 09:06:49 +0000 Subject: [PATCH 2/2] Fix CI failures [claude-ci-fix] Automated fix attempt 1 for CI failures. --- tests/PHPStan/Analyser/nsrt/bug-14081.php | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/PHPStan/Analyser/nsrt/bug-14081.php b/tests/PHPStan/Analyser/nsrt/bug-14081.php index 7b89d9f6947..609c14c0148 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-14081.php +++ b/tests/PHPStan/Analyser/nsrt/bug-14081.php @@ -38,3 +38,12 @@ function firstOnMapWithNullCheck(array $map): void assertType('int', $map[$key]); } } + +/** @param iterable $data */ +function iterableWithNullCheck(iterable $data): void +{ + $key = array_key_first($data); + if ($key !== null) { + assertType('mixed', $data[$key]); + } +}