From 1cb72b1e105195a3b5e637bcae712f399ef4901b Mon Sep 17 00:00:00 2001 From: phpstan-bot <79867460+phpstan-bot@users.noreply.github.com> Date: Sun, 1 Mar 2026 16:52:51 +0000 Subject: [PATCH] Fix phpstan/phpstan#14215: Nonexistent offset detection on lists with `count() - 1` - Extended TypeSpecifier to detect `count($list) - K` patterns in comparisons, not just plain `count($list)` - Adjusted sizeType computation to account for the subtraction value - List offset inference now works with both `$index < count($list) - K` and `$index <= count($list) - K` (when K >= 1) - Non-empty array narrowing threshold adjusted for subtraction - New regression test in tests/PHPStan/Rules/Arrays/data/bug-14215.php --- src/Analyser/TypeSpecifier.php | 48 +++++-- ...nexistentOffsetInArrayDimFetchRuleTest.php | 12 ++ tests/PHPStan/Rules/Arrays/data/bug-14215.php | 122 ++++++++++++++++++ 3 files changed, 168 insertions(+), 14 deletions(-) create mode 100644 tests/PHPStan/Rules/Arrays/data/bug-14215.php diff --git a/src/Analyser/TypeSpecifier.php b/src/Analyser/TypeSpecifier.php index ec9cb809c0..999e4db86b 100644 --- a/src/Analyser/TypeSpecifier.php +++ b/src/Analyser/TypeSpecifier.php @@ -266,36 +266,56 @@ public function specifyTypesInCondition( $leftType = $scope->getType($expr->left); $result = (new SpecifiedTypes([], []))->setRootExpr($expr); + $countFuncCall = null; + $subtraction = 0; + if ( - !$context->null() - && $expr->right instanceof FuncCall + $expr->right instanceof FuncCall && $expr->right->name instanceof Name && in_array(strtolower((string) $expr->right->name), ['count', 'sizeof'], true) && count($expr->right->getArgs()) >= 1 + ) { + $countFuncCall = $expr->right; + } elseif ( + $expr->right instanceof Node\Expr\BinaryOp\Minus + && $expr->right->left instanceof FuncCall + && $expr->right->left->name instanceof Name + && in_array(strtolower((string) $expr->right->left->name), ['count', 'sizeof'], true) + && count($expr->right->left->getArgs()) >= 1 + && $expr->right->right instanceof Node\Scalar\Int_ + && $expr->right->right->value >= 1 + ) { + $countFuncCall = $expr->right->left; + $subtraction = $expr->right->right->value; + } + + if ( + !$context->null() + && $countFuncCall !== null && $leftType->isInteger()->yes() ) { - $argType = $scope->getType($expr->right->getArgs()[0]->value); + $argType = $scope->getType($countFuncCall->getArgs()[0]->value); if ($leftType instanceof ConstantIntegerType) { if ($orEqual) { - $sizeType = IntegerRangeType::createAllGreaterThanOrEqualTo($leftType->getValue()); + $sizeType = IntegerRangeType::createAllGreaterThanOrEqualTo($leftType->getValue() + $subtraction); } else { - $sizeType = IntegerRangeType::createAllGreaterThan($leftType->getValue()); + $sizeType = IntegerRangeType::createAllGreaterThan($leftType->getValue() + $subtraction); } } elseif ($leftType instanceof IntegerRangeType) { - $sizeType = $leftType->shift($offset); + $sizeType = $leftType->shift($offset + $subtraction); } else { $sizeType = $leftType; } - $specifiedTypes = $this->specifyTypesForCountFuncCall($expr->right, $argType, $sizeType, $context, $scope, $expr); + $specifiedTypes = $this->specifyTypesForCountFuncCall($countFuncCall, $argType, $sizeType, $context, $scope, $expr); if ($specifiedTypes !== null) { $result = $result->unionWith($specifiedTypes); } if ( - $context->true() && (IntegerRangeType::createAllGreaterThanOrEqualTo(1 - $offset)->isSuperTypeOf($leftType)->yes()) - || ($context->false() && (new ConstantIntegerType(1 - $offset))->isSuperTypeOf($leftType)->yes()) + $context->true() && (IntegerRangeType::createAllGreaterThanOrEqualTo(1 - $offset - $subtraction)->isSuperTypeOf($leftType)->yes()) + || ($context->false() && (new ConstantIntegerType(1 - $offset - $subtraction))->isSuperTypeOf($leftType)->yes()) ) { if ($context->truthy() && $argType->isArray()->maybe()) { $countables = []; @@ -318,7 +338,7 @@ public function specifyTypesInCondition( if (count($countables) > 0) { $countableType = TypeCombinator::union(...$countables); - return $this->create($expr->right->getArgs()[0]->value, $countableType, $context, $scope)->setRootExpr($expr); + return $this->create($countFuncCall->getArgs()[0]->value, $countableType, $context, $scope)->setRootExpr($expr); } } @@ -329,21 +349,21 @@ public function specifyTypesInCondition( } $result = $result->unionWith( - $this->create($expr->right->getArgs()[0]->value, $newType, $context, $scope)->setRootExpr($expr), + $this->create($countFuncCall->getArgs()[0]->value, $newType, $context, $scope)->setRootExpr($expr), ); } } - // infer $list[$index] after $index < count($list) + // infer $list[$index] after $index < count($list) or $index < count($list) - K if ( $context->true() - && !$orEqual + && (!$orEqual || $subtraction >= 1) // constant offsets are handled via HasOffsetType/HasOffsetValueType && !$leftType instanceof ConstantIntegerType && $argType->isList()->yes() && IntegerRangeType::fromInterval(0, null)->isSuperTypeOf($leftType)->yes() ) { - $arrayArg = $expr->right->getArgs()[0]->value; + $arrayArg = $countFuncCall->getArgs()[0]->value; $dimFetch = new ArrayDimFetch($arrayArg, $expr->left); $result = $result->unionWith( $this->create($dimFetch, $argType->getIterableValueType(), TypeSpecifierContext::createTrue(), $scope)->setRootExpr($expr), diff --git a/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php b/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php index dc168211dd..50c939e4e5 100644 --- a/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php +++ b/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php @@ -1189,4 +1189,16 @@ public function testBug13770(): void ]); } + public function testBug14215(): void + { + $this->reportPossiblyNonexistentGeneralArrayOffset = true; + + $this->analyse([__DIR__ . '/data/bug-14215.php'], [ + [ + 'Offset int might not exist on list.', + 39, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Arrays/data/bug-14215.php b/tests/PHPStan/Rules/Arrays/data/bug-14215.php new file mode 100644 index 0000000000..c8042198d4 --- /dev/null +++ b/tests/PHPStan/Rules/Arrays/data/bug-14215.php @@ -0,0 +1,122 @@ + $array + * @param positive-int $index + */ + public function positiveIntLessThanCountMinusOne(array $array, int $index): int + { + if ($index < count($array) - 1) { + return $array[$index]; // should not report + } + + return 0; + } + + /** + * @param list $array + * @param positive-int $index + */ + public function positiveIntLessThanOrEqualCountMinusOne(array $array, int $index): int + { + if ($index <= count($array) - 1) { + return $array[$index]; // should not report + } + + return 0; + } + + /** + * @param list $array + */ + public function intLessThanOrEqualCountMinusOne(array $array, int $index): int + { + if ($index <= count($array) - 1) { + return $array[$index]; // should error report, could be negative int + } + + return 0; + } + + /** + * @param list $array + * @param int<0, max> $index + */ + public function nonNegativeIntLessThanCountMinusOne(array $array, int $index): int + { + if ($index < count($array) - 1) { + return $array[$index]; // should not report + } + + return 0; + } + + /** + * @param list $array + * @param int<0, max> $index + */ + public function nonNegativeIntLessThanOrEqualCountMinusOne(array $array, int $index): int + { + if ($index <= count($array) - 1) { + return $array[$index]; // should not report + } + + return 0; + } + + /** + * @param list $array + * @param positive-int $index + */ + public function positiveIntLessThanCountMinusTwo(array $array, int $index): int + { + if ($index < count($array) - 2) { + return $array[$index]; // should not report + } + + return 0; + } + + /** + * @param list $array + * @param positive-int $index + */ + public function positiveIntLessThanOrEqualCountMinusTwo(array $array, int $index): int + { + if ($index <= count($array) - 2) { + return $array[$index]; // should not report + } + + return 0; + } + + /** + * @param list $array + * @param positive-int $index + */ + public function positiveIntLessThanSizeofMinusOne(array $array, int $index): int + { + if ($index < sizeof($array) - 1) { + return $array[$index]; // should not report + } + + return 0; + } + + /** + * @param list $array + * @param positive-int $index + */ + public function positiveIntGreaterThanCountMinusOneInversed(array $array, int $index): int + { + if (count($array) - 1 > $index) { + return $array[$index]; // should not report + } + + return 0; + } +}