From f4b04c906aef7b46ee0e872324498911fdc24550 Mon Sep 17 00:00:00 2001 From: phpstan-bot <79867460+phpstan-bot@users.noreply.github.com> Date: Wed, 20 May 2026 09:00:20 +0000 Subject: [PATCH 1/3] Decompose union offset types in `ConstantArrayType::flipArray()` and `fillKeysArray()` to preserve per-key value precision - In `flipArray()`, when a value type decomposes into multiple constant scalar types (e.g. `'a'|'b'|'c'`), iterate over each scalar individually with `optional=true` instead of passing the full union to `setOffsetValueType`. This prevents the builder's optional-key-replacement logic from overwriting previous values and avoids partial-match degradation to general array types. - Apply the same decomposition in `fillKeysArray()` to fix the analogous partial-match degradation when overlapping union value types are used as keys. - Add `NonEmptyArrayType` wrapping in `ArrayFillKeysFunctionReturnTypeExtension` when the input array is non-empty, mirroring the existing logic in `ArrayFlipFunctionReturnTypeExtension`. --- src/Type/Constant/ConstantArrayType.php | 9 ++-- ...rayFillKeysFunctionReturnTypeExtension.php | 8 +++- tests/PHPStan/Analyser/nsrt/bug-14656.php | 47 +++++++++++++++++++ 3 files changed, 59 insertions(+), 5 deletions(-) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-14656.php diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index 4cde2e2f4a..89a1884ceb 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -1513,9 +1513,9 @@ public function fillKeysArray(Type $valueType): Type return $stringKeyType; } - $builder->setOffsetValueType($stringKeyType, $valueType, $this->isOptionalKey($i)); + $builder->setOffsetValueType($stringKeyType, $valueType, $this->isOptionalKey($i) || count($stringKeyType->getConstantScalarTypes()) > 1); } else { - $builder->setOffsetValueType($keyType, $valueType, $this->isOptionalKey($i)); + $builder->setOffsetValueType($keyType, $valueType, $this->isOptionalKey($i) || count($keyType->getConstantScalarTypes()) > 1); } } @@ -1541,10 +1541,11 @@ public function flipArray(): Type foreach ($this->keyTypes as $i => $keyType) { $valueType = $this->valueTypes[$i]; + $offsetType = $valueType->toArrayKey(); $builder->setOffsetValueType( - $valueType->toArrayKey(), + $offsetType, $keyType, - $this->isOptionalKey($i), + $this->isOptionalKey($i) || count($offsetType->getConstantScalarTypes()) > 1, ); } diff --git a/src/Type/Php/ArrayFillKeysFunctionReturnTypeExtension.php b/src/Type/Php/ArrayFillKeysFunctionReturnTypeExtension.php index c961c45362..7a9474f5c6 100644 --- a/src/Type/Php/ArrayFillKeysFunctionReturnTypeExtension.php +++ b/src/Type/Php/ArrayFillKeysFunctionReturnTypeExtension.php @@ -7,10 +7,12 @@ use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Php\PhpVersion; use PHPStan\Reflection\FunctionReflection; +use PHPStan\Type\Accessory\NonEmptyArrayType; use PHPStan\Type\DynamicFunctionReturnTypeExtension; use PHPStan\Type\NeverType; use PHPStan\Type\NullType; use PHPStan\Type\Type; +use PHPStan\Type\TypeCombinator; use function count; #[AutowiredService] @@ -38,7 +40,11 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection, return $this->phpVersion->arrayFunctionsReturnNullWithNonArray() ? new NullType() : new NeverType(); } - return $keysType->fillKeysArray($scope->getType($args[1]->value)); + $filled = $keysType->fillKeysArray($scope->getType($args[1]->value)); + if ($keysType->isIterableAtLeastOnce()->yes() && $filled->isArray()->yes()) { + return TypeCombinator::intersect($filled, new NonEmptyArrayType()); + } + return $filled; } } diff --git a/tests/PHPStan/Analyser/nsrt/bug-14656.php b/tests/PHPStan/Analyser/nsrt/bug-14656.php new file mode 100644 index 0000000000..85c422c558 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-14656.php @@ -0,0 +1,47 @@ +", array_flip($a)); + } + + /** @param array{0: 'a'|'b', 1: 'c'} $a */ + public function mixedUnionAndConstant(array $a): void + { + assertType("array{a?: 0, b?: 0, c: 1}", array_flip($a)); + } +} + +class ArrayFillKeysUnionValues +{ + /** @param array{0: 'a'|'b', 1: 'b'|'c'} $a */ + public function overlappingUnion(array $a): void + { + assertType("non-empty-array<'a'|'b'|'c', 'x'>", array_fill_keys($a, 'x')); + } + + /** @param array{0: 'a'|'b'|'c', 1: 'a'|'b'|'c', 2: 'a'|'b'|'c'} $a */ + public function allUnion(array $a): void + { + assertType("non-empty-array{a?: 'x', b?: 'x', c?: 'x'}", array_fill_keys($a, 'x')); + } + + /** @param array{0: 'a'|'b', 1: 'c'} $a */ + public function mixedUnionAndConstant(array $a): void + { + assertType("array{a?: 'x', b?: 'x', c: 'x'}", array_fill_keys($a, 'x')); + } +} From f5f920eb2ebac262ed29dd7b01144c960dc0eeba Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Fri, 12 Jun 2026 15:09:11 +0000 Subject: [PATCH 2/3] Update bug-14656 expectations to precise array shapes after unsealed merge The 2.2.x unsealed-array-shapes work makes array_flip()/array_fill_keys() on overlapping union values infer precise constant array shapes instead of degrading to a general array. Update the expectations accordingly: flipping array{0: 'a'|'b', 1: 'b'|'c'} now yields non-empty-array{a?: 0, b?: 0|1, c?: 1} rather than non-empty-array<'a'|'b'|'c', 0|1>. Co-Authored-By: Claude Opus 4.8 --- tests/PHPStan/Analyser/nsrt/bug-14656.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/PHPStan/Analyser/nsrt/bug-14656.php b/tests/PHPStan/Analyser/nsrt/bug-14656.php index 85c422c558..60b4b13d63 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-14656.php +++ b/tests/PHPStan/Analyser/nsrt/bug-14656.php @@ -15,7 +15,7 @@ public function allUnion(array $a): void /** @param array{0: 'a'|'b', 1: 'b'|'c'} $a */ public function overlappingUnion(array $a): void { - assertType("non-empty-array<'a'|'b'|'c', 0|1>", array_flip($a)); + assertType("non-empty-array{a?: 0, b?: 0|1, c?: 1}", array_flip($a)); } /** @param array{0: 'a'|'b', 1: 'c'} $a */ @@ -30,7 +30,7 @@ class ArrayFillKeysUnionValues /** @param array{0: 'a'|'b', 1: 'b'|'c'} $a */ public function overlappingUnion(array $a): void { - assertType("non-empty-array<'a'|'b'|'c', 'x'>", array_fill_keys($a, 'x')); + assertType("non-empty-array{a?: 'x', b?: 'x', c?: 'x'}", array_fill_keys($a, 'x')); } /** @param array{0: 'a'|'b'|'c', 1: 'a'|'b'|'c', 2: 'a'|'b'|'c'} $a */ From 6d06e52e37d0a5ef42c02b1e8723358bfe5c707b Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Fri, 12 Jun 2026 15:09:17 +0000 Subject: [PATCH 3/3] Add unsealed-array variants of array_flip/array_fill_keys union-value tests Mirror the sealed bug-14656 cases with unsealed array shapes (`...` tails) to lock in the per-key value precision across the unsealed extras: a finite union tail decomposes into optional explicit slots merged with the explicit keys, while a non-finite tail (e.g. `string`) survives as unsealed extras with overlapping explicit values widened. Co-Authored-By: Claude Opus 4.8 --- tests/PHPStan/Analyser/nsrt/bug-14656.php | 54 +++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/tests/PHPStan/Analyser/nsrt/bug-14656.php b/tests/PHPStan/Analyser/nsrt/bug-14656.php index 60b4b13d63..0d79bc8f61 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-14656.php +++ b/tests/PHPStan/Analyser/nsrt/bug-14656.php @@ -45,3 +45,57 @@ public function mixedUnionAndConstant(array $a): void assertType("array{a?: 'x', b?: 'x', c: 'x'}", array_fill_keys($a, 'x')); } } + +class ArrayFlipUnsealedUnionValues +{ + /** @param array{0: 'a'|'b'|'c', 1: 'a'|'b'|'c', 2: 'a'|'b'|'c', ...} $a */ + public function allUnion(array $a): void + { + assertType("non-empty-array{a?: int, b?: int, c?: int}", array_flip($a)); + } + + /** @param array{0: 'a'|'b', 1: 'b'|'c', ...} $a */ + public function overlappingUnion(array $a): void + { + assertType("non-empty-array{a?: int, b?: int, c?: int}", array_flip($a)); + } + + /** @param array{0: 'a'|'b', 1: 'c', ...} $a */ + public function mixedUnionAndConstant(array $a): void + { + assertType("array{a?: int, b?: int, c: 1}", array_flip($a)); + } + + /** @param array{0: 'a'|'b', 1: 'c', ...} $a */ + public function nonFiniteTail(array $a): void + { + assertType("array{a?: int, b?: int, c: int, ...}", array_flip($a)); + } +} + +class ArrayFillKeysUnsealedUnionValues +{ + /** @param array{0: 'a'|'b', 1: 'b'|'c', ...} $a */ + public function overlappingUnion(array $a): void + { + assertType("non-empty-array{a?: 'x', b?: 'x', c?: 'x'}", array_fill_keys($a, 'x')); + } + + /** @param array{0: 'a'|'b'|'c', 1: 'a'|'b'|'c', 2: 'a'|'b'|'c', ...} $a */ + public function allUnion(array $a): void + { + assertType("non-empty-array{a?: 'x', b?: 'x', c?: 'x'}", array_fill_keys($a, 'x')); + } + + /** @param array{0: 'a'|'b', 1: 'c', ...} $a */ + public function mixedUnionAndConstant(array $a): void + { + assertType("array{a?: 'x', b?: 'x', c: 'x'}", array_fill_keys($a, 'x')); + } + + /** @param array{0: 'a'|'b', 1: 'c', ...} $a */ + public function nonFiniteTail(array $a): void + { + assertType("array{a?: 'x', b?: 'x', c: 'x', ...}", array_fill_keys($a, 'x')); + } +}