From f39a922c196f214fb687ed4c4bd6e762911a5b9c Mon Sep 17 00:00:00 2001 From: staabm <120441+staabm@users.noreply.github.com> Date: Mon, 27 Apr 2026 08:13:28 +0000 Subject: [PATCH 1/2] Track `$arr[$key]` existence across `array_search`/`array_find_key` via conditional expression holders - Add conditional expression holders in TypeSpecifier for `$key = array_search($needle, $arr)` that fire when `$key !== false`, registering `$arr[$key]` as existing - Add conditional expression holders for `$key = array_find_key($arr, $cb)` that fire when `$key !== null`, registering `$arr[$key]` as existing - Add `array_find_key` to the existing `array_key_first/last !== null` comparison handler to narrow array to non-empty - Move `array_search` true-context handling from standalone block into unified handler alongside the conditional holder logic - Update existing test that was asserting the buggy behavior (separate assignment `$key = array_search(...)` followed by `if ($key !== false)` was reporting "Offset might not exist") --- src/Analyser/TypeSpecifier.php | 108 ++++++++++++++---- .../Analyser/nsrt/array-find-key-existing.php | 30 +++++ .../Analyser/nsrt/array-search-existing.php | 39 +++++++ ...nexistentOffsetInArrayDimFetchRuleTest.php | 14 ++- tests/PHPStan/Rules/Arrays/data/bug-14537.php | 71 ++++++++++++ 5 files changed, 234 insertions(+), 28 deletions(-) create mode 100644 tests/PHPStan/Analyser/nsrt/array-find-key-existing.php create mode 100644 tests/PHPStan/Analyser/nsrt/array-search-existing.php create mode 100644 tests/PHPStan/Rules/Arrays/data/bug-14537.php diff --git a/src/Analyser/TypeSpecifier.php b/src/Analyser/TypeSpecifier.php index 4a22a57950c..bfa13142b27 100644 --- a/src/Analyser/TypeSpecifier.php +++ b/src/Analyser/TypeSpecifier.php @@ -876,6 +876,90 @@ public function specifyTypesInCondition( } } + // infer $arr[$key] after $key = array_search($needle, $arr) + if ( + $expr->expr instanceof FuncCall + && $expr->expr->name instanceof Name + && $expr->expr->name->toLowerString() === 'array_search' + && count($expr->expr->getArgs()) >= 2 + ) { + $arrayArg = $expr->expr->getArgs()[1]->value; + $arrayType = $scope->getType($arrayArg); + + if ($arrayType->isArray()->yes()) { + if ($context->true()) { + $dimFetch = new ArrayDimFetch($arrayArg, $expr->var); + + $specifiedTypes = $specifiedTypes->unionWith( + $this->create($dimFetch, $arrayType->getIterableValueType(), TypeSpecifierContext::createTrue(), $scope), + ); + } elseif ($expr->var instanceof Expr\Variable && is_string($expr->var->name)) { + $keyType = $scope->getType($expr->expr); + $nonFalseKeyType = TypeCombinator::remove($keyType, new ConstantBooleanType(false)); + if (!$nonFalseKeyType instanceof NeverType && !$keyType->isFalse()->yes()) { + $dimFetch = new ArrayDimFetch($arrayArg, $expr->var); + $dimFetchString = $this->exprPrinter->printExpr($dimFetch); + $keyExprString = $this->exprPrinter->printExpr($expr->var); + + $holder = new ConditionalExpressionHolder( + [$keyExprString => ExpressionTypeHolder::createYes($expr->var, $nonFalseKeyType)], + ExpressionTypeHolder::createYes($dimFetch, $arrayType->getIterableValueType()), + ); + + $specifiedTypes = $specifiedTypes->unionWith( + (new SpecifiedTypes([], []))->setNewConditionalExpressionHolders([ + $dimFetchString => [$holder->getKey() => $holder], + ]), + ); + } + } + } + } + + // infer $arr[$key] after $key = array_find_key($arr, $callback) + if ( + $expr->expr instanceof FuncCall + && $expr->expr->name instanceof Name + && $expr->expr->name->toLowerString() === 'array_find_key' + && count($expr->expr->getArgs()) >= 2 + ) { + $arrayArg = $expr->expr->getArgs()[0]->value; + $arrayType = $scope->getType($arrayArg); + + if ($arrayType->isArray()->yes()) { + if ($context->true()) { + $specifiedTypes = $specifiedTypes->unionWith( + $this->create($arrayArg, new NonEmptyArrayType(), TypeSpecifierContext::createTrue(), $scope), + ); + + $dimFetch = new ArrayDimFetch($arrayArg, $expr->var); + + $specifiedTypes = $specifiedTypes->unionWith( + $this->create($dimFetch, $arrayType->getIterableValueType(), TypeSpecifierContext::createTrue(), $scope), + ); + } elseif ($expr->var instanceof Expr\Variable && is_string($expr->var->name)) { + $keyType = $scope->getType($expr->expr); + $nonNullKeyType = TypeCombinator::removeNull($keyType); + if (!$nonNullKeyType instanceof NeverType && !$keyType->isNull()->yes()) { + $dimFetch = new ArrayDimFetch($arrayArg, $expr->var); + $dimFetchString = $this->exprPrinter->printExpr($dimFetch); + $keyExprString = $this->exprPrinter->printExpr($expr->var); + + $holder = new ConditionalExpressionHolder( + [$keyExprString => ExpressionTypeHolder::createYes($expr->var, $nonNullKeyType)], + ExpressionTypeHolder::createYes($dimFetch, $arrayType->getIterableValueType()), + ); + + $specifiedTypes = $specifiedTypes->unionWith( + (new SpecifiedTypes([], []))->setNewConditionalExpressionHolders([ + $dimFetchString => [$holder->getKey() => $holder], + ]), + ); + } + } + } + } + if ($context->null()) { // infer $arr[$key] after $key = array_rand($arr) if ( @@ -933,27 +1017,6 @@ public function specifyTypesInCondition( return $specifiedTypes; } - if ($context->true()) { - // infer $arr[$key] after $key = array_search($needle, $arr) - if ( - $expr->expr instanceof FuncCall - && $expr->expr->name instanceof Name - && $expr->expr->name->toLowerString() === 'array_search' - && count($expr->expr->getArgs()) >= 2 - ) { - $arrayArg = $expr->expr->getArgs()[1]->value; - $arrayType = $scope->getType($arrayArg); - - if ($arrayType->isArray()->yes()) { - $dimFetch = new ArrayDimFetch($arrayArg, $expr->var); - $iterableValueType = $arrayType->getIterableValueType(); - - return $specifiedTypes->unionWith( - $this->create($dimFetch, $iterableValueType, TypeSpecifierContext::createTrue(), $scope), - ); - } - } - } return $specifiedTypes; } elseif ( $expr instanceof Expr\Isset_ @@ -3016,10 +3079,11 @@ private function resolveNormalizedIdentical(Expr\BinaryOp\Identical $expr, Scope // array_key_first($a) !== null // array_key_last($a) !== null + // array_find_key($a, $cb) !== null if ( $unwrappedLeftExpr instanceof FuncCall && $unwrappedLeftExpr->name instanceof Name - && in_array($unwrappedLeftExpr->name->toLowerString(), ['array_key_first', 'array_key_last'], true) + && in_array($unwrappedLeftExpr->name->toLowerString(), ['array_key_first', 'array_key_last', 'array_find_key'], true) && isset($unwrappedLeftExpr->getArgs()[0]) && $rightType->isNull()->yes() ) { diff --git a/tests/PHPStan/Analyser/nsrt/array-find-key-existing.php b/tests/PHPStan/Analyser/nsrt/array-find-key-existing.php new file mode 100644 index 00000000000..1f68cc067f6 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/array-find-key-existing.php @@ -0,0 +1,30 @@ += 8.4 + +declare(strict_types=1); + +namespace ArrayFindKeyExisting; + +use function PHPStan\Testing\assertType; + +/** + * @param list $list + */ +function arrayFindKeyNotNull(array $list, string $s): void +{ + $key = array_find_key($list, fn (string $v) => $v === $s); + if ($key !== null) { + assertType('non-empty-list', $list); + assertType('string', $list[$key]); + } +} + +/** + * @param array $map + */ +function arrayFindKeyStringKey(array $map): void +{ + $key = array_find_key($map, fn (int $v) => $v > 10); + if ($key !== null) { + assertType('int', $map[$key]); + } +} diff --git a/tests/PHPStan/Analyser/nsrt/array-search-existing.php b/tests/PHPStan/Analyser/nsrt/array-search-existing.php new file mode 100644 index 00000000000..9467939eb4d --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/array-search-existing.php @@ -0,0 +1,39 @@ + $list + */ +function arraySearchNotFalse(array $list, string $s): void +{ + $key = array_search($s, $list); + if ($key !== false) { + assertType('non-empty-list', $list); + assertType('string', $list[$key]); + } +} + +/** + * @param array $map + */ +function arraySearchStringKey(array $map, int $needle): void +{ + $key = array_search($needle, $map); + if ($key !== false) { + assertType('int', $map[$key]); + } +} + +/** + * @param list $list + */ +function arraySearchDeepWrite(array $list, string $s): void +{ + $key = array_search($s, $list); + if ($key !== false) { + assertType('string', $list[$key]); + } +} diff --git a/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php b/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php index 51c32f370af..ee45da29ce1 100644 --- a/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php +++ b/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php @@ -873,12 +873,7 @@ public function testArrayDimFetchAfterArraySearch(): void { $this->reportPossiblyNonexistentGeneralArrayOffset = true; - $this->analyse([__DIR__ . '/data/array-dim-after-array-search.php'], [ - [ - 'Offset int|string might not exist on non-empty-array.', - 20, - ], - ]); + $this->analyse([__DIR__ . '/data/array-dim-after-array-search.php'], []); } public function testArrayDimFetchOnArrayKeyFirsOrLastOrCount(): void @@ -1310,4 +1305,11 @@ public function testBug11218(): void $this->analyse([__DIR__ . '/data/bug-11218.php'], []); } + public function testBug14537(): void + { + $this->reportPossiblyNonexistentGeneralArrayOffset = true; + + $this->analyse([__DIR__ . '/data/bug-14537.php'], []); + } + } diff --git a/tests/PHPStan/Rules/Arrays/data/bug-14537.php b/tests/PHPStan/Rules/Arrays/data/bug-14537.php new file mode 100644 index 00000000000..b379e332491 --- /dev/null +++ b/tests/PHPStan/Rules/Arrays/data/bug-14537.php @@ -0,0 +1,71 @@ += 8.4 + +declare(strict_types=1); + +namespace Bug14537; + +/** + * @param list $list + */ +function arraySearchNotFalse(array $list, string $s): void +{ + $key = array_search($s, $list); + if ($key !== false) { + echo $list[$key]; + } +} + +/** + * @param array $map + */ +function arraySearchStringKey(array $map, int $needle): void +{ + $key = array_search($needle, $map); + if ($key !== false) { + echo $map[$key]; + } +} + +/** + * @param list $list + */ +function arraySearchReversedComparison(array $list, string $s): void +{ + $key = array_search($s, $list); + if (false !== $key) { + echo $list[$key]; + } +} + +/** + * @param list $list + */ +function arrayFindKeyNotNull(array $list, string $s): void +{ + $key = array_find_key($list, fn (string $v) => $v === $s); + if ($key !== null) { + echo $list[$key]; + } +} + +/** + * @param array $map + */ +function arrayFindKeyStringKey(array $map): void +{ + $key = array_find_key($map, fn (int $v) => $v > 10); + if ($key !== null) { + echo $map[$key]; + } +} + +/** + * @param list $list + */ +function arrayFindKeyReversedComparison(array $list, string $s): void +{ + $key = array_find_key($list, fn (string $v) => $v === $s); + if (null !== $key) { + echo $list[$key]; + } +} From a413d3c056c3117a1f036d55c43724fc46fd58a2 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Mon, 27 Apr 2026 08:26:43 +0000 Subject: [PATCH 2/2] De-duplicate ConditionalExpressionHolder creation for array dim fetch tracking Extract createArrayDimFetchConditionalExpressionHolder() helper method and merge the separate array_search/array_find_key blocks into one, reducing three instances of the same ~12-line pattern to single-line calls. Co-Authored-By: Claude Opus 4.6 --- src/Analyser/TypeSpecifier.php | 144 +++++++++++++-------------------- 1 file changed, 57 insertions(+), 87 deletions(-) diff --git a/src/Analyser/TypeSpecifier.php b/src/Analyser/TypeSpecifier.php index bfa13142b27..791d79f58c7 100644 --- a/src/Analyser/TypeSpecifier.php +++ b/src/Analyser/TypeSpecifier.php @@ -848,113 +848,62 @@ public function specifyTypesInCondition( $this->create($dimFetch, $arrayType->getIterableValueType(), TypeSpecifierContext::createTrue(), $scope), ); } elseif ($expr->var instanceof Expr\Variable && is_string($expr->var->name)) { - // The array might be empty here, so we cannot register - // $arr[$key] unconditionally. Attach a conditional holder - // that fires once the user narrows $key to non-null - // (e.g. `if ($key !== null)`), giving the deep-write - // path the same shape information `isset($arr[$key])` - // would have provided. $keyType = $scope->getType($expr->expr); $nonNullKeyType = TypeCombinator::removeNull($keyType); - if (!$nonNullKeyType instanceof NeverType && !$keyType->isNull()->yes()) { - $dimFetch = new ArrayDimFetch($arrayArg, $expr->var); - $dimFetchString = $this->exprPrinter->printExpr($dimFetch); - $keyExprString = $this->exprPrinter->printExpr($expr->var); - - $holder = new ConditionalExpressionHolder( - [$keyExprString => ExpressionTypeHolder::createYes($expr->var, $nonNullKeyType)], - ExpressionTypeHolder::createYes($dimFetch, $arrayType->getIterableValueType()), - ); - - $specifiedTypes = $specifiedTypes->unionWith( - (new SpecifiedTypes([], []))->setNewConditionalExpressionHolders([ - $dimFetchString => [$holder->getKey() => $holder], - ]), - ); - } - } - } - } - - // infer $arr[$key] after $key = array_search($needle, $arr) - if ( - $expr->expr instanceof FuncCall - && $expr->expr->name instanceof Name - && $expr->expr->name->toLowerString() === 'array_search' - && count($expr->expr->getArgs()) >= 2 - ) { - $arrayArg = $expr->expr->getArgs()[1]->value; - $arrayType = $scope->getType($arrayArg); - - if ($arrayType->isArray()->yes()) { - if ($context->true()) { - $dimFetch = new ArrayDimFetch($arrayArg, $expr->var); - - $specifiedTypes = $specifiedTypes->unionWith( - $this->create($dimFetch, $arrayType->getIterableValueType(), TypeSpecifierContext::createTrue(), $scope), - ); - } elseif ($expr->var instanceof Expr\Variable && is_string($expr->var->name)) { - $keyType = $scope->getType($expr->expr); - $nonFalseKeyType = TypeCombinator::remove($keyType, new ConstantBooleanType(false)); - if (!$nonFalseKeyType instanceof NeverType && !$keyType->isFalse()->yes()) { - $dimFetch = new ArrayDimFetch($arrayArg, $expr->var); - $dimFetchString = $this->exprPrinter->printExpr($dimFetch); - $keyExprString = $this->exprPrinter->printExpr($expr->var); - - $holder = new ConditionalExpressionHolder( - [$keyExprString => ExpressionTypeHolder::createYes($expr->var, $nonFalseKeyType)], - ExpressionTypeHolder::createYes($dimFetch, $arrayType->getIterableValueType()), - ); - + if (!$nonNullKeyType instanceof NeverType) { $specifiedTypes = $specifiedTypes->unionWith( - (new SpecifiedTypes([], []))->setNewConditionalExpressionHolders([ - $dimFetchString => [$holder->getKey() => $holder], - ]), + $this->createArrayDimFetchConditionalExpressionHolder($expr->var, $arrayArg, $arrayType, $nonNullKeyType), ); } } } } - // infer $arr[$key] after $key = array_find_key($arr, $callback) + // infer $arr[$key] after $key = array_search($needle, $arr) or $key = array_find_key($arr, $callback) if ( $expr->expr instanceof FuncCall && $expr->expr->name instanceof Name - && $expr->expr->name->toLowerString() === 'array_find_key' && count($expr->expr->getArgs()) >= 2 ) { - $arrayArg = $expr->expr->getArgs()[0]->value; - $arrayType = $scope->getType($arrayArg); - - if ($arrayType->isArray()->yes()) { - if ($context->true()) { - $specifiedTypes = $specifiedTypes->unionWith( - $this->create($arrayArg, new NonEmptyArrayType(), TypeSpecifierContext::createTrue(), $scope), - ); + $funcName = $expr->expr->name->toLowerString(); + $arrayArgIndex = null; + $sentinelType = null; + $narrowToNonEmpty = false; + + if ($funcName === 'array_search') { + $arrayArgIndex = 1; + $sentinelType = new ConstantBooleanType(false); + } elseif ($funcName === 'array_find_key') { + $arrayArgIndex = 0; + $sentinelType = new NullType(); + $narrowToNonEmpty = true; + } + + if ($arrayArgIndex !== null && $sentinelType !== null) { + $arrayArg = $expr->expr->getArgs()[$arrayArgIndex]->value; + $arrayType = $scope->getType($arrayArg); - $dimFetch = new ArrayDimFetch($arrayArg, $expr->var); + if ($arrayType->isArray()->yes()) { + if ($context->true()) { + if ($narrowToNonEmpty) { + $specifiedTypes = $specifiedTypes->unionWith( + $this->create($arrayArg, new NonEmptyArrayType(), TypeSpecifierContext::createTrue(), $scope), + ); + } - $specifiedTypes = $specifiedTypes->unionWith( - $this->create($dimFetch, $arrayType->getIterableValueType(), TypeSpecifierContext::createTrue(), $scope), - ); - } elseif ($expr->var instanceof Expr\Variable && is_string($expr->var->name)) { - $keyType = $scope->getType($expr->expr); - $nonNullKeyType = TypeCombinator::removeNull($keyType); - if (!$nonNullKeyType instanceof NeverType && !$keyType->isNull()->yes()) { $dimFetch = new ArrayDimFetch($arrayArg, $expr->var); - $dimFetchString = $this->exprPrinter->printExpr($dimFetch); - $keyExprString = $this->exprPrinter->printExpr($expr->var); - - $holder = new ConditionalExpressionHolder( - [$keyExprString => ExpressionTypeHolder::createYes($expr->var, $nonNullKeyType)], - ExpressionTypeHolder::createYes($dimFetch, $arrayType->getIterableValueType()), - ); $specifiedTypes = $specifiedTypes->unionWith( - (new SpecifiedTypes([], []))->setNewConditionalExpressionHolders([ - $dimFetchString => [$holder->getKey() => $holder], - ]), + $this->create($dimFetch, $arrayType->getIterableValueType(), TypeSpecifierContext::createTrue(), $scope), ); + } elseif ($expr->var instanceof Expr\Variable && is_string($expr->var->name)) { + $keyType = $scope->getType($expr->expr); + $narrowedKeyType = TypeCombinator::remove($keyType, $sentinelType); + if (!$narrowedKeyType instanceof NeverType) { + $specifiedTypes = $specifiedTypes->unionWith( + $this->createArrayDimFetchConditionalExpressionHolder($expr->var, $arrayArg, $arrayType, $narrowedKeyType), + ); + } } } } @@ -2455,6 +2404,27 @@ public function create( return $types; } + private function createArrayDimFetchConditionalExpressionHolder( + Expr\Variable $keyVar, + Expr $arrayArg, + Type $arrayType, + Type $narrowedKeyType, + ): SpecifiedTypes + { + $dimFetch = new ArrayDimFetch($arrayArg, $keyVar); + $dimFetchString = $this->exprPrinter->printExpr($dimFetch); + $keyExprString = $this->exprPrinter->printExpr($keyVar); + + $holder = new ConditionalExpressionHolder( + [$keyExprString => ExpressionTypeHolder::createYes($keyVar, $narrowedKeyType)], + ExpressionTypeHolder::createYes($dimFetch, $arrayType->getIterableValueType()), + ); + + return (new SpecifiedTypes([], []))->setNewConditionalExpressionHolders([ + $dimFetchString => [$holder->getKey() => $holder], + ]); + } + private function createForExpr( Expr $expr, Type $type,