From 951c677d7b474936f9fbc9d3e4f1df2eb1009ba8 Mon Sep 17 00:00:00 2001 From: staabm <120441+staabm@users.noreply.github.com> Date: Fri, 27 Feb 2026 18:45:51 +0000 Subject: [PATCH 1/6] Fix false positive for list offset access after $index < count($list) - Added type specification in TypeSpecifier for list dim fetch when a non-negative integer variable is compared with count() using strict less-than - When $index < count($list) holds and $index is non-negative, $list[$index] is guaranteed to be a valid offset for a list (sequential 0-based keys) - Excluded ConstantIntegerType left operands since those are already handled by specifyTypesForCountFuncCall - New regression test in tests/PHPStan/Rules/Arrays/data/bug-13770.php --- src/Analyser/TypeSpecifier.php | 15 +++++ ...nexistentOffsetInArrayDimFetchRuleTest.php | 16 +++++ tests/PHPStan/Rules/Arrays/data/bug-13770.php | 58 +++++++++++++++++++ 3 files changed, 89 insertions(+) create mode 100644 tests/PHPStan/Rules/Arrays/data/bug-13770.php diff --git a/src/Analyser/TypeSpecifier.php b/src/Analyser/TypeSpecifier.php index 0949e9eaf9..b6c3346430 100644 --- a/src/Analyser/TypeSpecifier.php +++ b/src/Analyser/TypeSpecifier.php @@ -333,6 +333,21 @@ public function specifyTypesInCondition( ); } } + + // infer $list[$index] after $index < count($list) + if ( + $context->true() + && !$orEqual + && !$leftType instanceof ConstantIntegerType + && $argType->isList()->yes() + && IntegerRangeType::fromInterval(0, null)->isSuperTypeOf($leftType)->yes() + ) { + $arrayArg = $expr->right->getArgs()[0]->value; + $dimFetch = new ArrayDimFetch($arrayArg, $expr->left); + $result = $result->unionWith( + $this->create($dimFetch, $argType->getIterableValueType(), TypeSpecifierContext::createTrue(), $scope)->setRootExpr($expr), + ); + } } if ( diff --git a/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php b/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php index f877c5dec7..97c92cf5aa 100644 --- a/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php +++ b/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php @@ -1155,4 +1155,20 @@ public function testBug11276(): void $this->analyse([__DIR__ . '/data/bug-11276.php'], []); } + public function testBug13770(): void + { + $this->reportPossiblyNonexistentGeneralArrayOffset = true; + + $this->analyse([__DIR__ . '/data/bug-13770.php'], [ + [ + 'Offset int<1, max> might not exist on non-empty-list.', + 40, + ], + [ + 'Offset int might not exist on list.', + 53, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Arrays/data/bug-13770.php b/tests/PHPStan/Rules/Arrays/data/bug-13770.php new file mode 100644 index 0000000000..67cb8a555e --- /dev/null +++ b/tests/PHPStan/Rules/Arrays/data/bug-13770.php @@ -0,0 +1,58 @@ + $array + * @param positive-int $index + */ + public function positiveIntLessThanCount(array $array, int $index): int + { + if ($index < count($array)) { + return $array[$index]; // should not report + } + + return 0; + } + + /** + * @param list $array + * @param int<0, max> $index + */ + public function nonNegativeIntLessThanCount(array $array, int $index): int + { + if ($index < count($array)) { + return $array[$index]; // should not report + } + + return 0; + } + + /** + * @param list $array + * @param positive-int $index + */ + public function positiveIntLessThanOrEqualCount(array $array, int $index): int + { + if ($index <= count($array)) { + return $array[$index]; // SHOULD still report - off by one + } + + return 0; + } + + /** + * @param list $array + * @param int $index + */ + public function anyIntLessThanCount(array $array, int $index): int + { + if ($index < count($array)) { + return $array[$index]; // SHOULD still report - could be negative + } + + return 0; + } +} From e9063c40ee726710ad701bf788a33faa025b1e95 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Sat, 28 Feb 2026 10:59:40 +0100 Subject: [PATCH 2/6] added inverse test --- .../NonexistentOffsetInArrayDimFetchRuleTest.php | 4 ++-- tests/PHPStan/Rules/Arrays/data/bug-13770.php | 13 +++++++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php b/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php index 97c92cf5aa..a2f03455e0 100644 --- a/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php +++ b/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php @@ -1162,11 +1162,11 @@ public function testBug13770(): void $this->analyse([__DIR__ . '/data/bug-13770.php'], [ [ 'Offset int<1, max> might not exist on non-empty-list.', - 40, + 53, ], [ 'Offset int might not exist on list.', - 53, + 66, ], ]); } diff --git a/tests/PHPStan/Rules/Arrays/data/bug-13770.php b/tests/PHPStan/Rules/Arrays/data/bug-13770.php index 67cb8a555e..1bb112b2c9 100644 --- a/tests/PHPStan/Rules/Arrays/data/bug-13770.php +++ b/tests/PHPStan/Rules/Arrays/data/bug-13770.php @@ -17,6 +17,19 @@ public function positiveIntLessThanCount(array $array, int $index): int return 0; } + /** + * @param list $array + * @param positive-int $index + */ + public function positiveIntLessThanCountInversed(array $array, int $index): int + { + if (count($array) > $index) { + return $array[$index]; // should not report + } + + return 0; + } + /** * @param list $array * @param int<0, max> $index From 4d458fc8dadc217cbb15f925ce5c98ac6934f775 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Sat, 28 Feb 2026 11:00:34 +0100 Subject: [PATCH 3/6] added comment --- src/Analyser/TypeSpecifier.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Analyser/TypeSpecifier.php b/src/Analyser/TypeSpecifier.php index b6c3346430..ec9cb809c0 100644 --- a/src/Analyser/TypeSpecifier.php +++ b/src/Analyser/TypeSpecifier.php @@ -338,6 +338,7 @@ public function specifyTypesInCondition( if ( $context->true() && !$orEqual + // constant offsets are handled via HasOffsetType/HasOffsetValueType && !$leftType instanceof ConstantIntegerType && $argType->isList()->yes() && IntegerRangeType::fromInterval(0, null)->isSuperTypeOf($leftType)->yes() From 6a9c8f89361b5a9e17db125da5069b71f160878a Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Sat, 28 Feb 2026 11:13:12 +0100 Subject: [PATCH 4/6] test recursive count --- .../Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php | 4 ++++ tests/PHPStan/Rules/Arrays/data/bug-13770.php | 9 +++++++++ 2 files changed, 13 insertions(+) diff --git a/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php b/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php index a2f03455e0..5e48d9056f 100644 --- a/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php +++ b/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php @@ -1168,6 +1168,10 @@ public function testBug13770(): void 'Offset int might not exist on list.', 66, ], + [ + 'Offset int might not exist on list.', + 75, + ], ]); } diff --git a/tests/PHPStan/Rules/Arrays/data/bug-13770.php b/tests/PHPStan/Rules/Arrays/data/bug-13770.php index 1bb112b2c9..df70b2d7fb 100644 --- a/tests/PHPStan/Rules/Arrays/data/bug-13770.php +++ b/tests/PHPStan/Rules/Arrays/data/bug-13770.php @@ -68,4 +68,13 @@ public function anyIntLessThanCount(array $array, int $index): int return 0; } + + public function anyIntOnRecursiveCount(array $array, int $index): int + { + if ($index < count($array, COUNT_RECURSIVE)) { + return $array[$index]; // SHOULD still report - could be negative + } + + return 0; + } } From 9278b3bcda27b25f866dccec8125880fec8f2c27 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Sat, 28 Feb 2026 12:11:58 +0100 Subject: [PATCH 5/6] Update NonexistentOffsetInArrayDimFetchRuleTest.php --- .../Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php b/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php index 5e48d9056f..fe99cf17d8 100644 --- a/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php +++ b/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php @@ -1169,7 +1169,7 @@ public function testBug13770(): void 66, ], [ - 'Offset int might not exist on list.', + 'Offset int might not exist on array.', 75, ], ]); From aef26320ba5490f3384aa03a9f674ba3e6da8b1f Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Sat, 28 Feb 2026 12:34:38 +0100 Subject: [PATCH 6/6] more tests --- ...nexistentOffsetInArrayDimFetchRuleTest.php | 10 +++- tests/PHPStan/Rules/Arrays/data/bug-13770.php | 51 +++++++++++++++++++ 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php b/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php index fe99cf17d8..282e7a2402 100644 --- a/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php +++ b/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php @@ -1168,9 +1168,17 @@ public function testBug13770(): void 'Offset int might not exist on list.', 66, ], + [ + 'Offset int might not exist on list.', + 91, + ], [ 'Offset int might not exist on array.', - 75, + 100, + ], + [ + 'Offset -1|3|6|10 might not exist on list.', + 126, ], ]); } diff --git a/tests/PHPStan/Rules/Arrays/data/bug-13770.php b/tests/PHPStan/Rules/Arrays/data/bug-13770.php index df70b2d7fb..cbd1748dfb 100644 --- a/tests/PHPStan/Rules/Arrays/data/bug-13770.php +++ b/tests/PHPStan/Rules/Arrays/data/bug-13770.php @@ -69,6 +69,31 @@ public function anyIntLessThanCount(array $array, int $index): int return 0; } + /** + * @param list $array + * @param int<0, max> $index + */ + public function positiveIntOnNormalCountMode(array $array, int $index): int + { + if ($index < count($array, COUNT_NORMAL)) { + return $array[$index]; // should not error + } + + return 0; + } + + /** + * @param list $array + */ + public function anyIntOnUnknownCountMode(array $array, int $index, $countMode): int + { + if ($index < count($array, $countMode)) { + return $array[$index]; // SHOULD still report - could be negative + } + + return 0; + } + public function anyIntOnRecursiveCount(array $array, int $index): int { if ($index < count($array, COUNT_RECURSIVE)) { @@ -77,4 +102,30 @@ public function anyIntOnRecursiveCount(array $array, int $index): int return 0; } + + /** + * @param list $array + * @param 3|6|10 $index + */ + public function constantPositiveIntLessThanCount(array $array, int $index): int + { + if ($index < count($array)) { + return $array[$index]; // should not report + } + + return 0; + } + + /** + * @param list $array + * @param -1|3|6|10 $index + */ + public function constantMaybeNegativeIntLessThanCount(array $array, int $index): int + { + if ($index < count($array)) { + return $array[$index]; // SHOULD still report - could be negative + } + + return 0; + } }