From a39c294aceadd157e4091b25499b21534279c755 Mon Sep 17 00:00:00 2001 From: phpstan-bot <79867460+phpstan-bot@users.noreply.github.com> Date: Thu, 12 Mar 2026 08:39:24 +0000 Subject: [PATCH 1/5] Fix regression in offset access check on non-empty narrowed arrays - Fixed TypeUtils::flattenTypes() to only expand bare ConstantArrayTypes, not ConstantArrayTypes wrapped in IntersectionTypes (e.g., from !== [] narrowing) - The regression was caused by 5b79cca6d which changed flattenTypes() to use getConstantArrays() which extracts ConstantArrayTypes from IntersectionTypes, losing the NonEmptyArrayType constraint during expansion - Added regression test in ArrayDestructuringRuleTest for bug #14270 - Updated bug-7143 test expectations to match restored behavior Closes https://github.com/phpstan/phpstan/issues/14270 --- src/Type/TypeUtils.php | 3 ++- .../Arrays/ArrayDestructuringRuleTest.php | 5 +++++ ...nexistentOffsetInArrayDimFetchRuleTest.php | 19 +------------------ tests/PHPStan/Rules/Arrays/data/bug-14270.php | 14 ++++++++++++++ 4 files changed, 22 insertions(+), 19 deletions(-) create mode 100644 tests/PHPStan/Rules/Arrays/data/bug-14270.php diff --git a/src/Type/TypeUtils.php b/src/Type/TypeUtils.php index 2b0c7635de..69a90395b0 100644 --- a/src/Type/TypeUtils.php +++ b/src/Type/TypeUtils.php @@ -13,6 +13,7 @@ use function array_filter; use function array_map; use function array_merge; +use function count; use function iterator_to_array; /** @@ -146,7 +147,7 @@ public static function flattenTypes(Type $type): array } $constantArrays = $type->getConstantArrays(); - if ($constantArrays !== []) { + if ($constantArrays !== [] && count($constantArrays) === 1 && $constantArrays[0] === $type) { $newTypes = []; foreach ($constantArrays as $constantArray) { $newTypes[] = $constantArray->getAllArrays(); diff --git a/tests/PHPStan/Rules/Arrays/ArrayDestructuringRuleTest.php b/tests/PHPStan/Rules/Arrays/ArrayDestructuringRuleTest.php index b105abcd36..f0b14c9667 100644 --- a/tests/PHPStan/Rules/Arrays/ArrayDestructuringRuleTest.php +++ b/tests/PHPStan/Rules/Arrays/ArrayDestructuringRuleTest.php @@ -49,6 +49,11 @@ public function testRule(): void ]); } + public function testBug14270(): void + { + $this->analyse([__DIR__ . '/data/bug-14270.php'], []); + } + #[RequiresPhp('>= 8.0')] public function testRuleWithNullsafeVariant(): void { diff --git a/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php b/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php index 7649e1dffe..43358c618f 100644 --- a/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php +++ b/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php @@ -1113,24 +1113,7 @@ public function testPR4385Bis(): void public function testBug7143(): void { - $this->analyse([__DIR__ . '/data/bug-7143.php'], [ - [ - "Offset 'foo' might not exist on non-empty-array{foo?: string, bar?: string}.", - 12, - ], - [ - "Offset 'bar' might not exist on non-empty-array{foo?: string, bar?: string}.", - 13, - ], - [ - "Offset 'foo' might not exist on non-empty-array{foo?: string, bar?: string, 1?: 1, 2?: 2, 3?: 3, 4?: 4, 5?: 5, 6?: 6, ...}.", - 21, - ], - [ - "Offset 'bar' might not exist on non-empty-array{foo?: string, bar?: string, 1?: 1, 2?: 2, 3?: 3, 4?: 4, 5?: 5, 6?: 6, ...}.", - 22, - ], - ]); + $this->analyse([__DIR__ . '/data/bug-7143.php'], []); } public function testBug12805(): void diff --git a/tests/PHPStan/Rules/Arrays/data/bug-14270.php b/tests/PHPStan/Rules/Arrays/data/bug-14270.php new file mode 100644 index 0000000000..d9781c936d --- /dev/null +++ b/tests/PHPStan/Rules/Arrays/data/bug-14270.php @@ -0,0 +1,14 @@ + Date: Thu, 12 Mar 2026 09:09:08 +0000 Subject: [PATCH 2/5] Revert incorrect flattenTypes() fix for bug-14270 The previous fix incorrectly modified TypeUtils::flattenTypes() which also broke bug-7143 assertions. The root cause is in regex array type inference, not in type flattening. Co-Authored-By: Claude Opus 4.6 --- src/Type/TypeUtils.php | 3 +-- ...nexistentOffsetInArrayDimFetchRuleTest.php | 19 ++++++++++++++++++- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/src/Type/TypeUtils.php b/src/Type/TypeUtils.php index 69a90395b0..2b0c7635de 100644 --- a/src/Type/TypeUtils.php +++ b/src/Type/TypeUtils.php @@ -13,7 +13,6 @@ use function array_filter; use function array_map; use function array_merge; -use function count; use function iterator_to_array; /** @@ -147,7 +146,7 @@ public static function flattenTypes(Type $type): array } $constantArrays = $type->getConstantArrays(); - if ($constantArrays !== [] && count($constantArrays) === 1 && $constantArrays[0] === $type) { + if ($constantArrays !== []) { $newTypes = []; foreach ($constantArrays as $constantArray) { $newTypes[] = $constantArray->getAllArrays(); diff --git a/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php b/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php index 43358c618f..7649e1dffe 100644 --- a/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php +++ b/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php @@ -1113,7 +1113,24 @@ public function testPR4385Bis(): void public function testBug7143(): void { - $this->analyse([__DIR__ . '/data/bug-7143.php'], []); + $this->analyse([__DIR__ . '/data/bug-7143.php'], [ + [ + "Offset 'foo' might not exist on non-empty-array{foo?: string, bar?: string}.", + 12, + ], + [ + "Offset 'bar' might not exist on non-empty-array{foo?: string, bar?: string}.", + 13, + ], + [ + "Offset 'foo' might not exist on non-empty-array{foo?: string, bar?: string, 1?: 1, 2?: 2, 3?: 3, 4?: 4, 5?: 5, 6?: 6, ...}.", + 21, + ], + [ + "Offset 'bar' might not exist on non-empty-array{foo?: string, bar?: string, 1?: 1, 2?: 2, 3?: 3, 4?: 4, 5?: 5, 6?: 6, ...}.", + 22, + ], + ]); } public function testBug12805(): void From 9d5dba45d19abd7dc27caeb5e1801f2e0c79aee4 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Thu, 12 Mar 2026 09:09:15 +0000 Subject: [PATCH 3/5] Fix preg_match type inference: return union of empty and matched types Instead of returning a single type with all-optional keys (wasMatched=Maybe), PregMatchParameterOutTypeExtension now returns a union of array{} (no match) and the matched type with correct required/optional key semantics (wasMatched=Yes). This means when the array is narrowed to non-empty (e.g. via $matches !== []), the empty variant is eliminated and all non-optional regex groups have required keys. Fixes phpstan/phpstan#14270 Co-Authored-By: Claude Opus 4.6 --- src/Type/Php/PregMatchParameterOutTypeExtension.php | 13 ++++++++++++- tests/PHPStan/Analyser/data/param-out.php | 4 ++-- tests/PHPStan/Analyser/nsrt/bug-11293.php | 6 +++--- tests/PHPStan/Analyser/nsrt/bug-11311.php | 6 +++--- tests/PHPStan/Analyser/nsrt/bug-11580.php | 2 +- tests/PHPStan/Analyser/nsrt/count-type.php | 2 +- tests/PHPStan/Analyser/nsrt/if.php | 4 ++-- tests/PHPStan/Analyser/nsrt/preg_match_shapes.php | 10 +++++----- 8 files changed, 29 insertions(+), 18 deletions(-) diff --git a/src/Type/Php/PregMatchParameterOutTypeExtension.php b/src/Type/Php/PregMatchParameterOutTypeExtension.php index b8bd415b45..58135a1dcb 100644 --- a/src/Type/Php/PregMatchParameterOutTypeExtension.php +++ b/src/Type/Php/PregMatchParameterOutTypeExtension.php @@ -8,8 +8,10 @@ use PHPStan\Reflection\FunctionReflection; use PHPStan\Reflection\ParameterReflection; use PHPStan\TrinaryLogic; +use PHPStan\Type\Constant\ConstantArrayTypeBuilder; use PHPStan\Type\FunctionParameterOutTypeExtension; use PHPStan\Type\Type; +use PHPStan\Type\TypeCombinator; use function in_array; use function strtolower; @@ -49,8 +51,17 @@ public function getParameterOutTypeFromFunctionCall(FunctionReflection $function } if ($functionReflection->getName() === 'preg_match') { - return $this->regexShapeMatcher->matchExpr($patternArg->value, $flagsType, TrinaryLogic::createMaybe(), $scope); + $matchedType = $this->regexShapeMatcher->matchExpr($patternArg->value, $flagsType, TrinaryLogic::createYes(), $scope); + if ($matchedType === null) { + return null; + } + + return TypeCombinator::union( + ConstantArrayTypeBuilder::createEmpty()->getArray(), + $matchedType, + ); } + return $this->regexShapeMatcher->matchAllExpr($patternArg->value, $flagsType, TrinaryLogic::createMaybe(), $scope); } diff --git a/tests/PHPStan/Analyser/data/param-out.php b/tests/PHPStan/Analyser/data/param-out.php index e34e1c9082..3eed881843 100644 --- a/tests/PHPStan/Analyser/data/param-out.php +++ b/tests/PHPStan/Analyser/data/param-out.php @@ -494,10 +494,10 @@ function fooMatch(string $input): void { assertType('list', $matches); preg_match('/@[a-z\d](?:[a-z\d]|-(?=[a-z\d])){0,38}(?!\w)/', $input, $matches, PREG_UNMATCHED_AS_NULL); - assertType("array{0?: string}", $matches); + assertType("array{}|array{non-falsy-string}", $matches); } function testMatch() { preg_match('#.*#', 'foo', $matches); - assertType('array{0?: string}', $matches); + assertType('array{}|array{string}', $matches); } diff --git a/tests/PHPStan/Analyser/nsrt/bug-11293.php b/tests/PHPStan/Analyser/nsrt/bug-11293.php index caf95180ab..49f8d008a6 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-11293.php +++ b/tests/PHPStan/Analyser/nsrt/bug-11293.php @@ -30,7 +30,7 @@ public function sayHello3(string $s): void public function sayHello4(string $s): void { if (preg_match('/data-(\d{6})\.json$/', $s, $matches) <= 0) { - assertType('list{0?: string, 1?: non-falsy-string&numeric-string}', $matches); + assertType('array{}|array{non-falsy-string, non-falsy-string&numeric-string}', $matches); return; } @@ -41,7 +41,7 @@ public function sayHello4(string $s): void public function sayHello5(string $s): void { if (preg_match('/data-(\d{6})\.json$/', $s, $matches) < 1) { - assertType('list{0?: string, 1?: non-falsy-string&numeric-string}', $matches); + assertType('array{}|array{non-falsy-string, non-falsy-string&numeric-string}', $matches); return; } @@ -52,7 +52,7 @@ public function sayHello5(string $s): void public function sayHello6(string $s): void { if (1 > preg_match('/data-(\d{6})\.json$/', $s, $matches)) { - assertType('list{0?: string, 1?: non-falsy-string&numeric-string}', $matches); + assertType('array{}|array{non-falsy-string, non-falsy-string&numeric-string}', $matches); return; } diff --git a/tests/PHPStan/Analyser/nsrt/bug-11311.php b/tests/PHPStan/Analyser/nsrt/bug-11311.php index 96b810431d..451eac6197 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-11311.php +++ b/tests/PHPStan/Analyser/nsrt/bug-11311.php @@ -191,12 +191,12 @@ function (string $s): void { function (string $s): void { preg_match('/%a(\d*)/', $s, $matches, PREG_UNMATCHED_AS_NULL); - assertType("list{0?: string, 1?: ''|numeric-string|null}", $matches); // could be array{0?: string, 1?: ''|numeric-string} + assertType("array{}|array{non-falsy-string, ''|numeric-string}", $matches); // could be array{0?: string, 1?: ''|numeric-string} }; function (string $s): void { preg_match('/%a(\d*)?/', $s, $matches, PREG_UNMATCHED_AS_NULL); - assertType("list{0?: string, 1?: ''|numeric-string|null}", $matches); // could be array{0?: string, 1?: ''|numeric-string} + assertType("array{}|array{non-falsy-string, ''|numeric-string|null}", $matches); // could be array{0?: string, 1?: ''|numeric-string} }; function (string $s): void { @@ -222,5 +222,5 @@ function (string $s): void { function (string $s): void { preg_match('~a|(\d)|(\s)~', $s, $matches, PREG_UNMATCHED_AS_NULL); - assertType("list{0?: string, 1?: numeric-string|null, 2?: non-empty-string|null}", $matches); + assertType("array{}|array{non-empty-string, numeric-string|null, non-empty-string|null}", $matches); }; diff --git a/tests/PHPStan/Analyser/nsrt/bug-11580.php b/tests/PHPStan/Analyser/nsrt/bug-11580.php index 039a1895f5..2e4f8200db 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-11580.php +++ b/tests/PHPStan/Analyser/nsrt/bug-11580.php @@ -26,7 +26,7 @@ public function bad2(string $in): void public function bad3(string $in): void { $result = preg_match('~^/xxx/([\w\-]+)/?([\w\-]+)?/?$~', $in, $matches); - assertType('list{0?: string, 1?: non-empty-string, 2?: non-empty-string}', $matches); + assertType('array{}|array{0: non-falsy-string, 1: non-empty-string, 2?: non-empty-string}', $matches); if ($result) { assertType('array{0: non-falsy-string, 1: non-empty-string, 2?: non-empty-string}', $matches); } diff --git a/tests/PHPStan/Analyser/nsrt/count-type.php b/tests/PHPStan/Analyser/nsrt/count-type.php index 1deb2e8695..8ebe0c445e 100644 --- a/tests/PHPStan/Analyser/nsrt/count-type.php +++ b/tests/PHPStan/Analyser/nsrt/count-type.php @@ -94,7 +94,7 @@ public function constantArrayWhichCanBecomeList(string $h): void return; } - assertType('array{string, non-empty-string}', $matches); + assertType('array{non-falsy-string, non-empty-string}', $matches); } } diff --git a/tests/PHPStan/Analyser/nsrt/if.php b/tests/PHPStan/Analyser/nsrt/if.php index de9ebc3cb9..0cb3980f10 100644 --- a/tests/PHPStan/Analyser/nsrt/if.php +++ b/tests/PHPStan/Analyser/nsrt/if.php @@ -392,7 +392,7 @@ function () { assertVariableCertainty(TrinaryLogic::createYes(), $anotherF); assertType('int<1, max>', $anotherF); assertVariableCertainty(TrinaryLogic::createYes(), $matches); - assertType('array{0?: string}', $matches); + assertType('array{}|array{string}', $matches); assertVariableCertainty(TrinaryLogic::createYes(), $anotherArray); assertType('array{test: array{\'another\'}}', $anotherArray); assertVariableCertainty(TrinaryLogic::createYes(), $ifVar); @@ -405,7 +405,7 @@ function () { assertType('1|2|3', $ifNotNestedVar); assertVariableCertainty(TrinaryLogic::createNo(), $variableOnlyInEarlyTerminatingElse); assertVariableCertainty(TrinaryLogic::createMaybe(), $matches2); - assertType('array{0?: string}', $matches2); + assertType('array{}|array{string}', $matches2); assertVariableCertainty(TrinaryLogic::createYes(), $inTry); assertType('1', $inTry); assertVariableCertainty(TrinaryLogic::createYes(), $matches3); diff --git a/tests/PHPStan/Analyser/nsrt/preg_match_shapes.php b/tests/PHPStan/Analyser/nsrt/preg_match_shapes.php index bf26bd5506..543a1b84ac 100644 --- a/tests/PHPStan/Analyser/nsrt/preg_match_shapes.php +++ b/tests/PHPStan/Analyser/nsrt/preg_match_shapes.php @@ -525,7 +525,7 @@ function bug11323(string $s): void { function (string $s): void { preg_match('/%a(\d*)/', $s, $matches); - assertType("list{0?: string, 1?: ''|numeric-string}", $matches); + assertType("array{}|array{non-falsy-string, ''|numeric-string}", $matches); }; class Bug11376 @@ -533,7 +533,7 @@ class Bug11376 public function test(string $str): void { preg_match('~^(?:(\w+)::)?(\w+)$~', $str, $matches); - assertType('list{0?: string, 1?: string, 2?: non-empty-string}', $matches); + assertType("array{}|array{non-empty-string, string, non-empty-string}", $matches); } public function test2(string $str): void @@ -706,7 +706,7 @@ static public function sayHello(string $source): void // 2 => '1', //) - assertType("array{0?: string, dateFrom?: ''|numeric-string, 1?: ''|numeric-string, dateTo?: numeric-string, 2?: numeric-string}", $matches); + assertType("array{}|array{0: string, dateFrom?: ''|numeric-string, 1?: ''|numeric-string, dateTo?: numeric-string, 2?: numeric-string}", $matches); } } @@ -730,7 +730,7 @@ function (string $s): void { function (string $s): void { preg_match('~a|(\d)|(\s)~', $s, $matches); - assertType("list{0?: string, 1?: '', 2?: non-empty-string}|list{0?: string, 1?: numeric-string}", $matches); + assertType("array{}|array{0: non-empty-string, 1?: numeric-string}|array{non-empty-string, '', non-empty-string}", $matches); }; function bug11490 (string $expression): void { @@ -1013,7 +1013,7 @@ function bug12749f(string $str): void function bug12397(string $string): void { $m = preg_match('#\b([A-Z]{2,})-(\d+)#', $string, $match); - assertType('list{0?: string, 1?: non-falsy-string, 2?: numeric-string}', $match); + assertType("array{}|array{non-falsy-string, non-falsy-string, numeric-string}", $match); } function bug12792(string $string): void { From 4c08be2567c5fa69fa5160c107b3654bb813972f Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Thu, 12 Mar 2026 09:29:27 +0000 Subject: [PATCH 4/5] Remove outdated 'could be' comments from preg_match test The union type representation makes these comments no longer applicable. Co-Authored-By: Claude Opus 4.6 --- tests/PHPStan/Analyser/nsrt/bug-11311.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/PHPStan/Analyser/nsrt/bug-11311.php b/tests/PHPStan/Analyser/nsrt/bug-11311.php index 451eac6197..79fbf3aca4 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-11311.php +++ b/tests/PHPStan/Analyser/nsrt/bug-11311.php @@ -191,12 +191,12 @@ function (string $s): void { function (string $s): void { preg_match('/%a(\d*)/', $s, $matches, PREG_UNMATCHED_AS_NULL); - assertType("array{}|array{non-falsy-string, ''|numeric-string}", $matches); // could be array{0?: string, 1?: ''|numeric-string} + assertType("array{}|array{non-falsy-string, ''|numeric-string}", $matches); }; function (string $s): void { preg_match('/%a(\d*)?/', $s, $matches, PREG_UNMATCHED_AS_NULL); - assertType("array{}|array{non-falsy-string, ''|numeric-string|null}", $matches); // could be array{0?: string, 1?: ''|numeric-string} + assertType("array{}|array{non-falsy-string, ''|numeric-string|null}", $matches); }; function (string $s): void { From 3dda0a3b186bfaf39af67cb918470512630155ed Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Thu, 12 Mar 2026 10:56:31 +0100 Subject: [PATCH 5/5] added regression test --- tests/PHPStan/Rules/Functions/ReturnTypeRuleTest.php | 7 +++++++ tests/PHPStan/Rules/Functions/data/bug-12397.php | 11 +++++++++++ 2 files changed, 18 insertions(+) create mode 100644 tests/PHPStan/Rules/Functions/data/bug-12397.php diff --git a/tests/PHPStan/Rules/Functions/ReturnTypeRuleTest.php b/tests/PHPStan/Rules/Functions/ReturnTypeRuleTest.php index 169aff175f..dfc4e2bce4 100644 --- a/tests/PHPStan/Rules/Functions/ReturnTypeRuleTest.php +++ b/tests/PHPStan/Rules/Functions/ReturnTypeRuleTest.php @@ -393,4 +393,11 @@ public function testBug12973(): void $this->analyse([__DIR__ . '/../../Analyser/nsrt/bug-12973.php'], []); } + public function testBug12397(): void + { + $this->checkNullables = true; + $this->checkExplicitMixed = true; + $this->analyse([__DIR__ . '/data/bug-12397.php'], []); + } + } diff --git a/tests/PHPStan/Rules/Functions/data/bug-12397.php b/tests/PHPStan/Rules/Functions/data/bug-12397.php new file mode 100644 index 0000000000..430142f07d --- /dev/null +++ b/tests/PHPStan/Rules/Functions/data/bug-12397.php @@ -0,0 +1,11 @@ + + */ +function matchStuff(string $string) : array { + $m = preg_match('#\b([A-Z]{2,})-(\d+)#', $string, $match); + return $match; +}