From a093a2a369af3cfc919eb5a561f0cd7f05abf177 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Wed, 15 Oct 2025 07:53:39 +0200 Subject: [PATCH 1/8] infer `non-empty-list/array` after `array_key_exists($i, $arr)` --- ...eyExistsFunctionTypeSpecifyingExtension.php | 18 ++++++++++-------- .../slevomat-foreach-array-key-exists-bug.php | 2 +- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/src/Type/Php/ArrayKeyExistsFunctionTypeSpecifyingExtension.php b/src/Type/Php/ArrayKeyExistsFunctionTypeSpecifyingExtension.php index c9de826666..7ec3250cc2 100644 --- a/src/Type/Php/ArrayKeyExistsFunctionTypeSpecifyingExtension.php +++ b/src/Type/Php/ArrayKeyExistsFunctionTypeSpecifyingExtension.php @@ -66,13 +66,15 @@ public function specifyTypes( && !$keyType instanceof ConstantStringType ) { if ($context->true()) { + $specifiedTypes = $this->typeSpecifier->create( + $array, + new NonEmptyArrayType(), + $context, + $scope, + ); + if ($arrayType->isIterableAtLeastOnce()->no()) { - return $this->typeSpecifier->create( - $array, - new NonEmptyArrayType(), - $context, - $scope, - ); + return $specifiedTypes; } $arrayKeyType = $arrayType->getIterableKeyType(); @@ -82,12 +84,12 @@ public function specifyTypes( $arrayKeyType = TypeCombinator::union($arrayKeyType, $arrayKeyType->toString()); } - $specifiedTypes = $this->typeSpecifier->create( + $specifiedTypes = $specifiedTypes->unionWith($this->typeSpecifier->create( $key, $arrayKeyType, $context, $scope, - ); + )); $arrayDimFetch = new ArrayDimFetch( $array, diff --git a/tests/PHPStan/Rules/Arrays/data/slevomat-foreach-array-key-exists-bug.php b/tests/PHPStan/Rules/Arrays/data/slevomat-foreach-array-key-exists-bug.php index 0588be365b..08ee797f08 100644 --- a/tests/PHPStan/Rules/Arrays/data/slevomat-foreach-array-key-exists-bug.php +++ b/tests/PHPStan/Rules/Arrays/data/slevomat-foreach-array-key-exists-bug.php @@ -15,7 +15,7 @@ public function doFoo(array $percentageIntervals, array $changes): void if ($percentageInterval->isInInterval((float) $changeInPercents)) { $key = $percentageInterval->getFormatted(); if (array_key_exists($key, $intervalResults)) { - assertType('array', $intervalResults); + assertType('non-empty-array', $intervalResults); assertType('array{itemsCount: mixed, interval: mixed}', $intervalResults[$key]); $intervalResults[$key]['itemsCount'] += $itemsCount; assertType('non-empty-array', $intervalResults); From 4966dab0131c1df085b335a198bb8b38fbdfda82 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Wed, 15 Oct 2025 07:56:09 +0200 Subject: [PATCH 2/8] Create bug13674a.php --- tests/PHPStan/Analyser/nsrt/bug13674a.php | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 tests/PHPStan/Analyser/nsrt/bug13674a.php diff --git a/tests/PHPStan/Analyser/nsrt/bug13674a.php b/tests/PHPStan/Analyser/nsrt/bug13674a.php new file mode 100644 index 0000000000..25c9f312cd --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug13674a.php @@ -0,0 +1,23 @@ + $arrayA + * @param list $listA + */ + public function sayHello($arrayA, $listA, int $i): void + { + if (array_key_exists($i, $arrayA)) { + assertType('non-empty-array', $arrayA); + } + + if (array_key_exists($i, $listA)) { + assertType('non-empty-list', $listA); + } + } +} From 8476760a813b871a1dd368cd337f9b44f38e9717 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Wed, 15 Oct 2025 08:06:37 +0200 Subject: [PATCH 3/8] Added regression tests --- tests/PHPStan/Analyser/nsrt/bug13674a.php | 6 +++ ...nexistentOffsetInArrayDimFetchRuleTest.php | 7 ++++ tests/PHPStan/Rules/Arrays/data/bug-3795.php | 41 +++++++++++++++++++ 3 files changed, 54 insertions(+) create mode 100644 tests/PHPStan/Rules/Arrays/data/bug-3795.php diff --git a/tests/PHPStan/Analyser/nsrt/bug13674a.php b/tests/PHPStan/Analyser/nsrt/bug13674a.php index 25c9f312cd..c1750e89da 100644 --- a/tests/PHPStan/Analyser/nsrt/bug13674a.php +++ b/tests/PHPStan/Analyser/nsrt/bug13674a.php @@ -14,10 +14,16 @@ public function sayHello($arrayA, $listA, int $i): void { if (array_key_exists($i, $arrayA)) { assertType('non-empty-array', $arrayA); + } else { + assertType('array', $arrayA); } + assertType('array', $arrayA); if (array_key_exists($i, $listA)) { assertType('non-empty-list', $listA); + } else { + assertType('list', $listA); } + assertType('list', $listA); } } diff --git a/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php b/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php index 471a1a1b01..813f1804df 100644 --- a/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php +++ b/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php @@ -1113,4 +1113,11 @@ public function testBug12805(): void $this->analyse([__DIR__ . '/data/bug-12805.php'], []); } + public function testBug3795(): void + { + $this->reportPossiblyNonexistentGeneralArrayOffset = true; + + $this->analyse([__DIR__ . '/data/bug-3795.php'], []); + } + } diff --git a/tests/PHPStan/Rules/Arrays/data/bug-3795.php b/tests/PHPStan/Rules/Arrays/data/bug-3795.php new file mode 100644 index 0000000000..75c9805e69 --- /dev/null +++ b/tests/PHPStan/Rules/Arrays/data/bug-3795.php @@ -0,0 +1,41 @@ +id = $id; + $this->name = $name; + } + + public function getId() : string { + return $this->id; + } + + public function getName() : string { + return $this->name; + } +} + +class Users { + /** + * @param array{id?: string, name?: string} $data + */ + public static function create(array $data) : User { + // The following generates a warning + + foreach (['id', 'name'] as $required) { + if (!array_key_exists($required, $data)) { + throw new InvalidArgumentException('Data is missing ' . $required); + } + } + + return new User( + (string) $data['id'], + (string) $data['name'], + ); + } +} From 3b8c06e65c1c3f9b232be4acc4dd2f7770620241 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Wed, 15 Oct 2025 08:10:49 +0200 Subject: [PATCH 4/8] Added regression test --- ...nexistentOffsetInArrayDimFetchRuleTest.php | 7 +++++++ tests/PHPStan/Rules/Arrays/data/bug-11276.php | 20 +++++++++++++++++++ 2 files changed, 27 insertions(+) create mode 100644 tests/PHPStan/Rules/Arrays/data/bug-11276.php diff --git a/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php b/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php index 813f1804df..03ab8b11c3 100644 --- a/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php +++ b/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php @@ -1106,6 +1106,13 @@ public function testPR4385Bis(): void $this->analyse([__DIR__ . '/data/pr-4385-bis.php'], []); } + public function testBug11276(): void + { + $this->reportPossiblyNonexistentGeneralArrayOffset = true; + + $this->analyse([__DIR__ . '/data/bug-11276.php'], []); + } + public function testBug12805(): void { $this->reportPossiblyNonexistentGeneralArrayOffset = true; diff --git a/tests/PHPStan/Rules/Arrays/data/bug-11276.php b/tests/PHPStan/Rules/Arrays/data/bug-11276.php new file mode 100644 index 0000000000..8ca5794f6f --- /dev/null +++ b/tests/PHPStan/Rules/Arrays/data/bug-11276.php @@ -0,0 +1,20 @@ +[a-z]{2})-(?[a-z]{1}[a-z0-9]{1})\/#', $url, $matches); + + foreach ($expected as $key => $value) { + if ($matches instanceof ArrayAccess || \array_key_exists($key, $matches)) { + $matches[$key]; + } + } + } +} From a3f243199b6293e41f210355d69ed1764277557f Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Wed, 15 Oct 2025 11:06:08 +0200 Subject: [PATCH 5/8] Added failling test while looking into `isset($arr[$i])` I realized that we have this untested case in `array_kex_exists` --- ...nexistentOffsetInArrayDimFetchRuleTest.php | 10 +++++++++ tests/PHPStan/Rules/Arrays/data/bug-7000b.php | 22 +++++++++++++++++++ 2 files changed, 32 insertions(+) create mode 100644 tests/PHPStan/Rules/Arrays/data/bug-7000b.php diff --git a/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php b/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php index 03ab8b11c3..96d57eba49 100644 --- a/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php +++ b/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php @@ -406,6 +406,16 @@ public function testBug7000(): void ]); } + public function testBug7000b(): void + { + $this->analyse([__DIR__ . '/data/bug-7000b.php'], [ + [ + "Offset 'require'|'require-dev' might not exist on array{require?: array, require-dev?: array}.", + 18, + ], + ]); + } + public function testBug6508(): void { $this->analyse([__DIR__ . '/data/bug-6508.php'], []); diff --git a/tests/PHPStan/Rules/Arrays/data/bug-7000b.php b/tests/PHPStan/Rules/Arrays/data/bug-7000b.php new file mode 100644 index 0000000000..8b272087e2 --- /dev/null +++ b/tests/PHPStan/Rules/Arrays/data/bug-7000b.php @@ -0,0 +1,22 @@ +, require-dev?: array} $composer */ + $composer = array(); + /** @var 'require'|'require-dev' $foo */ + $foo = ''; + foreach (array('require', 'require-dev') as $linkType) { + if (array_key_exists($linkType, $composer)) { + foreach ($composer[$linkType] as $x) {} // should not report error + foreach ($composer[$foo] as $x) {} // should report error. It can be $linkType = 'require', $foo = 'require-dev' + } + } + } +} From ef9e8aa56be4c179fe9a9e4148ea42ea9322e1ca Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Wed, 15 Oct 2025 11:11:35 +0200 Subject: [PATCH 6/8] simplify test --- .../Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php | 2 +- tests/PHPStan/Rules/Arrays/data/bug-7000b.php | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php b/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php index 96d57eba49..5ce45697ef 100644 --- a/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php +++ b/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php @@ -411,7 +411,7 @@ public function testBug7000b(): void $this->analyse([__DIR__ . '/data/bug-7000b.php'], [ [ "Offset 'require'|'require-dev' might not exist on array{require?: array, require-dev?: array}.", - 18, + 16, ], ]); } diff --git a/tests/PHPStan/Rules/Arrays/data/bug-7000b.php b/tests/PHPStan/Rules/Arrays/data/bug-7000b.php index 8b272087e2..2789458ab3 100644 --- a/tests/PHPStan/Rules/Arrays/data/bug-7000b.php +++ b/tests/PHPStan/Rules/Arrays/data/bug-7000b.php @@ -2,8 +2,6 @@ namespace Bug7000b; -use function array_key_exists; - class Foo { public function doBar(): void From a8c0d9c5f2389eba9550efcdc6fad5245489ce0e Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Wed, 15 Oct 2025 11:17:27 +0200 Subject: [PATCH 7/8] fix --- ...yKeyExistsFunctionTypeSpecifyingExtension.php | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/Type/Php/ArrayKeyExistsFunctionTypeSpecifyingExtension.php b/src/Type/Php/ArrayKeyExistsFunctionTypeSpecifyingExtension.php index 7ec3250cc2..714768a8b9 100644 --- a/src/Type/Php/ArrayKeyExistsFunctionTypeSpecifyingExtension.php +++ b/src/Type/Php/ArrayKeyExistsFunctionTypeSpecifyingExtension.php @@ -66,12 +66,16 @@ public function specifyTypes( && !$keyType instanceof ConstantStringType ) { if ($context->true()) { - $specifiedTypes = $this->typeSpecifier->create( - $array, - new NonEmptyArrayType(), - $context, - $scope, - ); + $specifiedTypes = new SpecifiedTypes(); + + if (count($keyType->getConstantScalarTypes()) <= 1) { + $specifiedTypes = $specifiedTypes->unionWith($this->typeSpecifier->create( + $array, + new NonEmptyArrayType(), + $context, + $scope, + )); + } if ($arrayType->isIterableAtLeastOnce()->no()) { return $specifiedTypes; From 12c6be325beb77f70b8350e169aeae83b0772b17 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Wed, 15 Oct 2025 11:18:50 +0200 Subject: [PATCH 8/8] remove no longer covered tests --- ...nexistentOffsetInArrayDimFetchRuleTest.php | 14 ------- tests/PHPStan/Rules/Arrays/data/bug-11276.php | 20 --------- tests/PHPStan/Rules/Arrays/data/bug-3795.php | 41 ------------------- 3 files changed, 75 deletions(-) delete mode 100644 tests/PHPStan/Rules/Arrays/data/bug-11276.php delete mode 100644 tests/PHPStan/Rules/Arrays/data/bug-3795.php diff --git a/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php b/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php index 5ce45697ef..b18e1aa4f3 100644 --- a/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php +++ b/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php @@ -1116,13 +1116,6 @@ public function testPR4385Bis(): void $this->analyse([__DIR__ . '/data/pr-4385-bis.php'], []); } - public function testBug11276(): void - { - $this->reportPossiblyNonexistentGeneralArrayOffset = true; - - $this->analyse([__DIR__ . '/data/bug-11276.php'], []); - } - public function testBug12805(): void { $this->reportPossiblyNonexistentGeneralArrayOffset = true; @@ -1130,11 +1123,4 @@ public function testBug12805(): void $this->analyse([__DIR__ . '/data/bug-12805.php'], []); } - public function testBug3795(): void - { - $this->reportPossiblyNonexistentGeneralArrayOffset = true; - - $this->analyse([__DIR__ . '/data/bug-3795.php'], []); - } - } diff --git a/tests/PHPStan/Rules/Arrays/data/bug-11276.php b/tests/PHPStan/Rules/Arrays/data/bug-11276.php deleted file mode 100644 index 8ca5794f6f..0000000000 --- a/tests/PHPStan/Rules/Arrays/data/bug-11276.php +++ /dev/null @@ -1,20 +0,0 @@ -[a-z]{2})-(?[a-z]{1}[a-z0-9]{1})\/#', $url, $matches); - - foreach ($expected as $key => $value) { - if ($matches instanceof ArrayAccess || \array_key_exists($key, $matches)) { - $matches[$key]; - } - } - } -} diff --git a/tests/PHPStan/Rules/Arrays/data/bug-3795.php b/tests/PHPStan/Rules/Arrays/data/bug-3795.php deleted file mode 100644 index 75c9805e69..0000000000 --- a/tests/PHPStan/Rules/Arrays/data/bug-3795.php +++ /dev/null @@ -1,41 +0,0 @@ -id = $id; - $this->name = $name; - } - - public function getId() : string { - return $this->id; - } - - public function getName() : string { - return $this->name; - } -} - -class Users { - /** - * @param array{id?: string, name?: string} $data - */ - public static function create(array $data) : User { - // The following generates a warning - - foreach (['id', 'name'] as $required) { - if (!array_key_exists($required, $data)) { - throw new InvalidArgumentException('Data is missing ' . $required); - } - } - - return new User( - (string) $data['id'], - (string) $data['name'], - ); - } -}