From c65ac7f6721758a570658146981a064fcd55cb42 Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Sun, 15 Feb 2026 18:26:34 +0100 Subject: [PATCH 1/3] Fix array + array inference --- .../InitializerExprTypeResolver.php | 37 ++++++++++++++++--- .../Analyser/LegacyNodeScopeResolverTest.php | 2 +- tests/PHPStan/Analyser/nsrt/bug-13561.php | 37 +++++++++++++++++++ 3 files changed, 70 insertions(+), 6 deletions(-) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-13561.php diff --git a/src/Reflection/InitializerExprTypeResolver.php b/src/Reflection/InitializerExprTypeResolver.php index 937775031f..dd0b9f3300 100644 --- a/src/Reflection/InitializerExprTypeResolver.php +++ b/src/Reflection/InitializerExprTypeResolver.php @@ -1499,18 +1499,45 @@ public function getPlusTypeFromTypes(Expr $left, Expr $right, Type $leftType, Ty $keyType = TypeCombinator::union(...$keyTypes); } + $leftIterableValueType = $leftType->getIterableValueType(); $arrayType = new ArrayType( $keyType, - TypeCombinator::union($leftType->getIterableValueType(), $rightType->getIterableValueType()), + TypeCombinator::union($leftIterableValueType, $rightType->getIterableValueType()), ); $accessories = []; - foreach ($leftType->getConstantArrays() as $type) { - foreach ($type->getKeyTypes() as $i => $offsetType) { - if ($type->isOptionalKey($i)) { + if ($leftCount > 0) { + // Use the first constant array as a reference to list potential offsets. + // We only need to check the first array because we're looking for offsets that exist in ALL arrays. + $constantArray = $leftConstantArrays[0]; + foreach ($constantArray->getKeyTypes() as $i => $offsetType) { + if ($constantArray->isOptionalKey($i)) { continue; } - $valueType = $type->getValueTypes()[$i]; + + if (!$leftType->hasOffsetValueType($offsetType)->yes()) { + continue; + } + + $valueType = $leftType->getOffsetValueType($offsetType); + $accessories[] = new HasOffsetValueType($offsetType, $valueType); + } + } + + if ($rightCount > 0) { + // Use the first constant array as a reference to list potential offsets. + // We only need to check the first array because we're looking for offsets that exist in ALL arrays. + $constantArray = $rightConstantArrays[0]; + foreach ($constantArray->getKeyTypes() as $i => $offsetType) { + if ($constantArray->isOptionalKey($i)) { + continue; + } + + if (!$rightType->hasOffsetValueType($offsetType)->yes()) { + continue; + } + + $valueType = TypeCombinator::union($leftIterableValueType, $rightType->getOffsetValueType($offsetType)); $accessories[] = new HasOffsetValueType($offsetType, $valueType); } } diff --git a/tests/PHPStan/Analyser/LegacyNodeScopeResolverTest.php b/tests/PHPStan/Analyser/LegacyNodeScopeResolverTest.php index 8459f47f97..036b844a9d 100644 --- a/tests/PHPStan/Analyser/LegacyNodeScopeResolverTest.php +++ b/tests/PHPStan/Analyser/LegacyNodeScopeResolverTest.php @@ -2768,7 +2768,7 @@ public static function dataBinaryOperations(): array '[1, 2, 3] + [4, 5, 6]', ], [ - 'non-empty-array', + 'non-empty-array&hasOffsetValue(0, int)&hasOffsetValue(1, int)&hasOffsetValue(2, int)', '$arrayOfUnknownIntegers + [1, 2, 3]', ], [ diff --git a/tests/PHPStan/Analyser/nsrt/bug-13561.php b/tests/PHPStan/Analyser/nsrt/bug-13561.php new file mode 100644 index 0000000000..41a8331bc6 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-13561.php @@ -0,0 +1,37 @@ += 8.0 + +namespace Bug13552; + +use function PHPStan\Testing\assertType; + +interface MyInterface { + public function doThing(): bool; + + /** + * @return array + */ + public function getArray(): array; +} + +function test_addition(MyInterface $i): void { + $x = $i->doThing() ? ['thing' => 'do'] : []; + assertType("array{}|array{thing: 'do'}", $x); + + $x += $i->getArray(); + assertType('array', $x); + + $x = $x ?: ['test' => 'string']; +} + +function more_test(MyInterface $i): void { + $x = $i->doThing() ? ['thing' => 'do', 'always_here' => true] : ['always_here' => 42]; + assertType("array{always_here: 42}|array{thing: 'do', always_here: true}", $x); + + $a = $i->getArray() + $x; + assertType("non-empty-array&hasOffsetValue('always_here', 42|string|true)", $a); + assertType('true', isset($a['always_here'])); + + $b = $x + $i->getArray(); + assertType("non-empty-array&hasOffsetValue('always_here', 42|true)", $b); + assertType('true', isset($b['always_here'])); +} From 5b128c4983515a6f76bd6f850cd0de1ec6c46ff0 Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Sun, 15 Feb 2026 18:46:47 +0100 Subject: [PATCH 2/3] Simplify --- src/Reflection/InitializerExprTypeResolver.php | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/src/Reflection/InitializerExprTypeResolver.php b/src/Reflection/InitializerExprTypeResolver.php index dd0b9f3300..2794147dcc 100644 --- a/src/Reflection/InitializerExprTypeResolver.php +++ b/src/Reflection/InitializerExprTypeResolver.php @@ -1510,11 +1510,7 @@ public function getPlusTypeFromTypes(Expr $left, Expr $right, Type $leftType, Ty // Use the first constant array as a reference to list potential offsets. // We only need to check the first array because we're looking for offsets that exist in ALL arrays. $constantArray = $leftConstantArrays[0]; - foreach ($constantArray->getKeyTypes() as $i => $offsetType) { - if ($constantArray->isOptionalKey($i)) { - continue; - } - + foreach ($constantArray->getKeyTypes() as $offsetType) { if (!$leftType->hasOffsetValueType($offsetType)->yes()) { continue; } @@ -1528,11 +1524,7 @@ public function getPlusTypeFromTypes(Expr $left, Expr $right, Type $leftType, Ty // Use the first constant array as a reference to list potential offsets. // We only need to check the first array because we're looking for offsets that exist in ALL arrays. $constantArray = $rightConstantArrays[0]; - foreach ($constantArray->getKeyTypes() as $i => $offsetType) { - if ($constantArray->isOptionalKey($i)) { - continue; - } - + foreach ($constantArray->getKeyTypes() as $offsetType) { if (!$rightType->hasOffsetValueType($offsetType)->yes()) { continue; } From 71c81d42b430b5cc3a86a7de1006a9d06dc1ff32 Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Sun, 15 Feb 2026 19:16:14 +0100 Subject: [PATCH 3/3] More tests --- tests/PHPStan/Analyser/nsrt/bug-13561.php | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/PHPStan/Analyser/nsrt/bug-13561.php b/tests/PHPStan/Analyser/nsrt/bug-13561.php index 41a8331bc6..80c935ecd4 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-13561.php +++ b/tests/PHPStan/Analyser/nsrt/bug-13561.php @@ -35,3 +35,16 @@ function more_test(MyInterface $i): void { assertType("non-empty-array&hasOffsetValue('always_here', 42|true)", $b); assertType('true', isset($b['always_here'])); } + +/** + * @param array{thing?: 'do', always_here: 42|true} $x + */ +function more_test_2(MyInterface $i, array $x): void { + $a = $i->getArray() + $x; + assertType("non-empty-array&hasOffsetValue('always_here', 42|string|true)", $a); + assertType('true', isset($a['always_here'])); + + $b = $x + $i->getArray(); + assertType("non-empty-array&hasOffsetValue('always_here', 42|true)", $b); + assertType('true', isset($b['always_here'])); +}