From 80c9526a7e8092553c9d59b6220e834c116334cd Mon Sep 17 00:00:00 2001 From: phpstan-bot <79867460+phpstan-bot@users.noreply.github.com> Date: Fri, 27 Mar 2026 14:26:05 +0000 Subject: [PATCH 1/7] Fix array_key_first/array_key_last offset check for non-variable expressions - The special-case logic in NonexistentOffsetInArrayDimFetchRule that suppresses false positives for $arr[array_key_first($arr)] only matched Variable AST nodes - Changed expression comparison to use ExprPrinter so it works with property fetches, static property fetches, and any other expression type - Also fixed the same limitation in the array_rand and count()-1 special cases - New regression test in tests/PHPStan/Rules/Arrays/data/bug-14390.php --- .../NonexistentOffsetInArrayDimFetchRule.php | 17 ++---- ...nexistentOffsetInArrayDimFetchRuleTest.php | 11 ++++ tests/PHPStan/Rules/Arrays/data/bug-14390.php | 55 +++++++++++++++++++ 3 files changed, 71 insertions(+), 12 deletions(-) create mode 100644 tests/PHPStan/Rules/Arrays/data/bug-14390.php diff --git a/src/Rules/Arrays/NonexistentOffsetInArrayDimFetchRule.php b/src/Rules/Arrays/NonexistentOffsetInArrayDimFetchRule.php index 9a4947d5216..9f9605b7dc4 100644 --- a/src/Rules/Arrays/NonexistentOffsetInArrayDimFetchRule.php +++ b/src/Rules/Arrays/NonexistentOffsetInArrayDimFetchRule.php @@ -10,6 +10,7 @@ use PHPStan\DependencyInjection\AutowiredParameter; use PHPStan\DependencyInjection\RegisteredRule; use PHPStan\Internal\SprintfHelper; +use PHPStan\Node\Printer\ExprPrinter; use PHPStan\Rules\Rule; use PHPStan\Rules\RuleErrorBuilder; use PHPStan\Rules\RuleLevelHelper; @@ -33,6 +34,7 @@ final class NonexistentOffsetInArrayDimFetchRule implements Rule public function __construct( private RuleLevelHelper $ruleLevelHelper, private NonexistentOffsetInArrayDimFetchCheck $nonexistentOffsetInArrayDimFetchCheck, + private ExprPrinter $exprPrinter, #[AutowiredParameter] private bool $reportMaybes, ) @@ -120,10 +122,7 @@ public function processNode(Node $node, Scope $scope): array $arrayArg = $node->dim->getArgs()[0]->value; $arrayType = $scope->getType($arrayArg); if ( - $arrayArg instanceof Node\Expr\Variable - && $node->var instanceof Node\Expr\Variable - && is_string($arrayArg->name) - && $arrayArg->name === $node->var->name + $this->exprPrinter->printExpr($arrayArg) === $this->exprPrinter->printExpr($node->var) && $arrayType->isArray()->yes() && $arrayType->isIterableAtLeastOnce()->yes() ) { @@ -146,10 +145,7 @@ public function processNode(Node $node, Scope $scope): array $arrayType = $scope->getType($arrayArg); if ( - $arrayArg instanceof Node\Expr\Variable - && $node->var instanceof Node\Expr\Variable - && is_string($arrayArg->name) - && $arrayArg->name === $node->var->name + $this->exprPrinter->printExpr($arrayArg) === $this->exprPrinter->printExpr($node->var) && $arrayType->isArray()->yes() && $arrayType->isIterableAtLeastOnce()->yes() && ($numArg === null || $one->isSuperTypeOf($scope->getType($numArg))->yes()) @@ -170,10 +166,7 @@ public function processNode(Node $node, Scope $scope): array $arrayArg = $node->dim->left->getArgs()[0]->value; $arrayType = $scope->getType($arrayArg); if ( - $arrayArg instanceof Node\Expr\Variable - && $node->var instanceof Node\Expr\Variable - && is_string($arrayArg->name) - && $arrayArg->name === $node->var->name + $this->exprPrinter->printExpr($arrayArg) === $this->exprPrinter->printExpr($node->var) && $arrayType->isList()->yes() && $arrayType->isIterableAtLeastOnce()->yes() ) { diff --git a/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php b/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php index 7def992a257..0007ed82644 100644 --- a/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php +++ b/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php @@ -2,6 +2,8 @@ namespace PHPStan\Rules\Arrays; +use PHPStan\Node\Printer\ExprPrinter; +use PHPStan\Node\Printer\Printer; use PHPStan\Rules\Rule; use PHPStan\Rules\RuleLevelHelper; use PHPStan\Testing\RuleTestCase; @@ -44,6 +46,7 @@ protected function getRule(): Rule reportPossiblyNonexistentGeneralArrayOffset: $this->reportPossiblyNonexistentGeneralArrayOffset, reportPossiblyNonexistentConstantArrayOffset: $this->reportPossiblyNonexistentConstantArrayOffset, ), + new ExprPrinter(new Printer()), true, ); } @@ -1277,4 +1280,12 @@ public function testBug14308(): void $this->analyse([__DIR__ . '/data/bug-14308.php'], []); } + #[RequiresPhp('>= 8.2')] + public function testBug14390(): void + { + $this->reportPossiblyNonexistentGeneralArrayOffset = true; + + $this->analyse([__DIR__ . '/data/bug-14390.php'], []); + } + } diff --git a/tests/PHPStan/Rules/Arrays/data/bug-14390.php b/tests/PHPStan/Rules/Arrays/data/bug-14390.php new file mode 100644 index 00000000000..a02572a53aa --- /dev/null +++ b/tests/PHPStan/Rules/Arrays/data/bug-14390.php @@ -0,0 +1,55 @@ += 8.2 + +declare(strict_types = 1); + +namespace Bug14390; + +readonly class Sample +{ + /** + * @param array $fields + */ + public function __construct( + public array $fields = [], + ) { + } +} + +class Foo +{ + public function bar( + Sample $sample, + ): void { + if ($sample->fields !== []) { + echo $sample->fields[array_key_first($sample->fields)]; + } + } + + /** + * @param array $fields + */ + public function zoo( + array $fields, + ): void { + if ($fields !== []) { + echo $fields[array_key_first($fields)]; + } + } + + public function withKey( + Sample $sample, + ): void { + if ($sample->fields !== []) { + $key = array_key_first($sample->fields); + echo $sample->fields[$key]; + } + } + + public function arrayKeyLast( + Sample $sample, + ): void { + if ($sample->fields !== []) { + echo $sample->fields[array_key_last($sample->fields)]; + } + } +} From 6b43b8d6ec994360508c2db15bdba060047fba43 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Sat, 28 Mar 2026 11:57:16 +0000 Subject: [PATCH 2/7] Add test coverage for array_rand and count()-1 property access patterns Addresses review feedback: the ExprPrinter changes for array_rand and count()-1 special cases were not covered by tests. Adds test cases for property access with array_rand() and count()-1 on non-empty arrays. Co-Authored-By: Claude Opus 4.6 --- tests/PHPStan/Rules/Arrays/data/bug-14390.php | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/tests/PHPStan/Rules/Arrays/data/bug-14390.php b/tests/PHPStan/Rules/Arrays/data/bug-14390.php index a02572a53aa..86737b6da4b 100644 --- a/tests/PHPStan/Rules/Arrays/data/bug-14390.php +++ b/tests/PHPStan/Rules/Arrays/data/bug-14390.php @@ -52,4 +52,43 @@ public function arrayKeyLast( echo $sample->fields[array_key_last($sample->fields)]; } } + + public function arrayRand( + Sample $sample, + ): void { + if ($sample->fields !== []) { + echo $sample->fields[array_rand($sample->fields)]; + } + } + + /** + * @param non-empty-list $list + */ + public function countMinus1( + array $list, + ): void { + echo $list[count($list) - 1]; + } +} + +readonly class SampleList +{ + /** + * @param list $items + */ + public function __construct( + public array $items = [], + ) { + } +} + +class Bar +{ + public function countMinus1Property( + SampleList $sample, + ): void { + if ($sample->items !== []) { + echo $sample->items[count($sample->items) - 1]; + } + } } From 3bb9cdb960b5b53bf5d772c3c91de68f0b869e05 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Sat, 28 Mar 2026 11:58:34 +0000 Subject: [PATCH 3/7] Add test coverage for static property access patterns in array offset checks Covers array_key_first, array_key_last, array_rand, and count()-1 special cases with static properties, as requested in review. Co-Authored-By: Claude Opus 4.6 --- tests/PHPStan/Rules/Arrays/data/bug-14390.php | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/tests/PHPStan/Rules/Arrays/data/bug-14390.php b/tests/PHPStan/Rules/Arrays/data/bug-14390.php index 86737b6da4b..8def6955bd4 100644 --- a/tests/PHPStan/Rules/Arrays/data/bug-14390.php +++ b/tests/PHPStan/Rules/Arrays/data/bug-14390.php @@ -92,3 +92,40 @@ public function countMinus1Property( } } } + +class StaticProps +{ + /** @var array */ + public static array $fields = []; + + /** @var list */ + public static array $items = []; + + public function arrayKeyFirstStatic(): void + { + if (self::$fields !== []) { + echo self::$fields[array_key_first(self::$fields)]; + } + } + + public function arrayKeyLastStatic(): void + { + if (self::$fields !== []) { + echo self::$fields[array_key_last(self::$fields)]; + } + } + + public function arrayRandStatic(): void + { + if (self::$fields !== []) { + echo self::$fields[array_rand(self::$fields)]; + } + } + + public function countMinus1Static(): void + { + if (self::$items !== []) { + echo self::$items[count(self::$items) - 1]; + } + } +} From 978a4975c2dddcddf93f44a8eb46cc5817a62825 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Sat, 28 Mar 2026 19:27:38 +0100 Subject: [PATCH 4/7] test pure/impure methods --- tests/PHPStan/Rules/Arrays/data/bug-14390.php | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/PHPStan/Rules/Arrays/data/bug-14390.php b/tests/PHPStan/Rules/Arrays/data/bug-14390.php index 8def6955bd4..7c118fee9cc 100644 --- a/tests/PHPStan/Rules/Arrays/data/bug-14390.php +++ b/tests/PHPStan/Rules/Arrays/data/bug-14390.php @@ -129,3 +129,21 @@ public function countMinus1Static(): void } } } + +function doWithMethods(WithMethods $withMethods) { + echo $withMethods->pureMethod()[array_key_first($withMethods->pureMethod())]; + echo $withMethods->impureMethod()[array_key_first($withMethods->impureMethod())]; +} + +class WithMethods { + /** + * @phpstan-pure + * @return non-empty-array + */ + public function pureMethod():array {} + /** + * @phpstan-impure + * @return non-empty-array + */ + public function impureMethod():array {} +} From a09c9df8130a4433bf7e967392fbe52595f51215 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Sat, 28 Mar 2026 19:42:33 +0100 Subject: [PATCH 5/7] impureMethod expects error --- .../Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php b/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php index 0007ed82644..25a94200da0 100644 --- a/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php +++ b/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php @@ -1285,7 +1285,12 @@ public function testBug14390(): void { $this->reportPossiblyNonexistentGeneralArrayOffset = true; - $this->analyse([__DIR__ . '/data/bug-14390.php'], []); + $this->analyse([__DIR__ . '/data/bug-14390.php'], [ + [ + 'Offset (int|string) might not exist on non-empty-array.', + 135, + ], + ]); } } From 8f8a19b57b93909b61adce0e87342988384258a9 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Sat, 28 Mar 2026 19:08:22 +0000 Subject: [PATCH 6/7] Guard ExprPrinter comparison with determinism check for impure method calls ExprPrinter comparison alone is insufficient because impure method calls like `$obj->impureMethod()` may return different values on each invocation, even though the printed expression is identical. Add isDeterministicExpr() to verify the expression is safe to compare: variables, property fetches, static property fetches, and pure method/static calls are deterministic. Co-Authored-By: Claude Opus 4.6 --- .../NonexistentOffsetInArrayDimFetchRule.php | 47 +++++++++++++++++-- 1 file changed, 44 insertions(+), 3 deletions(-) diff --git a/src/Rules/Arrays/NonexistentOffsetInArrayDimFetchRule.php b/src/Rules/Arrays/NonexistentOffsetInArrayDimFetchRule.php index 9f9605b7dc4..ffe4d295020 100644 --- a/src/Rules/Arrays/NonexistentOffsetInArrayDimFetchRule.php +++ b/src/Rules/Arrays/NonexistentOffsetInArrayDimFetchRule.php @@ -3,6 +3,7 @@ namespace PHPStan\Rules\Arrays; use PhpParser\Node; +use PhpParser\Node\Expr; use PhpParser\Node\Expr\ArrayDimFetch; use PhpParser\Node\Expr\Variable; use PHPStan\Analyser\NullsafeOperatorHelper; @@ -122,7 +123,8 @@ public function processNode(Node $node, Scope $scope): array $arrayArg = $node->dim->getArgs()[0]->value; $arrayType = $scope->getType($arrayArg); if ( - $this->exprPrinter->printExpr($arrayArg) === $this->exprPrinter->printExpr($node->var) + $this->isDeterministicExpr($node->var, $scope) + && $this->exprPrinter->printExpr($arrayArg) === $this->exprPrinter->printExpr($node->var) && $arrayType->isArray()->yes() && $arrayType->isIterableAtLeastOnce()->yes() ) { @@ -145,7 +147,8 @@ public function processNode(Node $node, Scope $scope): array $arrayType = $scope->getType($arrayArg); if ( - $this->exprPrinter->printExpr($arrayArg) === $this->exprPrinter->printExpr($node->var) + $this->isDeterministicExpr($node->var, $scope) + && $this->exprPrinter->printExpr($arrayArg) === $this->exprPrinter->printExpr($node->var) && $arrayType->isArray()->yes() && $arrayType->isIterableAtLeastOnce()->yes() && ($numArg === null || $one->isSuperTypeOf($scope->getType($numArg))->yes()) @@ -166,7 +169,8 @@ public function processNode(Node $node, Scope $scope): array $arrayArg = $node->dim->left->getArgs()[0]->value; $arrayType = $scope->getType($arrayArg); if ( - $this->exprPrinter->printExpr($arrayArg) === $this->exprPrinter->printExpr($node->var) + $this->isDeterministicExpr($node->var, $scope) + && $this->exprPrinter->printExpr($arrayArg) === $this->exprPrinter->printExpr($node->var) && $arrayType->isList()->yes() && $arrayType->isIterableAtLeastOnce()->yes() ) { @@ -182,6 +186,43 @@ public function processNode(Node $node, Scope $scope): array ); } + private function isDeterministicExpr(Expr $expr, Scope $scope): bool + { + if ($expr instanceof Variable) { + return true; + } + + if ($expr instanceof Expr\PropertyFetch) { + return $this->isDeterministicExpr($expr->var, $scope); + } + + if ($expr instanceof Expr\StaticPropertyFetch) { + return true; + } + + if ($expr instanceof Expr\MethodCall && $expr->name instanceof Node\Identifier) { + $callerType = $scope->getType($expr->var); + $methodReflection = $scope->getMethodReflection($callerType, $expr->name->name); + if ($methodReflection !== null && $methodReflection->hasSideEffects()->no()) { + return $this->isDeterministicExpr($expr->var, $scope); + } + + return false; + } + + if ($expr instanceof Expr\StaticCall && $expr->name instanceof Node\Identifier && $expr->class instanceof Node\Name) { + $classType = $scope->resolveTypeByName($expr->class); + $methodReflection = $scope->getMethodReflection($classType, $expr->name->name); + if ($methodReflection !== null && $methodReflection->hasSideEffects()->no()) { + return true; + } + + return false; + } + + return false; + } + private function isImplicitArrayCreation(Node\Expr\ArrayDimFetch $node, Scope $scope): TrinaryLogic { $varNode = $node->var; From f3910e3a02c27cf67089c28731744b185c2251ca Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Sat, 28 Mar 2026 19:29:58 +0000 Subject: [PATCH 7/7] Check method args for determinism and support ArrayDimFetch in isDeterministicExpr - Check that MethodCall/StaticCall arguments are also deterministic, not just the method purity. E.g. $foo->pureMethod($foo->impureMethod()) is not deterministic because the impure argument may produce different values. - Support ArrayDimFetch expressions (e.g. $nested[$key][array_key_first(...)]), checking both the array and the dim key for determinism. - Add test cases for pure method with impure args, deterministic dim fetch, and non-deterministic dim fetch with impure method key. Co-Authored-By: Claude Opus 4.6 --- .../NonexistentOffsetInArrayDimFetchRule.php | 28 +++++++++++++++++-- ...nexistentOffsetInArrayDimFetchRuleTest.php | 8 ++++++ tests/PHPStan/Rules/Arrays/data/bug-14390.php | 24 +++++++++++++++- 3 files changed, 57 insertions(+), 3 deletions(-) diff --git a/src/Rules/Arrays/NonexistentOffsetInArrayDimFetchRule.php b/src/Rules/Arrays/NonexistentOffsetInArrayDimFetchRule.php index ffe4d295020..4eaca375543 100644 --- a/src/Rules/Arrays/NonexistentOffsetInArrayDimFetchRule.php +++ b/src/Rules/Arrays/NonexistentOffsetInArrayDimFetchRule.php @@ -200,11 +200,21 @@ private function isDeterministicExpr(Expr $expr, Scope $scope): bool return true; } + if ($expr instanceof Expr\ArrayDimFetch) { + if ($expr->dim === null) { + return false; + } + + return $this->isDeterministicExpr($expr->var, $scope) + && $this->isDeterministicExpr($expr->dim, $scope); + } + if ($expr instanceof Expr\MethodCall && $expr->name instanceof Node\Identifier) { $callerType = $scope->getType($expr->var); $methodReflection = $scope->getMethodReflection($callerType, $expr->name->name); if ($methodReflection !== null && $methodReflection->hasSideEffects()->no()) { - return $this->isDeterministicExpr($expr->var, $scope); + return $this->isDeterministicExpr($expr->var, $scope) + && $this->areDeterministicArgs($expr->getArgs(), $scope); } return false; @@ -214,7 +224,7 @@ private function isDeterministicExpr(Expr $expr, Scope $scope): bool $classType = $scope->resolveTypeByName($expr->class); $methodReflection = $scope->getMethodReflection($classType, $expr->name->name); if ($methodReflection !== null && $methodReflection->hasSideEffects()->no()) { - return true; + return $this->areDeterministicArgs($expr->getArgs(), $scope); } return false; @@ -223,6 +233,20 @@ private function isDeterministicExpr(Expr $expr, Scope $scope): bool return false; } + /** + * @param Node\Arg[] $args + */ + private function areDeterministicArgs(array $args, Scope $scope): bool + { + foreach ($args as $arg) { + if (!$this->isDeterministicExpr($arg->value, $scope)) { + return false; + } + } + + return true; + } + private function isImplicitArrayCreation(Node\Expr\ArrayDimFetch $node, Scope $scope): TrinaryLogic { $varNode = $node->var; diff --git a/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php b/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php index 25a94200da0..f247c4cbc79 100644 --- a/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php +++ b/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php @@ -1290,6 +1290,14 @@ public function testBug14390(): void 'Offset (int|string) might not exist on non-empty-array.', 135, ], + [ + 'Offset (int|string) might not exist on non-empty-array.', + 136, + ], + [ + 'Offset string might not exist on non-empty-array.', + 169, + ], ]); } diff --git a/tests/PHPStan/Rules/Arrays/data/bug-14390.php b/tests/PHPStan/Rules/Arrays/data/bug-14390.php index 7c118fee9cc..9f74b332253 100644 --- a/tests/PHPStan/Rules/Arrays/data/bug-14390.php +++ b/tests/PHPStan/Rules/Arrays/data/bug-14390.php @@ -133,6 +133,7 @@ public function countMinus1Static(): void function doWithMethods(WithMethods $withMethods) { echo $withMethods->pureMethod()[array_key_first($withMethods->pureMethod())]; echo $withMethods->impureMethod()[array_key_first($withMethods->impureMethod())]; + echo $withMethods->pureMethod($withMethods->impureMethod())[array_key_first($withMethods->pureMethod($withMethods->impureMethod()))]; } class WithMethods { @@ -140,10 +141,31 @@ class WithMethods { * @phpstan-pure * @return non-empty-array */ - public function pureMethod():array {} + public function pureMethod(mixed ...$args):array {} /** * @phpstan-impure * @return non-empty-array */ public function impureMethod():array {} } + +class NestedArray +{ + /** + * @param array> $nested + */ + public function dimFetchDeterministic(array $nested, string $key): void + { + if (isset($nested[$key])) { + echo $nested[$key][array_key_first($nested[$key])]; + } + } + + /** + * @param non-empty-array> $nested + */ + public function dimFetchWithMethodKey(WithMethods $w, array $nested): void + { + echo $nested[$w->impureMethod()][array_key_first($nested[$w->impureMethod()])]; + } +}