From 3e258a59763fef2aea30208287ee59c40247d76d Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Fri, 17 Apr 2026 18:11:13 +0200 Subject: [PATCH 01/66] Initial implementation of unsealed array shapes Array shapes like `array{a: int}` in PHPDocs are only sealed in Bleeding Edge. Without Bleeding edge, the goal is to match the current flawed behaviour as close as possible. --- src/PhpDoc/TypeNodeResolver.php | 84 +++-- src/Type/Constant/ConstantArrayType.php | 348 ++++++++++++++++-- .../Constant/ConstantArrayTypeBuilder.php | 21 +- .../Generic/TemplateConstantArrayType.php | 9 +- ...nsafe-array-string-key-casting-prevent.php | 38 ++ tests/PHPStan/Analyser/nsrt/bug-12355.php | 10 +- tests/PHPStan/Analyser/nsrt/list-shapes.php | 2 +- .../Analyser/nsrt/unsealed-array-shapes.php | 85 +++++ .../CallToFunctionParametersRuleTest.php | 11 + .../Rules/Functions/ReturnTypeRuleTest.php | 13 + .../Rules/Functions/data/bug-11494.php | 18 + .../Rules/Functions/data/bug-13565.php | 19 + .../Type/Constant/ConstantArrayTypeTest.php | 276 +++++++++++++- tests/PHPStan/Type/TypeToPhpDocNodeTest.php | 11 + 14 files changed, 876 insertions(+), 69 deletions(-) create mode 100644 tests/PHPStan/Analyser/nsrt/unsealed-array-shapes.php create mode 100644 tests/PHPStan/Rules/Functions/data/bug-11494.php create mode 100644 tests/PHPStan/Rules/Functions/data/bug-13565.php diff --git a/src/PhpDoc/TypeNodeResolver.php b/src/PhpDoc/TypeNodeResolver.php index 99e0856fe70..1b841dccec4 100644 --- a/src/PhpDoc/TypeNodeResolver.php +++ b/src/PhpDoc/TypeNodeResolver.php @@ -705,24 +705,7 @@ static function (string $variance): TemplateTypeVariance { if (count($genericTypes) === 1) { // array $arrayType = new ArrayType((new BenevolentUnionType([new IntegerType(), new StringType()]))->toArrayKey(), $genericTypes[0]); } elseif (count($genericTypes) === 2) { // array - $originalKey = $genericTypes[0]; - if ($this->reportUnsafeArrayStringKeyCasting === ReportUnsafeArrayStringKeyCastingToggle::PREVENT) { - $originalKey = TypeTraverser::map($originalKey, static function (Type $type, callable $traverse) { - if ($type instanceof UnionType || $type instanceof IntersectionType) { - return $traverse($type); - } - - if ($type instanceof StringType) { - return TypeCombinator::intersect($type, new AccessoryDecimalIntegerStringType(inverse: true)); - } - - return $type; - }); - } - $keyType = TypeCombinator::intersect($originalKey->toArrayKey(), new UnionType([ - new IntegerType(), - new StringType(), - ]))->toArrayKey(); + $keyType = $this->transformUnsafeArrayKey($genericTypes[0]); $finiteTypes = $keyType->getFiniteTypes(); if ( count($finiteTypes) === 1 @@ -1002,6 +985,28 @@ static function (string $variance): TemplateTypeVariance { return new ErrorType(); } + private function transformUnsafeArrayKey(Type $keyType): Type + { + if ($this->reportUnsafeArrayStringKeyCasting === ReportUnsafeArrayStringKeyCastingToggle::PREVENT) { + $keyType = TypeTraverser::map($keyType, static function (Type $type, callable $traverse) { + if ($type instanceof UnionType || $type instanceof IntersectionType) { + return $traverse($type); + } + + if ($type instanceof StringType) { + return TypeCombinator::intersect($type, new AccessoryDecimalIntegerStringType(inverse: true)); + } + + return $type; + }); + } + + return TypeCombinator::intersect($keyType->toArrayKey(), new UnionType([ + new IntegerType(), + new StringType(), + ]))->toArrayKey(); + } + private function resolveCallableTypeNode(CallableTypeNode $typeNode, NameScope $nameScope): Type { $templateTags = []; @@ -1101,13 +1106,48 @@ private function resolveArrayShapeNode(ArrayShapeNode $typeNode, NameScope $name $builder->setOffsetValueType($offsetType, $this->resolve($itemNode->valueType, $nameScope), $itemNode->optional); } + $isList = in_array($typeNode->kind, [ + ArrayShapeNode::KIND_LIST, + ArrayShapeNode::KIND_NON_EMPTY_LIST, + ], true); + + if (!$typeNode->sealed) { + if ($typeNode->unsealedType === null) { + if ($isList) { + $unsealedKeyType = IntegerRangeType::createAllGreaterThanOrEqualTo(0); + } else { + $unsealedKeyType = (new BenevolentUnionType([new IntegerType(), new StringType()]))->toArrayKey(); + } + $builder->makeUnsealed( + $unsealedKeyType, + new MixedType(), + ); + } else { + if ($typeNode->unsealedType->keyType === null) { + if ($isList) { + $unsealedKeyType = IntegerRangeType::createAllGreaterThanOrEqualTo(0); + } else { + $unsealedKeyType = (new BenevolentUnionType([new IntegerType(), new StringType()]))->toArrayKey(); + } + } else { + $unsealedKeyType = $this->transformUnsafeArrayKey($this->resolve($typeNode->unsealedType->keyType, $nameScope)); + } + $unsealedKeyFiniteTypes = $unsealedKeyType->getFiniteTypes(); + $unsealedValueType = $this->resolve($typeNode->unsealedType->valueType, $nameScope); + if (count($unsealedKeyFiniteTypes) > 0) { + foreach ($unsealedKeyFiniteTypes as $unsealedKeyFiniteType) { + $builder->setOffsetValueType($unsealedKeyFiniteType, $unsealedValueType, true); + } + } else { + $builder->makeUnsealed($unsealedKeyType, $unsealedValueType); + } + } + } + $arrayType = $builder->getArray(); $accessories = []; - if (in_array($typeNode->kind, [ - ArrayShapeNode::KIND_LIST, - ArrayShapeNode::KIND_NON_EMPTY_LIST, - ], true)) { + if ($isList) { $accessories[] = new AccessoryArrayListType(); } diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index 8850f7f45a2..1d717d79d97 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -4,11 +4,13 @@ use Nette\Utils\Strings; use PHPStan\Analyser\OutOfClassScope; +use PHPStan\DependencyInjection\BleedingEdgeToggle; use PHPStan\Php\PhpVersion; use PHPStan\PhpDocParser\Ast\ConstExpr\ConstExprIntegerNode; use PHPStan\PhpDocParser\Ast\ConstExpr\ConstExprStringNode; use PHPStan\PhpDocParser\Ast\Type\ArrayShapeItemNode; use PHPStan\PhpDocParser\Ast\Type\ArrayShapeNode; +use PHPStan\PhpDocParser\Ast\Type\ArrayShapeUnsealedTypeNode; use PHPStan\PhpDocParser\Ast\Type\ConstTypeNode; use PHPStan\PhpDocParser\Ast\Type\IdentifierTypeNode; use PHPStan\PhpDocParser\Ast\Type\TypeNode; @@ -27,15 +29,19 @@ use PHPStan\Type\Accessory\HasOffsetValueType; use PHPStan\Type\Accessory\NonEmptyArrayType; use PHPStan\Type\ArrayType; +use PHPStan\Type\BenevolentUnionType; use PHPStan\Type\BooleanType; use PHPStan\Type\CompoundType; use PHPStan\Type\ConstantScalarType; use PHPStan\Type\ErrorType; use PHPStan\Type\GeneralizePrecision; +use PHPStan\Type\Generic\TemplateMixedType; +use PHPStan\Type\Generic\TemplateStrictMixedType; use PHPStan\Type\Generic\TemplateType; use PHPStan\Type\Generic\TemplateTypeMap; use PHPStan\Type\Generic\TemplateTypeVariance; use PHPStan\Type\IntegerRangeType; +use PHPStan\Type\IntegerType; use PHPStan\Type\IntersectionType; use PHPStan\Type\IsSuperTypeOfResult; use PHPStan\Type\MixedType; @@ -43,6 +49,8 @@ use PHPStan\Type\NullType; use PHPStan\Type\RecursionGuard; use PHPStan\Type\StaticTypeFactory; +use PHPStan\Type\StrictMixedType; +use PHPStan\Type\StringType; use PHPStan\Type\Traits\ArrayTypeTrait; use PHPStan\Type\Traits\NonObjectTypeTrait; use PHPStan\Type\Traits\UndecidedComparisonTypeTrait; @@ -92,6 +100,9 @@ class ConstantArrayType implements Type private TrinaryLogic $isList; + /** @var array{Type, Type}|null */ + private ?array $unsealed; // phpcs:ignore + /** @var self[]|null */ private ?array $allArrays = null; @@ -108,6 +119,7 @@ class ConstantArrayType implements Type * @param array $valueTypes * @param list $nextAutoIndexes * @param int[] $optionalKeys + * @param array{Type, Type}|null $unsealed */ public function __construct( private array $keyTypes, @@ -115,6 +127,7 @@ public function __construct( private array $nextAutoIndexes = [0], private array $optionalKeys = [], ?TrinaryLogic $isList = null, + ?array $unsealed = null, ) { assert(count($keyTypes) === count($valueTypes)); @@ -128,6 +141,44 @@ public function __construct( $isList = TrinaryLogic::createNo(); } $this->isList = $isList; + + if ($unsealed !== null) { + if (in_array($unsealed[0]->describe(VerbosityLevel::value()), ['(int|string)', '(int|non-decimal-int-string)'], true)) { + $unsealed[0] = new MixedType(); + } + if ($unsealed[0] instanceof StrictMixedType && !$unsealed[0] instanceof TemplateStrictMixedType) { + $unsealed[0] = (new UnionType([new StringType(), new IntegerType()]))->toArrayKey(); + } + } elseif (BleedingEdgeToggle::isBleedingEdge()) { + $never = new NeverType(true); + $unsealed = [$never, $never]; + } + $this->unsealed = $unsealed; + } + + public function isSealed(): TrinaryLogic + { + return $this->isUnsealed()->negate(); + } + + public function isUnsealed(): TrinaryLogic + { + $unsealed = $this->unsealed; + if ($unsealed === null) { + return TrinaryLogic::createMaybe(); + } + + [$keyType] = $unsealed; + + return TrinaryLogic::createFromBoolean(!$keyType instanceof NeverType || !$keyType->isExplicit()); + } + + /** + * @return array{Type, Type}|null + */ + public function getUnsealedTypes(): ?array + { + return $this->unsealed; } /** @@ -135,16 +186,18 @@ public function __construct( * @param array $valueTypes * @param list $nextAutoIndexes * @param int[] $optionalKeys + * @param array{Type, Type}|null $unsealed */ protected function recreate( array $keyTypes, array $valueTypes, - array $nextAutoIndexes = [0], - array $optionalKeys = [], - ?TrinaryLogic $isList = null, + array $nextAutoIndexes, + array $optionalKeys, + ?TrinaryLogic $isList, + ?array $unsealed, ): self { - return new self($keyTypes, $valueTypes, $nextAutoIndexes, $optionalKeys, $isList); + return new self($keyTypes, $valueTypes, $nextAutoIndexes, $optionalKeys, $isList, $unsealed); } public function getConstantArrays(): array @@ -185,6 +238,16 @@ public function getIterableKeyType(): Type $keyType = new UnionType($this->keyTypes); } + if ($this->isUnsealed()->yes() && $this->unsealed !== null) { + $unsealedKeyType = $this->unsealed[0]; + if ($unsealedKeyType instanceof MixedType && !$unsealedKeyType instanceof TemplateMixedType) { + $unsealedKeyType = (new BenevolentUnionType([new IntegerType(), new StringType()]))->toArrayKey(); + } elseif ($unsealedKeyType instanceof StrictMixedType && !$unsealedKeyType instanceof TemplateStrictMixedType) { + $unsealedKeyType = (new BenevolentUnionType([new IntegerType(), new StringType()]))->toArrayKey(); + } + $keyType = TypeCombinator::union($keyType, $unsealedKeyType); + } + return $this->iterableKeyType = $keyType; } @@ -194,7 +257,12 @@ public function getIterableValueType(): Type return $this->iterableValueType; } - return $this->iterableValueType = count($this->valueTypes) > 0 ? TypeCombinator::union(...$this->valueTypes) : new NeverType(true); + $valueType = count($this->valueTypes) > 0 ? TypeCombinator::union(...$this->valueTypes) : new NeverType(true); + if ($this->isUnsealed()->yes() && $this->unsealed !== null) { + $valueType = TypeCombinator::union($valueType, $this->unsealed[1]); + } + + return $this->iterableValueType = $valueType; } public function getKeyType(): Type @@ -335,10 +403,173 @@ public function accepts(Type $type, bool $strictTypes): AcceptsResult return $type->isAcceptedBy($this, $strictTypes); } - if ($type instanceof self && count($this->keyTypes) === 0) { - return AcceptsResult::createFromBoolean(count($type->keyTypes) === 0); + $isUnsealed = $this->isUnsealed(); + if (!$isUnsealed->yes()) { + if ($type instanceof self && count($this->keyTypes) === 0) { + return AcceptsResult::createFromBoolean(count($type->keyTypes) === 0); + } + } + + $result = $this->checkOurKeys($type, $strictTypes)->and(new AcceptsResult($type->isArray(), [])); + if ($this->unsealed === null) { + if ($type->isOversizedArray()->yes()) { + if (!$result->no()) { + return AcceptsResult::createYes(); + } + } + + return $result; + } + + [$unsealedKeyType, $unsealedValueType] = $this->unsealed; + + if ($isUnsealed->no()) { + if (!$type->isConstantArray()->yes()) { + return $result->and(AcceptsResult::createNo([ + 'Sealed array shape can only accept a constant array. Extra keys are not allowed.', + ])); + } + + $constantArrays = $type->getConstantArrays(); + if (count($constantArrays) !== 1) { + throw new ShouldNotHappenException('Type with more than one constant array occurred, should have been eliminated with `instanceof CompoundType` above.'); + } + + $keys = []; + foreach ($constantArrays[0]->getKeyTypes() as $otherKeyType) { + $keys[$otherKeyType->getValue()] = $otherKeyType; + } + + foreach ($this->keyTypes as $keyType) { + unset($keys[$keyType->getValue()]); + } + + foreach ($keys as $extraKey) { + $result = $result->and(AcceptsResult::createNo([ + sprintf('Sealed array shape does not accept array with extra key %s.', $extraKey->describe(VerbosityLevel::precise())), + ])); + } + + if (!$constantArrays[0]->isUnsealed()->no()) { + $result = $result->and(AcceptsResult::createNo([ + 'Sealed array shape does not accept unsealed array shape.', + ])); + } + + return $result; + } + + if (!$type->isConstantArray()->yes()) { + return $result->and($unsealedKeyType->accepts($type->getIterableKeyType(), $strictTypes)) + ->and($unsealedValueType->accepts($type->getIterableValueType(), $strictTypes)); + } + + $constantArrays = $type->getConstantArrays(); + if (count($constantArrays) !== 1) { + throw new ShouldNotHappenException('Type with more than one constant array occurred, should have been eliminated with `instanceof CompoundType` above.'); + } + + $keys = []; + $constantArray = $constantArrays[0]; + foreach ($constantArray->getKeyTypes() as $i => $otherKeyType) { + $keys[$otherKeyType->getValue()] = [$i, $otherKeyType]; + } + + foreach ($this->keyTypes as $keyType) { + unset($keys[$keyType->getValue()]); + } + + foreach ($keys as [$i, $extraKeyType]) { + $acceptsKey = $unsealedKeyType->accepts($extraKeyType, $strictTypes)->decorateReasons( + static fn (string $reason) => sprintf( + 'Unsealed array key type %s does not accept extra key type %s: %s', + $unsealedKeyType->describe(VerbosityLevel::value()), + $extraKeyType->describe(VerbosityLevel::value()), + $reason, + ), + ); + if (!$acceptsKey->yes() && count($acceptsKey->reasons) === 0) { + $acceptsKey = new AcceptsResult($acceptsKey->result, [ + sprintf( + 'Unsealed array key type %s does not accept extra key type %s.', + $unsealedKeyType->describe(VerbosityLevel::value()), + $extraKeyType->describe(VerbosityLevel::value()), + ), + ]); + } + $result = $result->and($acceptsKey); + + $extraValueType = $constantArray->getValueTypes()[$i]; + $acceptsValue = $unsealedValueType->accepts($extraValueType, $strictTypes)->decorateReasons( + static fn (string $reason) => sprintf( + 'Unsealed array value type %s does not accept extra offset %s with value type %s: %s', + $unsealedValueType->describe(VerbosityLevel::value()), + $extraKeyType->describe(VerbosityLevel::value()), + $extraValueType->describe(VerbosityLevel::value()), + $reason, + ), + ); + if (!$acceptsValue->yes() && count($acceptsValue->reasons) === 0) { + $acceptsValue = new AcceptsResult($acceptsValue->result, [ + sprintf( + 'Unsealed array value type %s does not accept extra offset %s with value type %s.', + $unsealedValueType->describe(VerbosityLevel::value()), + $extraKeyType->describe(VerbosityLevel::value()), + $extraValueType->describe(VerbosityLevel::value()), + ), + ]); + } + $result = $result->and($acceptsValue); + } + + $otherUnsealed = $constantArray->getUnsealedTypes(); + if ($otherUnsealed !== null && !$constantArray->isUnsealed()->no()) { + [$otherUnsealedKeyType, $otherUnsealedValueType] = $otherUnsealed; + + $acceptsUnsealedKey = $unsealedKeyType->accepts($otherUnsealedKeyType, $strictTypes)->decorateReasons( + static fn (string $reason) => sprintf( + 'Unsealed array key type %s does not accept unsealed array key type %s: %s', + $unsealedKeyType->describe(VerbosityLevel::value()), + $otherUnsealedKeyType->describe(VerbosityLevel::value()), + $reason, + ), + ); + if (!$acceptsUnsealedKey->yes() && count($acceptsUnsealedKey->reasons) === 0) { + $acceptsUnsealedKey = new AcceptsResult($acceptsUnsealedKey->result, [ + sprintf( + 'Unsealed array key type %s does not accept unsealed array key type %s.', + $unsealedKeyType->describe(VerbosityLevel::value()), + $otherUnsealedKeyType->describe(VerbosityLevel::value()), + ), + ]); + } + $result = $result->and($acceptsUnsealedKey); + + $acceptsUnsealedValue = $unsealedValueType->accepts($otherUnsealedValueType, $strictTypes)->decorateReasons( + static fn (string $reason) => sprintf( + 'Unsealed array value type %s does not accept unsealed array value type %s: %s', + $unsealedValueType->describe(VerbosityLevel::value()), + $otherUnsealedValueType->describe(VerbosityLevel::value()), + $reason, + ), + ); + if (!$acceptsUnsealedValue->yes() && count($acceptsUnsealedValue->reasons) === 0) { + $acceptsUnsealedValue = new AcceptsResult($acceptsUnsealedValue->result, [ + sprintf( + 'Unsealed array value type %s does not accept unsealed array value type %s.', + $unsealedValueType->describe(VerbosityLevel::value()), + $otherUnsealedValueType->describe(VerbosityLevel::value()), + ), + ]); + } + $result = $result->and($acceptsUnsealedValue); } + return $result; + } + + private function checkOurKeys(Type $type, bool $strictTypes): AcceptsResult + { $result = AcceptsResult::createYes(); foreach ($this->keyTypes as $i => $keyType) { $valueType = $this->valueTypes[$i]; @@ -385,13 +616,6 @@ public function accepts(Type $type, bool $strictTypes): AcceptsResult $result = $result->and($acceptsValue); } - $result = $result->and(new AcceptsResult($type->isArray(), [])); - if ($type->isOversizedArray()->yes()) { - if (!$result->no()) { - return AcceptsResult::createYes(); - } - } - return $result; } @@ -728,7 +952,7 @@ public function getOffsetValueType(Type $offsetType): Type $matchingValueTypes[] = $this->valueTypes[$i]; } - if ($all) { + if ($all && !$this->isUnsealed()->yes()) { return $this->getIterableValueType(); } @@ -816,7 +1040,7 @@ public function unsetOffset(Type $offsetType, bool $preserveListCertainty = fals return new NeverType(); } - return $this->recreate($newKeyTypes, $newValueTypes, $this->nextAutoIndexes, $newOptionalKeys, $newIsList); + return $this->recreate($newKeyTypes, $newValueTypes, $this->nextAutoIndexes, $newOptionalKeys, $newIsList, $this->unsealed); } return $this; @@ -861,7 +1085,7 @@ public function unsetOffset(Type $offsetType, bool $preserveListCertainty = fals $newIsList = $newIsList->and(TrinaryLogic::createMaybe()); } - return $this->recreate($this->keyTypes, $this->valueTypes, $this->nextAutoIndexes, $optionalKeys, $newIsList); + return $this->recreate($this->keyTypes, $this->valueTypes, $this->nextAutoIndexes, $optionalKeys, $newIsList, $this->unsealed); } $optionalKeys = $this->optionalKeys; @@ -891,7 +1115,7 @@ public function unsetOffset(Type $offsetType, bool $preserveListCertainty = fals return new NeverType(); } - return $this->recreate($this->keyTypes, $this->valueTypes, $this->nextAutoIndexes, $optionalKeys, $newIsList); + return $this->recreate($this->keyTypes, $this->valueTypes, $this->nextAutoIndexes, $optionalKeys, $newIsList, $this->unsealed); } /** @@ -1115,7 +1339,7 @@ public function sliceArray(Type $offsetType, Type $lengthType, TrinaryLogic $pre if ($length === 0 || ($offset < 0 && $length < 0 && $offset - $length >= 0)) { // 0 / 0, 3 / 0 or e.g. -3 / -3 or -3 / -4 and so on never extract anything - return $this->recreate([], []); + return $this->recreate([], [], [0], [], null, [new NeverType(true), new NeverType(true)]); } if ($length < 0) { @@ -1404,11 +1628,16 @@ public function getArraySize(): Type { $optionalKeysCount = count($this->optionalKeys); $totalKeysCount = count($this->getKeyTypes()); - if ($optionalKeysCount === 0) { - return new ConstantIntegerType($totalKeysCount); + if (!$this->isUnsealed()->yes()) { + if ($optionalKeysCount === 0) { + return new ConstantIntegerType($totalKeysCount); + } + $max = $totalKeysCount; + } else { + $max = null; } - return IntegerRangeType::fromInterval($totalKeysCount - $optionalKeysCount, $totalKeysCount); + return IntegerRangeType::fromInterval($totalKeysCount - $optionalKeysCount, $max); } public function getFirstIterableKeyType(): Type @@ -1524,6 +1753,7 @@ private function removeLastElements(int $length): self $nextAutoindexes, array_values($optionalKeys), $this->isList, + $this->unsealed, ); } @@ -1622,7 +1852,7 @@ public function generalizeValues(): self $valueTypes[] = $valueType->generalize(GeneralizePrecision::lessSpecific()); } - return $this->recreate($this->keyTypes, $valueTypes, $this->nextAutoIndexes, $this->optionalKeys, $this->isList); + return $this->recreate($this->keyTypes, $valueTypes, $this->nextAutoIndexes, $this->optionalKeys, $this->isList, $this->unsealed); } private function degradeToGeneralArray(): Type @@ -1670,7 +1900,7 @@ private function getKeysOrValuesArray(array $types): self static fn (int $i): ConstantIntegerType => new ConstantIntegerType($i), array_keys($types), ); - return $this->recreate($keyTypes, $types, $autoIndexes, $this->optionalKeys, TrinaryLogic::createYes()); + return $this->recreate($keyTypes, $types, $autoIndexes, $this->optionalKeys, TrinaryLogic::createYes(), $this->unsealed); // todo unsealed } $keyTypes = []; @@ -1699,7 +1929,7 @@ private function getKeysOrValuesArray(array $types): self $maxIndex++; } - return $this->recreate($keyTypes, $valueTypes, $autoIndexes, $optionalKeys, TrinaryLogic::createYes()); + return $this->recreate($keyTypes, $valueTypes, $autoIndexes, $optionalKeys, TrinaryLogic::createYes(), $this->unsealed); // todo unsealed } public function describe(VerbosityLevel $level): string @@ -1744,6 +1974,23 @@ public function describe(VerbosityLevel $level): string $append = ', ...'; } + if ($this->isUnsealed()->yes() && $this->unsealed !== null) { + if (count($items) > 0) { + $append .= ', '; + } + $append .= '...'; + $keyDescription = $this->unsealed[0]->describe(VerbosityLevel::precise()); + $isMixedKeyType = $this->unsealed[0] instanceof MixedType && $keyDescription === 'mixed' && !$this->unsealed[0]->isExplicitMixed(); + $isMixedItemType = $this->unsealed[1] instanceof MixedType && $this->unsealed[1]->describe(VerbosityLevel::precise()) === 'mixed' && !$this->unsealed[1]->isExplicitMixed(); + if ($isMixedKeyType || ($this->isList()->yes() && $keyDescription === 'int<0, max>')) { + if (!$isMixedItemType) { + $append .= sprintf('<%s>', $this->unsealed[1]->describe($level)); + } + } else { + $append .= sprintf('<%s, %s>', $this->unsealed[0]->describe($level), $this->unsealed[1]->describe($level)); + } + } + return sprintf( '%s{%s%s}', $arrayName, @@ -1864,11 +2111,21 @@ public function traverse(callable $cb): Type $valueTypes[] = $transformedValueType; } + $unsealed = $this->unsealed; + if ($unsealed !== null) { + [$unsealedKeyType, $unsealedValueType] = $unsealed; + $transformedUnsealedValueType = $cb($unsealedValueType); + if ($transformedUnsealedValueType !== $unsealedValueType) { + $stillOriginal = false; + $unsealed = [$unsealedKeyType, $transformedUnsealedValueType]; + } + } + if ($stillOriginal) { return $this; } - return $this->recreate($this->keyTypes, $valueTypes, $this->nextAutoIndexes, $this->optionalKeys, $this->isList); + return $this->recreate($this->keyTypes, $valueTypes, $this->nextAutoIndexes, $this->optionalKeys, $this->isList, $unsealed); } public function traverseSimultaneously(Type $right, callable $cb): Type @@ -1894,7 +2151,7 @@ public function traverseSimultaneously(Type $right, callable $cb): Type return $this; } - return $this->recreate($this->keyTypes, $valueTypes, $this->nextAutoIndexes, $this->optionalKeys, $this->isList); + return $this->recreate($this->keyTypes, $valueTypes, $this->nextAutoIndexes, $this->optionalKeys, $this->isList, $this->unsealed); } public function isKeysSupersetOf(self $otherArray): bool @@ -1951,6 +2208,8 @@ public function isKeysSupersetOf(self $otherArray): bool } } + // todo unsealed + return true; } @@ -1977,7 +2236,7 @@ public function mergeWith(self $otherArray): self $nextAutoIndexes = array_values(array_unique(array_merge($this->nextAutoIndexes, $otherArray->nextAutoIndexes))); sort($nextAutoIndexes); - return $this->recreate($this->keyTypes, $valueTypes, $nextAutoIndexes, $optionalKeys, $this->isList->and($otherArray->isList)); + return $this->recreate($this->keyTypes, $valueTypes, $nextAutoIndexes, $optionalKeys, $this->isList->and($otherArray->isList), $this->unsealed); // todo unsealed } /** @@ -2033,7 +2292,7 @@ public function makeOffsetRequired(Type $offsetType): self } if (count($this->optionalKeys) !== count($optionalKeys)) { - return $this->recreate($this->keyTypes, $this->valueTypes, $this->nextAutoIndexes, array_values($optionalKeys), $this->isList); + return $this->recreate($this->keyTypes, $this->valueTypes, $this->nextAutoIndexes, array_values($optionalKeys), $this->isList, $this->unsealed); } break; @@ -2052,7 +2311,9 @@ public function makeList(): Type return new NeverType(); } - return $this->recreate($this->keyTypes, $this->valueTypes, $this->nextAutoIndexes, $this->optionalKeys, TrinaryLogic::createYes()); + // todo can't be a list if keyTypes are not subsequent integers, or if unsealed type is not int keys + + return $this->recreate($this->keyTypes, $this->valueTypes, $this->nextAutoIndexes, $this->optionalKeys, TrinaryLogic::createYes(), $this->unsealed); } public function makeListMaybe(): Type @@ -2204,6 +2465,33 @@ public function toPhpDocNode(): TypeNode ); } + if ($this->isUnsealed()->yes() && $this->unsealed !== null) { + $unsealedKeyTypeDescription = $this->unsealed[0]->describe(VerbosityLevel::precise()); + $isMixedUnsealedKeyType = $this->unsealed[0] instanceof MixedType && $unsealedKeyTypeDescription === 'mixed' && !$this->unsealed[0]->isExplicitMixed(); + $isMixedUnsealedItemType = $this->unsealed[1] instanceof MixedType && $this->unsealed[1]->describe(VerbosityLevel::precise()) === 'mixed' && !$this->unsealed[1]->isExplicitMixed(); + if ($isMixedUnsealedKeyType || ($this->isList()->yes() && $unsealedKeyTypeDescription === 'int<0, max>')) { + if ($isMixedUnsealedItemType) { + return ArrayShapeNode::createUnsealed( + $exportValuesOnly ? $values : $items, + null, + $this->shouldBeDescribedAsAList() ? ArrayShapeNode::KIND_LIST : ArrayShapeNode::KIND_ARRAY, + ); + } + + return ArrayShapeNode::createUnsealed( + $exportValuesOnly ? $values : $items, + new ArrayShapeUnsealedTypeNode($this->unsealed[1]->toPhpDocNode(), null), + $this->shouldBeDescribedAsAList() ? ArrayShapeNode::KIND_LIST : ArrayShapeNode::KIND_ARRAY, + ); + } + + return ArrayShapeNode::createUnsealed( + $exportValuesOnly ? $values : $items, + new ArrayShapeUnsealedTypeNode($this->unsealed[1]->toPhpDocNode(), $this->unsealed[0]->toPhpDocNode()), + ArrayShapeNode::KIND_ARRAY, + ); + } + return ArrayShapeNode::createSealed( $exportValuesOnly ? $values : $items, $this->shouldBeDescribedAsAList() ? ArrayShapeNode::KIND_LIST : ArrayShapeNode::KIND_ARRAY, diff --git a/src/Type/Constant/ConstantArrayTypeBuilder.php b/src/Type/Constant/ConstantArrayTypeBuilder.php index cd7f5aa0265..100828e52d5 100644 --- a/src/Type/Constant/ConstantArrayTypeBuilder.php +++ b/src/Type/Constant/ConstantArrayTypeBuilder.php @@ -2,6 +2,7 @@ namespace PHPStan\Type\Constant; +use PHPStan\DependencyInjection\BleedingEdgeToggle; use PHPStan\ShouldNotHappenException; use PHPStan\TrinaryLogic; use PHPStan\Type\Accessory\AccessoryArrayListType; @@ -11,6 +12,7 @@ use PHPStan\Type\CallableType; use PHPStan\Type\ClosureType; use PHPStan\Type\IntersectionType; +use PHPStan\Type\NeverType; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; use PHPStan\Type\TypeUtils; @@ -48,6 +50,7 @@ final class ConstantArrayTypeBuilder * @param array $valueTypes * @param list $nextAutoIndexes * @param array $optionalKeys + * @param array{Type, Type}|null $unsealed */ private function __construct( private array $keyTypes, @@ -55,6 +58,7 @@ private function __construct( private array $nextAutoIndexes, private array $optionalKeys, private TrinaryLogic $isList, + private ?array $unsealed, ) { $this->isNonEmpty = TrinaryLogic::createNo(); @@ -62,7 +66,12 @@ private function __construct( public static function createEmpty(): self { - return new self([], [], [0], [], TrinaryLogic::createYes()); + $unsealed = null; + if (BleedingEdgeToggle::isBleedingEdge()) { + $never = new NeverType(true); + $unsealed = [$never, $never]; + } + return new self([], [], [0], [], TrinaryLogic::createYes(), $unsealed); } public static function createFromConstantArray(ConstantArrayType $startArrayType): self @@ -73,6 +82,7 @@ public static function createFromConstantArray(ConstantArrayType $startArrayType $startArrayType->getNextAutoIndexes(), $startArrayType->getOptionalKeys(), $startArrayType->isList(), + $startArrayType->getUnsealedTypes(), ); $builder->isNonEmpty = $startArrayType->isIterableAtLeastOnce(); @@ -83,6 +93,11 @@ public static function createFromConstantArray(ConstantArrayType $startArrayType return $builder; } + public function makeUnsealed(Type $keyType, Type $valueType): void + { + $this->unsealed = [$keyType, $valueType]; + } + public function setOffsetValueType(?Type $offsetType, Type $valueType, bool $optional = false): void { if ($offsetType !== null) { @@ -386,13 +401,13 @@ public function getArray(): Type { $keyTypesCount = count($this->keyTypes); if ($keyTypesCount === 0) { - return new ConstantArrayType([], []); + return new ConstantArrayType([], [], unsealed: $this->unsealed); } if (!$this->degradeToGeneralArray) { /** @var list $keyTypes */ $keyTypes = $this->keyTypes; - $array = new ConstantArrayType($keyTypes, $this->valueTypes, $this->nextAutoIndexes, $this->optionalKeys, $this->isList); + $array = new ConstantArrayType($keyTypes, $this->valueTypes, $this->nextAutoIndexes, $this->optionalKeys, $this->isList, , $this->unsealed); if ($this->isNonEmpty->yes() && !$array->isIterableAtLeastOnce()->yes()) { return TypeCombinator::intersect($array, new NonEmptyArrayType()); } diff --git a/src/Type/Generic/TemplateConstantArrayType.php b/src/Type/Generic/TemplateConstantArrayType.php index afb9ca61c03..dca27867fe4 100644 --- a/src/Type/Generic/TemplateConstantArrayType.php +++ b/src/Type/Generic/TemplateConstantArrayType.php @@ -39,9 +39,10 @@ public function __construct( protected function recreate( array $keyTypes, array $valueTypes, - array $nextAutoIndexes = [0], - array $optionalKeys = [], - ?TrinaryLogic $isList = null, + array $nextAutoIndexes, + array $optionalKeys, + ?TrinaryLogic $isList, + ?array $unsealed, ): ConstantArrayType { return new self( @@ -49,7 +50,7 @@ protected function recreate( $this->strategy, $this->variance, $this->name, - new ConstantArrayType($keyTypes, $valueTypes, $nextAutoIndexes, $optionalKeys, $isList), + new ConstantArrayType($keyTypes, $valueTypes, $nextAutoIndexes, $optionalKeys, $isList, $unsealed), $this->default, ); } diff --git a/tests/PHPStan/Analyser/data/report-unsafe-array-string-key-casting-prevent.php b/tests/PHPStan/Analyser/data/report-unsafe-array-string-key-casting-prevent.php index 163a996bd25..89e1be359b1 100644 --- a/tests/PHPStan/Analyser/data/report-unsafe-array-string-key-casting-prevent.php +++ b/tests/PHPStan/Analyser/data/report-unsafe-array-string-key-casting-prevent.php @@ -89,3 +89,41 @@ public function doArrayCreationAndAssign(string $s): void } } + +class Unsealed +{ + + /** + * @param array{a: int, ...} $a + */ + public function doFoo(array $a): void + { + assertType('array{a: int, ...}', $a); + foreach ($a as $k => $v) { + assertType('non-decimal-int-string', $k); + } + } + + /** + * @param array{a: int, ...} $a + */ + public function doBar(array $a): void + { + assertType('array{a: int, ...}', $a); + foreach ($a as $k => $v) { + assertType('(int|non-decimal-int-string)', $k); + } + } + + /** + * @param array{a: int, ...} $a + */ + public function doBaz(array $a): void + { + assertType('array{a: int, ...}', $a); + foreach ($a as $k => $v) { + assertType('int|non-decimal-int-string', $k); + } + } + +} diff --git a/tests/PHPStan/Analyser/nsrt/bug-12355.php b/tests/PHPStan/Analyser/nsrt/bug-12355.php index 4b7ee866cdc..ed67cce3e12 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-12355.php +++ b/tests/PHPStan/Analyser/nsrt/bug-12355.php @@ -20,11 +20,11 @@ abstract class Animal * @param AnimalData $arg */ public function __construct(array $arg) { - assertType('ValidType of array{name: string} (class Bug12355\Animal, argument)', $arg['animalSpecificData']); + assertType('ValidType of array{name: string, ...} (class Bug12355\Animal, argument)', $arg['animalSpecificData']); if (isset($arg['habitat'])) { //do things } - assertType('ValidType of array{name: string} (class Bug12355\Animal, argument)', $arg['animalSpecificData']); + assertType('ValidType of array{name: string, ...} (class Bug12355\Animal, argument)', $arg['animalSpecificData']); } } @@ -34,7 +34,7 @@ public function __construct(array $arg) { */ function testMergeWithDifferentObjects(array $arg): void { - assertType('T of array{name: string} (function Bug12355\testMergeWithDifferentObjects(), argument)', $arg['first']); + assertType('T of array{name: string, ...} (function Bug12355\testMergeWithDifferentObjects(), argument)', $arg['first']); // Modifying $arg in one branch causes different ConstantArrayType objects if (isset($arg['flag'])) { @@ -43,6 +43,6 @@ function testMergeWithDifferentObjects(array $arg): void // After scope merge, $arg's value types for 'first' and 'second' go through // ConstantArrayType::mergeWith() which uses new self() — stripping TemplateConstantArrayType - assertType('T of array{name: string} (function Bug12355\testMergeWithDifferentObjects(), argument)', $arg['first']); - assertType('T of array{name: string} (function Bug12355\testMergeWithDifferentObjects(), argument)', $arg['second']); + assertType('T of array{name: string, ...} (function Bug12355\testMergeWithDifferentObjects(), argument)', $arg['first']); + assertType('T of array{name: string, ...} (function Bug12355\testMergeWithDifferentObjects(), argument)', $arg['second']); } diff --git a/tests/PHPStan/Analyser/nsrt/list-shapes.php b/tests/PHPStan/Analyser/nsrt/list-shapes.php index 62313ca8e77..8ea8b4c9cea 100644 --- a/tests/PHPStan/Analyser/nsrt/list-shapes.php +++ b/tests/PHPStan/Analyser/nsrt/list-shapes.php @@ -21,6 +21,6 @@ public function bar($l1, $l2, $l3, $l4, $l5, $l6): void assertType("array{'a'}", $l3); assertType("array{'a', 'b'}", $l4); assertType("array{0: 'a', 1?: 'b'}", $l5); - assertType("array{'a', 'b'}", $l6); + assertType("array{'a', 'b', ...}", $l6); } } diff --git a/tests/PHPStan/Analyser/nsrt/unsealed-array-shapes.php b/tests/PHPStan/Analyser/nsrt/unsealed-array-shapes.php new file mode 100644 index 00000000000..f80da767b68 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/unsealed-array-shapes.php @@ -0,0 +1,85 @@ +} $b + * @param array{a: int, ...} $c + * @param list{int, string, ...} $d + * @param list{int, string, 2?: string, 3?: string, ...} $e + * @param list{int, string, ...} $f + * @param list{int, string, 2?: string, 3?: string, ...} $g + */ + public function doFoo(array $a, array $b, array $c, array $d, array $e, array $f, array $g): void + { + assertType('array{a: int, ...}', $a); + foreach ($a as $k => $v) { + assertType('(int|string)', $k); + assertType('mixed', $v); + } + + assertType('array{a: int, ...}', $b); + foreach ($b as $k => $v) { + assertType('string', $k); + assertType('float|int', $v); + } + assertType('array{a: int, ...}', $c); + foreach ($c as $k => $v) { + assertType('(int|string)', $k); + assertType('float|int', $v); + } + + assertType('array{int, string, ...}', $d); + foreach ($d as $k => $v) { + assertType('int<0, max>', $k); + assertType('float|int|string', $v); + } + + assertType('list{0: int, 1: string, 2?: string, 3?: string, ...}', $e); + foreach ($e as $k => $v) { + assertType('int<0, max>', $k); + assertType('float|int|string', $v); + } + + assertType('array{int, string, ...}', $f); + foreach ($f as $k => $v) { + assertType('int<0, max>', $k); + assertType('mixed', $v); + } + + assertType('list{0: int, 1: string, 2?: string, 3?: string, ...}', $g); + foreach ($e as $k => $v) { + assertType('int<0, max>', $k); + assertType('float|int|string', $v); + } + } + + /** + * @param array{a: int, ...} $a + * @return void + */ + public function wrongKeyButResolvedToIntString(array $a): void + { + assertType('array{a: int, ...}', $a); + } + + /** + * @param array{...} $a + * @param array{a: int, ...<'b'|'c', string>} $b + * @param array{a: int, b: float, ...<'b'|'c', string>} $c + */ + public function edgeCases(array $a, array $b, array $c): void + { + assertType('array{...}', $a); + assertType('array{a: int, b?: string, c?: string}', $b); + assertType('array{a: int, b: float|string, c?: string}', $c); + } + +} diff --git a/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php b/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php index f5b3ad7b2f0..11c72db5273 100644 --- a/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php +++ b/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php @@ -2961,4 +2961,15 @@ public function testBug11894(): void $this->analyse([__DIR__ . '/data/bug-11894.php'], []); } + public function testBug11494(): void + { + $this->analyse([__DIR__ . '/data/bug-11494.php'], [ + [ + 'Parameter #1 $a of function Bug11494\test expects array{long: string, details: string}|array{short: string}, array{short: \'thing\', extra: \'other\'} given.', + 18, + "• Type #1 from the union: Sealed array shape does not accept array with extra key 'extra'.\n• Type #2 from the union: Sealed array shape does not accept array with extra key 'extra'." + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Functions/ReturnTypeRuleTest.php b/tests/PHPStan/Rules/Functions/ReturnTypeRuleTest.php index 272fc1a39e9..da3ba5d6d56 100644 --- a/tests/PHPStan/Rules/Functions/ReturnTypeRuleTest.php +++ b/tests/PHPStan/Rules/Functions/ReturnTypeRuleTest.php @@ -438,4 +438,17 @@ public function testBug14428(): void $this->analyse([__DIR__ . '/../../Analyser/nsrt/bug-14428.php'], []); } + public function testBug13565(): void + { + $this->checkNullables = true; + $this->checkExplicitMixed = true; + $this->analyse([__DIR__ . '/data/bug-13565.php'], [ + [ + 'Function Bug13565\x() should return array{name: string} but returns array{name: \'string\', email: Bug13565\NotAString}.', + 11, + 'Sealed array shape does not accept array with extra key \'email\'.', + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Functions/data/bug-11494.php b/tests/PHPStan/Rules/Functions/data/bug-11494.php new file mode 100644 index 00000000000..61f276b3f95 --- /dev/null +++ b/tests/PHPStan/Rules/Functions/data/bug-11494.php @@ -0,0 +1,18 @@ + 'thing', 'extra' => 'other']); diff --git a/tests/PHPStan/Rules/Functions/data/bug-13565.php b/tests/PHPStan/Rules/Functions/data/bug-13565.php new file mode 100644 index 00000000000..04270b99975 --- /dev/null +++ b/tests/PHPStan/Rules/Functions/data/bug-13565.php @@ -0,0 +1,19 @@ + 'string', 'email' => new NotAString()]; +} + +/** + * @return array{name: string, email?: string} + */ +function y(): array { return x(); } + +function send_mail(string $val): void { echo "sending mail to $val"; } diff --git a/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php b/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php index 6d6c41af1cc..f061882c1e7 100644 --- a/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php +++ b/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php @@ -3,6 +3,7 @@ namespace PHPStan\Type\Constant; use Closure; +use PHPStan\DependencyInjection\BleedingEdgeToggle; use PHPStan\Testing\PHPStanTestCase; use PHPStan\TrinaryLogic; use PHPStan\Type\Accessory\HasOffsetType; @@ -13,6 +14,7 @@ use PHPStan\Type\Generic\TemplateTypeFactory; use PHPStan\Type\Generic\TemplateTypeScope; use PHPStan\Type\Generic\TemplateTypeVariance; +use PHPStan\Type\IntegerRangeType; use PHPStan\Type\IntegerType; use PHPStan\Type\IntersectionType; use PHPStan\Type\IterableType; @@ -26,6 +28,7 @@ use PHPStan\Type\UnionType; use PHPStan\Type\VerbosityLevel; use PHPUnit\Framework\Attributes\DataProvider; +use stdClass; use function array_map; use function sprintf; @@ -409,6 +412,9 @@ public static function dataAccepts(): iterable TrinaryLogic::createMaybe(), ]; + $bleedingEdgeBackup = BleedingEdgeToggle::isBleedingEdge(); + BleedingEdgeToggle::setBleedingEdge(false); + yield [ new ConstantArrayType([], []), new ConstantArrayType([], []), @@ -420,6 +426,7 @@ public static function dataAccepts(): iterable new ConstantArrayType([], []), new ConstantArrayType([new ConstantStringType('a')], [new StringType()]), TrinaryLogic::createNo(), + [], ]; // non-empty array (with unknown sealedness) accepts extra keys @@ -433,18 +440,184 @@ public static function dataAccepts(): iterable new IntegerType(), ]), TrinaryLogic::createYes(), + [], + ]; + + BleedingEdgeToggle::setBleedingEdge(true); + + // empty array (sealed) does not accept extra keys + yield [ + new ConstantArrayType([], []), + new ConstantArrayType([new ConstantStringType('a')], [new StringType()]), + TrinaryLogic::createNo(), + [], + ]; + + // non-empty array (sealed) does not accept extra keys + yield [ + new ConstantArrayType([new ConstantStringType('a')], [new StringType()]), + new ConstantArrayType([ + new ConstantStringType('a'), + new ConstantStringType('b'), + ], [ + new StringType(), + new IntegerType(), + ]), + TrinaryLogic::createNo(), + ['Sealed array shape does not accept array with extra key \'b\'.'], + ]; + + // sealed array does not accept general array + yield [ + new ConstantArrayType([new ConstantStringType('a')], [new StringType()]), + new ArrayType(new StringType(), new StringType()), + TrinaryLogic::createNo(), + ['Sealed array shape can only accept a constant array. Extra keys are not allowed.'], + ]; + + // sealed array does not accept unsealed array + yield [ + new ConstantArrayType([new ConstantStringType('a')], [new StringType()]), + new ConstantArrayType([new ConstantStringType('a')], [new StringType()], unsealed: [new StringType(), new ObjectType(stdClass::class)]), + TrinaryLogic::createNo(), + ['Sealed array shape does not accept unsealed array shape.'], + ]; + + // unsealed array accepts compatible general array + yield [ + new ConstantArrayType([new ConstantStringType('a')], [new StringType()], unsealed: [new StringType(), new StringType()]), + new IntersectionType([ + new ArrayType(new StringType(), new StringType()), + new HasOffsetValueType(new ConstantStringType('a'), new StringType()), + ]), + TrinaryLogic::createYes(), + [], + ]; + + // unsealed array does not accept incompatible general array (the error is in the keys already) + yield [ + new ConstantArrayType([new ConstantStringType('a')], [new IntegerType()], unsealed: [new StringType(), new StringType()]), + new IntersectionType([ + new ArrayType(new StringType(), new StringType()), + new HasOffsetValueType(new ConstantStringType('a'), new StringType()), + ]), + TrinaryLogic::createNo(), + [], + ]; + + // unsealed array does not accept incompatible general array (integer vs. string unsealed values) + yield [ + new ConstantArrayType([new ConstantStringType('a')], [new StringType()], unsealed: [new StringType(), new IntegerType()]), + new IntersectionType([ + new ArrayType(new StringType(), new StringType()), + new HasOffsetValueType(new ConstantStringType('a'), new StringType()), + ]), + TrinaryLogic::createNo(), + [], + ]; + + // unsealed array must check extra keys against its own unsealed types + yield [ + new ConstantArrayType([new ConstantStringType('a')], [new StringType()], unsealed: [new StringType(), new StringType()]), + new ConstantArrayType([ + new ConstantStringType('a'), + new ConstantStringType('b'), + ], [ + new StringType(), + new StringType(), + ]), + TrinaryLogic::createYes(), + [], + ]; + + yield [ + new ConstantArrayType([new ConstantStringType('a')], [new StringType()], unsealed: [new IntegerType(), new StringType()]), + new ConstantArrayType([ + new ConstantStringType('a'), + new ConstantIntegerType(10), + ], [ + new StringType(), + new StringType(), + ]), + TrinaryLogic::createYes(), + [], + ]; + + yield [ + new ConstantArrayType([new ConstantStringType('a')], [new StringType()], unsealed: [new IntegerType(), new StringType()]), + new ConstantArrayType([ + new ConstantStringType('a'), + new ConstantStringType('b'), + ], [ + new StringType(), + new StringType(), + ]), + TrinaryLogic::createNo(), + [ + 'Unsealed array key type int does not accept extra key type \'b\'.', + ], + ]; + + yield [ + new ConstantArrayType([new ConstantStringType('a')], [new StringType()], unsealed: [new StringType(), new IntegerType()]), + new ConstantArrayType([ + new ConstantStringType('a'), + new ConstantStringType('b'), + ], [ + new StringType(), + new StringType(), + ]), + TrinaryLogic::createNo(), + [ + 'Unsealed array value type int does not accept extra offset \'b\' with value type string.', + ], + ]; + + // unsealed array must check the other array unsealed types + yield [ + new ConstantArrayType([new ConstantStringType('a')], [new StringType()], unsealed: [new StringType(), new StringType()]), + new ConstantArrayType([new ConstantStringType('a')], [new StringType()], unsealed: [new StringType(), new StringType()]), + TrinaryLogic::createYes(), + [], + ]; + + yield [ + new ConstantArrayType([new ConstantStringType('a')], [new StringType()], unsealed: [new StringType(), new StringType()]), + new ConstantArrayType([new ConstantStringType('a')], [new StringType()], unsealed: [new IntegerType(), new StringType()]), + TrinaryLogic::createNo(), + [ + 'Unsealed array key type string does not accept unsealed array key type int.', + ], + ]; + + yield [ + new ConstantArrayType([new ConstantStringType('a')], [new StringType()], unsealed: [new StringType(), new StringType()]), + new ConstantArrayType([new ConstantStringType('a')], [new StringType()], unsealed: [new StringType(), new IntegerType()]), + TrinaryLogic::createNo(), + [ + 'Unsealed array value type string does not accept unsealed array value type int.', + ], ]; + + BleedingEdgeToggle::setBleedingEdge($bleedingEdgeBackup); } + /** + * @param array|null $reasons + */ #[DataProvider('dataAccepts')] - public function testAccepts(Type $type, Type $otherType, TrinaryLogic $expectedResult): void + public function testAccepts(Type $type, Type $otherType, TrinaryLogic $expectedResult, ?array $reasons = null): void { - $actualResult = $type->accepts($otherType, true)->result; + $actualResult = $type->accepts($otherType, true); + $testDescription = sprintf('%s -> accepts(%s)', $type->describe(VerbosityLevel::precise()), $otherType->describe(VerbosityLevel::precise())); $this->assertSame( $expectedResult->describe(), - $actualResult->describe(), - sprintf('%s -> accepts(%s)', $type->describe(VerbosityLevel::precise()), $otherType->describe(VerbosityLevel::precise())), + $actualResult->result->describe(), + $testDescription, ); + if ($reasons !== null) { + $this->assertSame($reasons, $actualResult->reasons, $testDescription); + } } public static function dataIsSuperTypeOf(): iterable @@ -1116,4 +1289,99 @@ public function testHasOffsetValueType( ); } + public function testSealedness(): void + { + $bleedingEdgeBackup = BleedingEdgeToggle::isBleedingEdge(); + + BleedingEdgeToggle::setBleedingEdge(false); + + try { + $builder = ConstantArrayTypeBuilder::createEmpty(); + $array = $builder->getArray(); + $this->assertInstanceOf(ConstantArrayType::class, $array); + $this->assertSame(TrinaryLogic::createMaybe()->describe(), $array->isSealed()->describe()); + $this->assertSame(TrinaryLogic::createMaybe()->describe(), $array->isUnsealed()->describe()); + + BleedingEdgeToggle::setBleedingEdge(true); + $builder = ConstantArrayTypeBuilder::createEmpty(); + $array = $builder->getArray(); + $this->assertInstanceOf(ConstantArrayType::class, $array); + $this->assertSame(TrinaryLogic::createYes()->describe(), $array->isSealed()->describe()); + $this->assertSame(TrinaryLogic::createNo()->describe(), $array->isUnsealed()->describe()); + + $builder = ConstantArrayTypeBuilder::createEmpty(); + $builder->makeUnsealed(new IntegerType(), new StringType()); + $array = $builder->getArray(); + $this->assertInstanceOf(ConstantArrayType::class, $array); + $this->assertSame(TrinaryLogic::createNo()->describe(), $array->isSealed()->describe()); + $this->assertSame(TrinaryLogic::createYes()->describe(), $array->isUnsealed()->describe()); + } finally { + BleedingEdgeToggle::setBleedingEdge($bleedingEdgeBackup); + } + } + + public static function dataGetArraySize(): iterable + { + $bleedingEdgeBackup = BleedingEdgeToggle::isBleedingEdge(); + + foreach ([false, true] as $bleedingEdge) { + BleedingEdgeToggle::setBleedingEdge($bleedingEdge); + + yield [ + new ConstantArrayType([], []), + new ConstantIntegerType(0), + ]; + + $builder = ConstantArrayTypeBuilder::createEmpty(); + yield [ + $builder->getArray(), + new ConstantIntegerType(0), + ]; + + $builder->makeUnsealed(new IntegerType(), new ObjectType(stdClass::class)); + yield [ + $builder->getArray(), + IntegerRangeType::createAllGreaterThanOrEqualTo(0), + ]; + + $builder->setOffsetValueType(new ConstantIntegerType(0), new ObjectType(stdClass::class)); + yield [ + $builder->getArray(), + IntegerRangeType::createAllGreaterThanOrEqualTo(1), + ]; + + $builder->setOffsetValueType(new ConstantIntegerType(1), new ObjectType(stdClass::class), true); + yield [ + $builder->getArray(), + IntegerRangeType::createAllGreaterThanOrEqualTo(1), + ]; + } + + $builder = ConstantArrayTypeBuilder::createEmpty(); + $builder->makeUnsealed(new IntegerType(), new ObjectType(stdClass::class)); + yield [ + $builder->getArray(), + IntegerRangeType::createAllGreaterThanOrEqualTo(0), + ]; + $builder->setOffsetValueType(new ConstantIntegerType(0), new ObjectType(stdClass::class)); + yield [ + $builder->getArray(), + IntegerRangeType::createAllGreaterThanOrEqualTo(1), + ]; + + $builder->setOffsetValueType(new ConstantIntegerType(1), new ObjectType(stdClass::class), true); + yield [ + $builder->getArray(), + IntegerRangeType::createAllGreaterThanOrEqualTo(1), + ]; + + BleedingEdgeToggle::setBleedingEdge($bleedingEdgeBackup); + } + + #[DataProvider('dataGetArraySize')] + public function testGetArraySize(Type $constantArray, Type $expectedSize): void + { + $this->assertSame($expectedSize->describe(VerbosityLevel::precise()), $constantArray->getArraySize()->describe(VerbosityLevel::precise())); + } + } diff --git a/tests/PHPStan/Type/TypeToPhpDocNodeTest.php b/tests/PHPStan/Type/TypeToPhpDocNodeTest.php index fcc6c6d0cf9..41ce52e2e4d 100644 --- a/tests/PHPStan/Type/TypeToPhpDocNodeTest.php +++ b/tests/PHPStan/Type/TypeToPhpDocNodeTest.php @@ -562,6 +562,17 @@ public static function dataFromTypeStringToPhpDocNode(): iterable yield ['callable(Foo $foo=, Bar $bar=): Bar']; yield ['Closure(Foo $foo=, Bar $bar=): Bar']; yield ['Closure(Foo $foo=, Bar $bar=): (Closure(Foo): Bar)']; + + yield ['array{a: int}']; + yield ['array{a: int, ...}']; + yield ['array{a: int, ...}']; + yield ['array{a: int, ...}']; + yield ['array{int, int, int, ...}']; + yield ['array{int, int, int, ...}']; + yield ['array{int, int, int, ...}']; + + yield ['list{0?: int, 1?: int, 2?: int, ...}']; + yield ['list{0?: int, 1?: int, 2?: int, ...}']; } #[DataProvider('dataFromTypeStringToPhpDocNode')] From f4a2f61af0dc0766ff31231fd5a4852ead19f5b3 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 22 Apr 2026 10:03:54 +0200 Subject: [PATCH 02/66] So intersecting of constant arrays works --- tests/PHPStan/Type/TypeCombinatorTest.php | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/tests/PHPStan/Type/TypeCombinatorTest.php b/tests/PHPStan/Type/TypeCombinatorTest.php index 1652e75bbb9..90b6127222b 100644 --- a/tests/PHPStan/Type/TypeCombinatorTest.php +++ b/tests/PHPStan/Type/TypeCombinatorTest.php @@ -5119,6 +5119,27 @@ public static function dataIntersect(): iterable ConstantArrayType::class, 'array{0|1|2|3, stdClass}', ]; + + yield [ + [ + new ConstantArrayType( + [new ConstantIntegerType(0), new ConstantIntegerType(1)], + [new IntegerType(), new UnionType([ + new ConstantStringType('0'), + new ConstantStringType('foo'), + ])] + ), + new ConstantArrayType( + [new ConstantIntegerType(0), new ConstantIntegerType(1)], + [IntegerRangeType::createAllGreaterThanOrEqualTo(0), new IntersectionType([ + new StringType(), + new AccessoryNonFalsyStringType(), + ])], + ), + ], + ConstantArrayType::class, + "array{int<0, max>, 'foo'}", + ]; } /** From 183febb67954ae654b3e1529063ebfeb1664c532 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 22 Apr 2026 10:11:36 +0200 Subject: [PATCH 03/66] Dedup code --- src/Type/TypeCombinator.php | 45 +++++++++++++++---------------------- 1 file changed, 18 insertions(+), 27 deletions(-) diff --git a/src/Type/TypeCombinator.php b/src/Type/TypeCombinator.php index 9744491218f..eb37cc0708c 100644 --- a/src/Type/TypeCombinator.php +++ b/src/Type/TypeCombinator.php @@ -1666,42 +1666,33 @@ public static function intersect(Type ...$types): Type continue 2; } - if ($types[$i] instanceof ConstantArrayType && ($types[$j] instanceof ArrayType || $types[$j] instanceof ConstantArrayType)) { + $constArrayIsI = $types[$i] instanceof ConstantArrayType && ($types[$j] instanceof ArrayType || $types[$j] instanceof ConstantArrayType); + $constArrayIsJ = $types[$j] instanceof ConstantArrayType && ($types[$i] instanceof ArrayType || $types[$i] instanceof ConstantArrayType); + if ($constArrayIsI || $constArrayIsJ) { + $constArray = $constArrayIsI ? $types[$i] : $types[$j]; + $otherArray = $constArrayIsI ? $types[$j] : $types[$i]; + $newArray = ConstantArrayTypeBuilder::createEmpty(); - $valueTypes = $types[$i]->getValueTypes(); - foreach ($types[$i]->getKeyTypes() as $k => $keyType) { - $hasOffset = $types[$j]->hasOffsetValueType($keyType); + $valueTypes = $constArray->getValueTypes(); + foreach ($constArray->getKeyTypes() as $k => $keyType) { + $hasOffset = $otherArray->hasOffsetValueType($keyType); if ($hasOffset->no()) { continue; } $newArray->setOffsetValueType( - self::intersect($keyType, $types[$j]->getIterableKeyType()), - self::intersect($valueTypes[$k], $types[$j]->getOffsetValueType($keyType)), - $types[$i]->isOptionalKey($k) && !$hasOffset->yes(), + self::intersect($keyType, $otherArray->getIterableKeyType()), + self::intersect($valueTypes[$k], $otherArray->getOffsetValueType($keyType)), + $constArray->isOptionalKey($k) && !$hasOffset->yes(), ); } - $types[$i] = $newArray->getArray(); - array_splice($types, $j--, 1); - $typesCount--; - continue 2; - } - if ($types[$j] instanceof ConstantArrayType && ($types[$i] instanceof ArrayType || $types[$i] instanceof ConstantArrayType)) { - $newArray = ConstantArrayTypeBuilder::createEmpty(); - $valueTypes = $types[$j]->getValueTypes(); - foreach ($types[$j]->getKeyTypes() as $k => $keyType) { - $hasOffset = $types[$i]->hasOffsetValueType($keyType); - if ($hasOffset->no()) { - continue; - } - $newArray->setOffsetValueType( - self::intersect($keyType, $types[$i]->getIterableKeyType()), - self::intersect($valueTypes[$k], $types[$i]->getOffsetValueType($keyType)), - $types[$j]->isOptionalKey($k) && !$hasOffset->yes(), - ); + if ($constArrayIsI) { + $types[$i] = $newArray->getArray(); + array_splice($types, $j--, 1); + } else { + $types[$j] = $newArray->getArray(); + array_splice($types, $i--, 1); } - $types[$j] = $newArray->getArray(); - array_splice($types, $i--, 1); $typesCount--; continue 2; } From b2c4d834f46b7f06472cb454ad09b210a38264cd Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 22 Apr 2026 10:42:23 +0200 Subject: [PATCH 04/66] intersecting improvement --- src/Type/Constant/ConstantArrayType.php | 30 +++ src/Type/TypeCombinator.php | 154 ++++++++++- tests/PHPStan/Type/TypeCombinatorTest.php | 295 ++++++++++++++++++---- 3 files changed, 418 insertions(+), 61 deletions(-) diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index 1d717d79d97..dbc1506cf6c 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -397,6 +397,36 @@ public function isOptionalKey(int $i): bool return in_array($i, $this->optionalKeys, true); } + public function sortKeys(): self + { + $indices = array_keys($this->keyTypes); + usort($indices, fn (int $a, int $b): int => $this->keyTypes[$a]->getValue() <=> $this->keyTypes[$b]->getValue()); + + $newKeyTypes = []; + $newValueTypes = []; + $indexMap = []; + foreach ($indices as $newIdx => $oldIdx) { + $newKeyTypes[] = $this->keyTypes[$oldIdx]; + $newValueTypes[] = $this->valueTypes[$oldIdx]; + $indexMap[$oldIdx] = $newIdx; + } + + $newOptionalKeys = []; + foreach ($this->optionalKeys as $oldIdx) { + $newOptionalKeys[] = $indexMap[$oldIdx]; + } + sort($newOptionalKeys); + + return $this->recreate( + $newKeyTypes, + $newValueTypes, + $this->nextAutoIndexes, + $newOptionalKeys, + $this->isList, + $this->unsealed, + ); + } + public function accepts(Type $type, bool $strictTypes): AcceptsResult { if ($type instanceof CompoundType && !$type instanceof IntersectionType) { diff --git a/src/Type/TypeCombinator.php b/src/Type/TypeCombinator.php index eb37cc0708c..e2df506d9e5 100644 --- a/src/Type/TypeCombinator.php +++ b/src/Type/TypeCombinator.php @@ -1672,25 +1672,38 @@ public static function intersect(Type ...$types): Type $constArray = $constArrayIsI ? $types[$i] : $types[$j]; $otherArray = $constArrayIsI ? $types[$j] : $types[$i]; - $newArray = ConstantArrayTypeBuilder::createEmpty(); - $valueTypes = $constArray->getValueTypes(); - foreach ($constArray->getKeyTypes() as $k => $keyType) { - $hasOffset = $otherArray->hasOffsetValueType($keyType); - if ($hasOffset->no()) { - continue; + if ( + $otherArray instanceof ConstantArrayType + && !$constArray->isUnsealed()->maybe() + && !$otherArray->isUnsealed()->maybe() + ) { + $merged = self::intersectDefiniteConstantArrays($constArray, $otherArray); + if ($merged instanceof NeverType) { + return $merged; } - $newArray->setOffsetValueType( - self::intersect($keyType, $otherArray->getIterableKeyType()), - self::intersect($valueTypes[$k], $otherArray->getOffsetValueType($keyType)), - $constArray->isOptionalKey($k) && !$hasOffset->yes(), - ); + $newArrayType = $merged; + } else { + $newArray = ConstantArrayTypeBuilder::createEmpty(); + $valueTypes = $constArray->getValueTypes(); + foreach ($constArray->getKeyTypes() as $k => $keyType) { + $hasOffset = $otherArray->hasOffsetValueType($keyType); + if ($hasOffset->no()) { + continue; + } + $newArray->setOffsetValueType( + self::intersect($keyType, $otherArray->getIterableKeyType()), + self::intersect($valueTypes[$k], $otherArray->getOffsetValueType($keyType)), + $constArray->isOptionalKey($k) && !$hasOffset->yes(), + ); + } + $newArrayType = $newArray->getArray(); } if ($constArrayIsI) { - $types[$i] = $newArray->getArray(); + $types[$i] = $newArrayType; array_splice($types, $j--, 1); } else { - $types[$j] = $newArray->getArray(); + $types[$j] = $newArrayType; array_splice($types, $i--, 1); } $typesCount--; @@ -1770,6 +1783,121 @@ public static function intersect(Type ...$types): Type return new IntersectionType($types); } + private static function intersectDefiniteConstantArrays(ConstantArrayType $a, ConstantArrayType $b): Type + { + $aSealed = $a->isUnsealed()->no(); + $bSealed = $b->isUnsealed()->no(); + $bothUnsealed = !$aSealed && !$bSealed; + + $aKeyByValue = []; + foreach ($a->getKeyTypes() as $k => $keyType) { + $aKeyByValue[$keyType->getValue()] = $k; + } + $bKeyByValue = []; + foreach ($b->getKeyTypes() as $k => $keyType) { + $bKeyByValue[$keyType->getValue()] = $k; + } + + if ($aSealed && $bSealed) { + foreach ($aKeyByValue as $keyValue => $k) { + if (!$a->isOptionalKey($k) && !array_key_exists($keyValue, $bKeyByValue)) { + return new NeverType(); + } + } + foreach ($bKeyByValue as $keyValue => $k) { + if (!$b->isOptionalKey($k) && !array_key_exists($keyValue, $aKeyByValue)) { + return new NeverType(); + } + } + } + + $newArray = ConstantArrayTypeBuilder::createEmpty(); + + if ($bothUnsealed) { + $aUnsealed = $a->getUnsealedTypes(); + $bUnsealed = $b->getUnsealedTypes(); + $unsealedKey = self::intersect($aUnsealed[0], $bUnsealed[0]); + $unsealedValue = self::intersect($aUnsealed[1], $bUnsealed[1]); + if ($unsealedKey instanceof NeverType || $unsealedValue instanceof NeverType) { + return new NeverType(); + } + $newArray->makeUnsealed($unsealedKey, $unsealedValue); + } else { + $never = new NeverType(true); + $newArray->makeUnsealed($never, $never); + } + + $resolveOtherValue = static function (ConstantArrayType $other, Type $keyType): ?Type { + if ($other->hasOffsetValueType($keyType)->yes()) { + return $other->getOffsetValueType($keyType); + } + $otherUnsealed = $other->getUnsealedTypes(); + if ($otherUnsealed === null) { + return null; + } + [$unsealedKey, $unsealedValue] = $otherUnsealed; + if ($unsealedKey instanceof NeverType && $unsealedKey->isExplicit()) { + return null; + } + if ($unsealedKey->isSuperTypeOf($keyType)->no()) { + return null; + } + return $unsealedValue; + }; + + $keysToProcess = []; + foreach ($aKeyByValue as $keyValue => $k) { + $keysToProcess[$keyValue] = [$k, $bKeyByValue[$keyValue] ?? null]; + } + foreach ($bKeyByValue as $keyValue => $k) { + if (!array_key_exists($keyValue, $keysToProcess)) { + $keysToProcess[$keyValue] = [null, $k]; + } + } + + foreach ($keysToProcess as [$aIdx, $bIdx]) { + if ($aIdx !== null && $bIdx !== null) { + $keyType = $a->getKeyTypes()[$aIdx]; + $value = self::intersect($a->getValueTypes()[$aIdx], $b->getValueTypes()[$bIdx]); + $optional = $a->isOptionalKey($aIdx) && $b->isOptionalKey($bIdx); + } elseif ($aIdx !== null) { + $keyType = $a->getKeyTypes()[$aIdx]; + $aValue = $a->getValueTypes()[$aIdx]; + $bValue = $resolveOtherValue($b, $keyType); + if ($bValue === null) { + if ($a->isOptionalKey($aIdx)) { + continue; + } + return new NeverType(); + } + $value = self::intersect($aValue, $bValue); + $optional = $a->isOptionalKey($aIdx); + } else { + $keyType = $b->getKeyTypes()[$bIdx]; + $bValue = $b->getValueTypes()[$bIdx]; + $aValue = $resolveOtherValue($a, $keyType); + if ($aValue === null) { + if ($b->isOptionalKey($bIdx)) { + continue; + } + return new NeverType(); + } + $value = self::intersect($aValue, $bValue); + $optional = $b->isOptionalKey($bIdx); + } + + if ($value instanceof NeverType) { + if ($optional) { + continue; + } + return new NeverType(); + } + $newArray->setOffsetValueType($keyType, $value, $optional); + } + + return $newArray->getArray(); + } + /** * Merge two IntersectionTypes that have the same structure but differ * in HasOffsetValueType value types (matched by offset key). diff --git a/tests/PHPStan/Type/TypeCombinatorTest.php b/tests/PHPStan/Type/TypeCombinatorTest.php index 90b6127222b..9747a0d38ef 100644 --- a/tests/PHPStan/Type/TypeCombinatorTest.php +++ b/tests/PHPStan/Type/TypeCombinatorTest.php @@ -14,8 +14,11 @@ use InvalidArgumentException; use Iterator; use ObjectShapesAcceptance\ClassWithFooIntProperty; +use PHPStan\DependencyInjection\BleedingEdgeToggle; use PHPStan\Fixture\FinalClass; use PHPStan\Generics\FunctionsAssertType\C; +use PHPStan\PhpDoc\TypeStringResolver; +use PHPStan\PhpDocParser\Parser\ParserException; use PHPStan\Reflection\Callables\SimpleImpurePoint; use PHPStan\Testing\PHPStanTestCase; use PHPStan\TrinaryLogic; @@ -5140,10 +5143,183 @@ public static function dataIntersect(): iterable ConstantArrayType::class, "array{int<0, max>, 'foo'}", ]; + + // current flawed behaviour (unknown sealedness) + yield [ + [ + new ConstantArrayType( + [ + new ConstantStringType('a'), + new ConstantStringType('b'), + ], + [ + new IntegerType(), + new StringType(), + ], + ), + new ConstantArrayType( + [ + new ConstantStringType('a'), + ], + [ + new IntegerType(), + ], + ), + ], + ConstantArrayType::class, + 'array{a: int, b: string}', + ]; + + // new behaviour with definitely sealed arrays + yield [ + [ + 'array{a: int, b: string}', + 'array{a: int}', + ], + NeverType::class, + '*NEVER*=implicit', + ]; + + yield [ + [ + 'array{int, 0|\'foo\'}', + 'array{int<0, max>, non-falsy-string}', + ], + ConstantArrayType::class, + "array{int<0, max>, 'foo'}", + ]; + + yield [ + [ + 'array{a: int, b: string}', + 'array{a: int<0, max>, ...}', + ], + ConstantArrayType::class, + 'array{a: int<0, max>, b: string}', + ]; + + yield [ + [ + 'array{a: int, b: string, ...}', + 'array{a: int<0, max>, ...}', + ], + ConstantArrayType::class, + 'array{a: int<0, max>, b: string, ...}', + ]; + + // both unsealed, disjoint known keys, default extras — union of known keys + yield [ + [ + 'array{a: int, ...}', + 'array{b: string, ...}', + ], + ConstantArrayType::class, + 'array{a: int, b: string, ...}', + ]; + + // both unsealed, narrower unsealed value on right + yield [ + [ + 'array{a: int, ...}', + 'array{a: int, ...}', + ], + ConstantArrayType::class, + 'array{a: int, ...}', + ]; + + // both unsealed, narrower unsealed key on right (array-key ∩ string = string) + yield [ + [ + 'array{a: int, ...}', + 'array{a: int, ...}', + ], + ConstantArrayType::class, + 'array{a: int, ...}', + ]; + + // both unsealed, unsealed value types intersect to a narrower common type + yield [ + [ + 'array{...>}', + 'array{...>}', + ], + ConstantArrayType::class, + 'array{...>}', + ]; + + // both unsealed, unsealed key types incompatible — no valid key overlap + yield [ + [ + 'array{...}', + 'array{...}', + ], + NeverType::class, + '*NEVER*=implicit', + ]; + + // both unsealed, unsealed value types incompatible + yield [ + [ + 'array{...}', + 'array{...}', + ], + NeverType::class, + '*NEVER*=implicit', + ]; + + // both unsealed: one side's known key conflicts with the other side's unsealed value type + yield [ + [ + 'array{a: int, ...}', + 'array{...}', + ], + NeverType::class, + '*NEVER*=implicit', + ]; + + // both unsealed: known key value is compatible with other side's unsealed value + yield [ + [ + 'array{a: non-empty-string, ...}', + 'array{...}', + ], + ConstantArrayType::class, + 'array{a: non-empty-string, ...}', + ]; + + // both unsealed with same known key, value types incompatible at that key + yield [ + [ + 'array{a: int, ...}', + 'array{a: string, ...}', + ], + NeverType::class, + '*NEVER*=implicit', + ]; + + // sealed + unsealed where sealed's known key value doesn't fit unsealed's key type — incompatible + yield [ + [ + 'array{a: int}', + 'array{...}', + ], + NeverType::class, + '*NEVER*=implicit', + ]; + + // sealed + unsealed where sealed is compatible with unsealed's unsealed types + yield [ + [ + 'array{a: int}', + 'array{...}', + ], + ConstantArrayType::class, + 'array{a: int}', + ]; } /** - * @param list $types + * @param list|list $types * @param class-string $expectedTypeClass */ #[DataProvider('dataIntersect')] @@ -5153,35 +5329,24 @@ public function testIntersect( string $expectedTypeDescription, ): void { - $actualType = TypeCombinator::intersect(...$types); - $actualTypeDescription = $actualType->describe(VerbosityLevel::precise()); - if ($actualType instanceof MixedType) { - if ($actualType->isExplicitMixed()) { - $actualTypeDescription .= '=explicit'; - } else { - $actualTypeDescription .= '=implicit'; - } - } - if ($actualType instanceof NeverType) { - if ($actualType->isExplicit()) { - $actualTypeDescription .= '=explicit'; - } else { - $actualTypeDescription .= '=implicit'; + $bleedingEdgeBackup = BleedingEdgeToggle::isBleedingEdge(); + $typeStringResolver = self::getContainer()->getByType(TypeStringResolver::class); + foreach ($types as $i => $type) { + BleedingEdgeToggle::setBleedingEdge(true); + if (is_string($type)) { + $types[$i] = $typeStringResolver->resolve($type, null); } } - if (get_class($actualType) === ObjectType::class && $actualType->isEnum()->no()) { - $actualClassReflection = $actualType->getClassReflection(); - if ( - $actualClassReflection !== null - && $actualClassReflection->hasFinalByKeywordOverride() - && $actualClassReflection->isFinal() - ) { - $actualTypeDescription .= '=final'; - } - } + BleedingEdgeToggle::setBleedingEdge($bleedingEdgeBackup); - $this->assertSame($expectedTypeDescription, $actualTypeDescription); + $actualType = TypeCombinator::intersect(...$types); + $actualTypeDescription = self::describeForIntersectTest($actualType); + + $this->assertSame( + self::sortExpectedDescription($expectedTypeDescription, $typeStringResolver), + $actualTypeDescription, + ); $this->assertInstanceOf($expectedTypeClass, $actualType); } @@ -5196,35 +5361,69 @@ public function testIntersectInversed( string $expectedTypeDescription, ): void { - $actualType = TypeCombinator::intersect(...array_reverse($types)); - $actualTypeDescription = $actualType->describe(VerbosityLevel::precise()); - if ($actualType instanceof MixedType) { - if ($actualType->isExplicitMixed()) { - $actualTypeDescription .= '=explicit'; - } else { - $actualTypeDescription .= '=implicit'; - } - } - if ($actualType instanceof NeverType) { - if ($actualType->isExplicit()) { - $actualTypeDescription .= '=explicit'; - } else { - $actualTypeDescription .= '=implicit'; + $bleedingEdgeBackup = BleedingEdgeToggle::isBleedingEdge(); + $typeStringResolver = self::getContainer()->getByType(TypeStringResolver::class); + foreach ($types as $i => $type) { + BleedingEdgeToggle::setBleedingEdge(true); + if (is_string($type)) { + $types[$i] = $typeStringResolver->resolve($type, null); } } - if (get_class($actualType) === ObjectType::class && $actualType->isEnum()->no()) { - $actualClassReflection = $actualType->getClassReflection(); + BleedingEdgeToggle::setBleedingEdge($bleedingEdgeBackup); + + $actualType = TypeCombinator::intersect(...array_reverse($types)); + $actualTypeDescription = self::describeForIntersectTest($actualType); + + $this->assertSame( + self::sortExpectedDescription($expectedTypeDescription, $typeStringResolver), + $actualTypeDescription, + ); + $this->assertInstanceOf($expectedTypeClass, $actualType); + } + + private static function describeForIntersectTest(Type $type): string + { + if ($type instanceof ConstantArrayType) { + $type = $type->sortKeys(); + } + $description = $type->describe(VerbosityLevel::precise()); + if ($type instanceof MixedType) { + $description .= $type->isExplicitMixed() ? '=explicit' : '=implicit'; + } + if ($type instanceof NeverType) { + $description .= $type->isExplicit() ? '=explicit' : '=implicit'; + } + if (get_class($type) === ObjectType::class && $type->isEnum()->no()) { + $classReflection = $type->getClassReflection(); if ( - $actualClassReflection !== null - && $actualClassReflection->hasFinalByKeywordOverride() - && $actualClassReflection->isFinal() + $classReflection !== null + && $classReflection->hasFinalByKeywordOverride() + && $classReflection->isFinal() ) { - $actualTypeDescription .= '=final'; + $description .= '=final'; } } - $this->assertSame($expectedTypeDescription, $actualTypeDescription); - $this->assertInstanceOf($expectedTypeClass, $actualType); + return $description; + } + + private static function sortExpectedDescription(string $description, TypeStringResolver $resolver): string + { + $bleedingEdgeBackup = BleedingEdgeToggle::isBleedingEdge(); + BleedingEdgeToggle::setBleedingEdge(true); + try { + $type = $resolver->resolve($description, null); + } catch (ParserException) { + return $description; + } finally { + BleedingEdgeToggle::setBleedingEdge($bleedingEdgeBackup); + } + + if ($type instanceof ConstantArrayType) { + return $type->sortKeys()->describe(VerbosityLevel::precise()); + } + + return $description; } public static function dataRemove(): array From f389a5e4b2bbb6e3ce847b8ab6d25fb6450870ed Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 22 Apr 2026 12:20:40 +0200 Subject: [PATCH 05/66] isSuperTypeOf --- src/Type/Constant/ConstantArrayType.php | 78 ++++++++++++++++- src/Type/TypeCombinator.php | 6 +- .../CallToFunctionParametersRuleTest.php | 2 +- .../Type/Constant/ConstantArrayTypeTest.php | 86 ++++++++++++++++++- tests/PHPStan/Type/TypeCombinatorTest.php | 17 ++-- 5 files changed, 175 insertions(+), 14 deletions(-) diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index dbc1506cf6c..16156f38a60 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -58,6 +58,7 @@ use PHPStan\Type\TypeCombinator; use PHPStan\Type\UnionType; use PHPStan\Type\VerbosityLevel; +use function array_key_exists; use function array_keys; use function array_map; use function array_merge; @@ -80,6 +81,7 @@ use function str_contains; use function strtolower; use function strtoupper; +use function usort; use const CASE_LOWER; use const CASE_UPPER; @@ -652,13 +654,29 @@ private function checkOurKeys(Type $type, bool $strictTypes): AcceptsResult public function isSuperTypeOf(Type $type): IsSuperTypeOfResult { if ($type instanceof self) { + $thisUnsealedness = $this->isUnsealed(); + $typeUnsealedness = $type->isUnsealed(); + $bothDefinite = !$thisUnsealedness->maybe() && !$typeUnsealedness->maybe(); + if (count($this->keyTypes) === 0) { - return new IsSuperTypeOfResult($type->isIterableAtLeastOnce()->negate(), []); + if (!$bothDefinite) { + return new IsSuperTypeOfResult($type->isIterableAtLeastOnce()->negate(), []); + } + if ($thisUnsealedness->no()) { + return new IsSuperTypeOfResult($type->isIterableAtLeastOnce()->negate(), []); + } + // $this is unsealed with no known keys — fall through to extras/unsealed-part checks below } $results = []; foreach ($this->keyTypes as $i => $keyType) { $hasOffset = $type->hasOffsetValueType($keyType); + if ($bothDefinite && $hasOffset->no() && $typeUnsealedness->yes()) { + [$typeUnsealedKey] = $type->getUnsealedTypes(); + if (!$typeUnsealedKey->isSuperTypeOf($keyType)->no()) { + $hasOffset = TrinaryLogic::createMaybe(); + } + } if ($hasOffset->no()) { if (!$this->isOptionalKey($i)) { return IsSuperTypeOfResult::createNo(); @@ -670,13 +688,69 @@ public function isSuperTypeOf(Type $type): IsSuperTypeOfResult $results[] = IsSuperTypeOfResult::createMaybe(); } - $isValueSuperType = $this->valueTypes[$i]->isSuperTypeOf($type->getOffsetValueType($keyType)); + $otherValueType = $type->getOffsetValueType($keyType); + if ($otherValueType instanceof ErrorType && $bothDefinite && $typeUnsealedness->yes()) { + [, $typeUnsealedValue] = $type->getUnsealedTypes(); + $otherValueType = $typeUnsealedValue; + } + $isValueSuperType = $this->valueTypes[$i]->isSuperTypeOf($otherValueType); if ($isValueSuperType->no()) { return $isValueSuperType->decorateReasons(static fn (string $reason) => sprintf('Offset %s: %s', $keyType->describe(VerbosityLevel::value()), $reason)); } $results[] = $isValueSuperType; } + if ($bothDefinite) { + $thisKeyValues = []; + foreach ($this->keyTypes as $thisKeyType) { + $thisKeyValues[$thisKeyType->getValue()] = true; + } + + foreach ($type->getKeyTypes() as $i => $typeKey) { + if (array_key_exists($typeKey->getValue(), $thisKeyValues)) { + continue; + } + + if ($thisUnsealedness->no()) { + if (!$type->isOptionalKey($i)) { + return IsSuperTypeOfResult::createNo(); + } + $results[] = IsSuperTypeOfResult::createMaybe(); + continue; + } + + [$thisUnsealedKey, $thisUnsealedValue] = $this->getUnsealedTypes(); + $keyCheck = $thisUnsealedKey->isSuperTypeOf($typeKey); + if ($keyCheck->no()) { + if ($type->isOptionalKey($i)) { + $results[] = IsSuperTypeOfResult::createMaybe(); + continue; + } + return IsSuperTypeOfResult::createNo(); + } + $valueCheck = $thisUnsealedValue->isSuperTypeOf($type->getValueTypes()[$i]); + if ($valueCheck->no()) { + if ($type->isOptionalKey($i)) { + $results[] = IsSuperTypeOfResult::createMaybe(); + continue; + } + return IsSuperTypeOfResult::createNo(); + } + $results[] = $keyCheck->and($valueCheck); + } + + if ($typeUnsealedness->yes()) { + if ($thisUnsealedness->no()) { + $results[] = IsSuperTypeOfResult::createMaybe(); + } else { + [$thisUnsealedKey, $thisUnsealedValue] = $this->getUnsealedTypes(); + [$typeUnsealedKey, $typeUnsealedValue] = $type->getUnsealedTypes(); + $results[] = $thisUnsealedKey->isSuperTypeOf($typeUnsealedKey); + $results[] = $thisUnsealedValue->isSuperTypeOf($typeUnsealedValue); + } + } + } + return IsSuperTypeOfResult::createYes()->and(...$results); } diff --git a/src/Type/TypeCombinator.php b/src/Type/TypeCombinator.php index e2df506d9e5..0019f2462ee 100644 --- a/src/Type/TypeCombinator.php +++ b/src/Type/TypeCombinator.php @@ -1850,9 +1850,11 @@ private static function intersectDefiniteConstantArrays(ConstantArrayType $a, Co $keysToProcess[$keyValue] = [$k, $bKeyByValue[$keyValue] ?? null]; } foreach ($bKeyByValue as $keyValue => $k) { - if (!array_key_exists($keyValue, $keysToProcess)) { - $keysToProcess[$keyValue] = [null, $k]; + if (array_key_exists($keyValue, $keysToProcess)) { + continue; } + + $keysToProcess[$keyValue] = [null, $k]; } foreach ($keysToProcess as [$aIdx, $bIdx]) { diff --git a/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php b/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php index 11c72db5273..40ae1db11cc 100644 --- a/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php +++ b/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php @@ -2967,7 +2967,7 @@ public function testBug11494(): void [ 'Parameter #1 $a of function Bug11494\test expects array{long: string, details: string}|array{short: string}, array{short: \'thing\', extra: \'other\'} given.', 18, - "• Type #1 from the union: Sealed array shape does not accept array with extra key 'extra'.\n• Type #2 from the union: Sealed array shape does not accept array with extra key 'extra'." + "• Type #1 from the union: Sealed array shape does not accept array with extra key 'extra'.\n• Type #2 from the union: Sealed array shape does not accept array with extra key 'extra'.", ], ]); } diff --git a/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php b/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php index f061882c1e7..dab3e8bf4bb 100644 --- a/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php +++ b/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php @@ -4,6 +4,7 @@ use Closure; use PHPStan\DependencyInjection\BleedingEdgeToggle; +use PHPStan\PhpDoc\TypeStringResolver; use PHPStan\Testing\PHPStanTestCase; use PHPStan\TrinaryLogic; use PHPStan\Type\Accessory\HasOffsetType; @@ -30,6 +31,7 @@ use PHPUnit\Framework\Attributes\DataProvider; use stdClass; use function array_map; +use function is_string; use function sprintf; class ConstantArrayTypeTest extends PHPStanTestCase @@ -615,9 +617,11 @@ public function testAccepts(Type $type, Type $otherType, TrinaryLogic $expectedR $actualResult->result->describe(), $testDescription, ); - if ($reasons !== null) { - $this->assertSame($reasons, $actualResult->reasons, $testDescription); + if ($reasons === null) { + return; } + + $this->assertSame($reasons, $actualResult->reasons, $testDescription); } public static function dataIsSuperTypeOf(): iterable @@ -909,11 +913,87 @@ public static function dataIsSuperTypeOf(): iterable ]), TrinaryLogic::createYes(), ]; + + // definite sealedness tests (bleeding edge) + + // both sealed, same keys, compatible values + yield ['array{a: int, b: string}', 'array{a: int, b: string}', TrinaryLogic::createYes()]; + + // both sealed, bigger vs smaller (subset) — sealed requires exact keys + yield ['array{a: int, b: string}', 'array{a: int}', TrinaryLogic::createNo()]; + yield ['array{a: int}', 'array{a: int, b: string}', TrinaryLogic::createNo()]; + + // both sealed, narrower value + yield ['array{a: int}', 'array{a: int<0, max>}', TrinaryLogic::createYes()]; + yield ['array{a: int<0, max>}', 'array{a: int}', TrinaryLogic::createMaybe()]; + + // both sealed, optional key in left only + yield ['array{a: int, b?: string}', 'array{a: int}', TrinaryLogic::createYes()]; + yield ['array{a: int, b?: string}', 'array{a: int, b: string}', TrinaryLogic::createYes()]; + + // both unsealed, compatible known keys + compatible unsealed + yield ['array{a: int, ...}', 'array{a: int<0, max>, ...}', TrinaryLogic::createYes()]; + yield ['array{a: int<0, max>, ...}', 'array{a: int, ...}', TrinaryLogic::createMaybe()]; + + // both unsealed, bigger known on right (right's extra fits left's unsealed extras) + yield ['array{a: int, ...}', 'array{a: int, b: string, ...}', TrinaryLogic::createYes()]; + + // both unsealed, right has known key left doesn't require; left's unsealed must cover + yield ['array{a: int, ...}', 'array{a: int, b: int, ...}', TrinaryLogic::createNo()]; + yield ['array{a: int, ...}', 'array{a: int, b: non-empty-string, ...}', TrinaryLogic::createYes()]; + + // both unsealed, narrower unsealed value on right + yield ['array{a: int, ...}', 'array{a: int, ...}', TrinaryLogic::createYes()]; + yield ['array{a: int, ...}', 'array{a: int, ...}', TrinaryLogic::createMaybe()]; + + // both unsealed, narrower unsealed key on right (array-key ⊃ string) + yield ['array{a: int, ...}', 'array{a: int, ...}', TrinaryLogic::createYes()]; + yield ['array{a: int, ...}', 'array{a: int, ...}', TrinaryLogic::createMaybe()]; + + // both unsealed, incompatible unsealed key types + yield ['array{...}', 'array{...}', TrinaryLogic::createNo()]; + + // both unsealed, incompatible unsealed value types + yield ['array{...}', 'array{...}', TrinaryLogic::createNo()]; + + // unsealed vs sealed — sealed's extras must fit unsealed's unsealed + yield ['array{a: int, ...}', 'array{a: int, b: string}', TrinaryLogic::createYes()]; + yield ['array{a: int, ...}', 'array{a: int, b: string}', TrinaryLogic::createNo()]; + + // sealed vs unsealed — unsealed might have extras sealed doesn't allow + yield ['array{a: int}', 'array{a: int, ...}', TrinaryLogic::createMaybe()]; + yield ['array{a: int, b: string}', 'array{a: int<0, max>, ...}', TrinaryLogic::createMaybe()]; + + // sealed vs unsealed where sealed's keys can't be in unsealed's extras + yield ['array{a: int}', 'array{...}', TrinaryLogic::createNo()]; + + // sealed vs unsealed where sealed fits unsealed's extras + yield ['array{a: int}', 'array{...}', TrinaryLogic::createMaybe()]; } + /** + * @param ConstantArrayType|string $type + * @param Type|string $otherType + */ #[DataProvider('dataIsSuperTypeOf')] - public function testIsSuperTypeOf(ConstantArrayType $type, Type $otherType, TrinaryLogic $expectedResult): void + public function testIsSuperTypeOf($type, $otherType, TrinaryLogic $expectedResult): void { + $bleedingEdgeBackup = BleedingEdgeToggle::isBleedingEdge(); + BleedingEdgeToggle::setBleedingEdge(true); + try { + $resolver = self::getContainer()->getByType(TypeStringResolver::class); + if (is_string($type)) { + $resolved = $resolver->resolve($type, null); + $this->assertInstanceOf(ConstantArrayType::class, $resolved); + $type = $resolved; + } + if (is_string($otherType)) { + $otherType = $resolver->resolve($otherType, null); + } + } finally { + BleedingEdgeToggle::setBleedingEdge($bleedingEdgeBackup); + } + $actualResult = $type->isSuperTypeOf($otherType); $this->assertSame( $expectedResult->describe(), diff --git a/tests/PHPStan/Type/TypeCombinatorTest.php b/tests/PHPStan/Type/TypeCombinatorTest.php index 9747a0d38ef..a1c2f1a1ff3 100644 --- a/tests/PHPStan/Type/TypeCombinatorTest.php +++ b/tests/PHPStan/Type/TypeCombinatorTest.php @@ -67,6 +67,7 @@ use function array_reverse; use function get_class; use function implode; +use function is_string; use function sprintf; use const PHP_VERSION_ID; @@ -5130,7 +5131,7 @@ public static function dataIntersect(): iterable [new IntegerType(), new UnionType([ new ConstantStringType('0'), new ConstantStringType('foo'), - ])] + ])], ), new ConstantArrayType( [new ConstantIntegerType(0), new ConstantIntegerType(1)], @@ -5333,9 +5334,11 @@ public function testIntersect( $typeStringResolver = self::getContainer()->getByType(TypeStringResolver::class); foreach ($types as $i => $type) { BleedingEdgeToggle::setBleedingEdge(true); - if (is_string($type)) { - $types[$i] = $typeStringResolver->resolve($type, null); + if (!is_string($type)) { + continue; } + + $types[$i] = $typeStringResolver->resolve($type, null); } BleedingEdgeToggle::setBleedingEdge($bleedingEdgeBackup); @@ -5351,7 +5354,7 @@ public function testIntersect( } /** - * @param list $types + * @param list|list $types * @param class-string $expectedTypeClass */ #[DataProvider('dataIntersect')] @@ -5365,9 +5368,11 @@ public function testIntersectInversed( $typeStringResolver = self::getContainer()->getByType(TypeStringResolver::class); foreach ($types as $i => $type) { BleedingEdgeToggle::setBleedingEdge(true); - if (is_string($type)) { - $types[$i] = $typeStringResolver->resolve($type, null); + if (!is_string($type)) { + continue; } + + $types[$i] = $typeStringResolver->resolve($type, null); } BleedingEdgeToggle::setBleedingEdge($bleedingEdgeBackup); From bb4ea57bf40def7f85d86663f535ee9c8be57de9 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 22 Apr 2026 18:12:53 +0200 Subject: [PATCH 06/66] union improvement --- src/Type/Constant/ConstantArrayType.php | 223 +++++++++- .../Constant/ConstantArrayTypeBuilder.php | 7 + src/Type/TypeCombinator.php | 72 +++- .../Analyser/nsrt/array-append-count.php | 32 ++ tests/PHPStan/Analyser/nsrt/bug-14314.php | 2 +- tests/PHPStan/Analyser/nsrt/bug-5584.php | 2 +- tests/PHPStan/Analyser/nsrt/bug-9985.php | 2 +- .../nsrt/conditional-array-key-exists.php | 25 ++ .../nsrt/generalize-scope-recursive.php | 2 +- .../Analyser/nsrt/has-offset-type-bug.php | 6 +- tests/PHPStan/Analyser/nsrt/list-count.php | 8 +- .../Analyser/nsrt/narrow-tagged-union.php | 2 +- .../Analyser/nsrt/unsealed-array-shapes.php | 2 +- .../Rules/Comparison/data/bug-7898.php | 2 +- .../Constant/ConstantArrayTypeBuilderTest.php | 43 ++ .../Type/Constant/ConstantArrayTypeTest.php | 11 +- tests/PHPStan/Type/TypeCombinatorTest.php | 389 ++++++++++++++---- 17 files changed, 718 insertions(+), 112 deletions(-) create mode 100644 tests/PHPStan/Analyser/nsrt/array-append-count.php create mode 100644 tests/PHPStan/Analyser/nsrt/conditional-array-key-exists.php diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index 16156f38a60..01adc9a3b4a 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -136,7 +136,18 @@ public function __construct( $keyTypesCount = count($this->keyTypes); if ($keyTypesCount === 0) { - $isList = TrinaryLogic::createYes(); + if ($unsealed === null) { + $isList = TrinaryLogic::createYes(); + } else { + [$unsealedKeyType] = $unsealed; + if ($unsealedKeyType instanceof NeverType && $unsealedKeyType->isExplicit()) { + $isList = TrinaryLogic::createYes(); + } elseif ($unsealedKeyType->isInteger()->yes()) { + $isList = TrinaryLogic::createMaybe(); + } else { + $isList = TrinaryLogic::createNo(); + } + } } if ($isList === null) { @@ -554,7 +565,7 @@ public function accepts(Type $type, bool $strictTypes): AcceptsResult $result = $result->and($acceptsValue); } - $otherUnsealed = $constantArray->getUnsealedTypes(); + $otherUnsealed = $constantArray->unsealed; if ($otherUnsealed !== null && !$constantArray->isUnsealed()->no()) { [$otherUnsealedKeyType, $otherUnsealedValueType] = $otherUnsealed; @@ -656,7 +667,7 @@ public function isSuperTypeOf(Type $type): IsSuperTypeOfResult if ($type instanceof self) { $thisUnsealedness = $this->isUnsealed(); $typeUnsealedness = $type->isUnsealed(); - $bothDefinite = !$thisUnsealedness->maybe() && !$typeUnsealedness->maybe(); + $bothDefinite = $this->unsealed !== null && $type->unsealed !== null; if (count($this->keyTypes) === 0) { if (!$bothDefinite) { @@ -672,7 +683,7 @@ public function isSuperTypeOf(Type $type): IsSuperTypeOfResult foreach ($this->keyTypes as $i => $keyType) { $hasOffset = $type->hasOffsetValueType($keyType); if ($bothDefinite && $hasOffset->no() && $typeUnsealedness->yes()) { - [$typeUnsealedKey] = $type->getUnsealedTypes(); + [$typeUnsealedKey] = $type->unsealed; if (!$typeUnsealedKey->isSuperTypeOf($keyType)->no()) { $hasOffset = TrinaryLogic::createMaybe(); } @@ -690,7 +701,7 @@ public function isSuperTypeOf(Type $type): IsSuperTypeOfResult $otherValueType = $type->getOffsetValueType($keyType); if ($otherValueType instanceof ErrorType && $bothDefinite && $typeUnsealedness->yes()) { - [, $typeUnsealedValue] = $type->getUnsealedTypes(); + [, $typeUnsealedValue] = $type->unsealed; $otherValueType = $typeUnsealedValue; } $isValueSuperType = $this->valueTypes[$i]->isSuperTypeOf($otherValueType); @@ -719,7 +730,7 @@ public function isSuperTypeOf(Type $type): IsSuperTypeOfResult continue; } - [$thisUnsealedKey, $thisUnsealedValue] = $this->getUnsealedTypes(); + [$thisUnsealedKey, $thisUnsealedValue] = $this->unsealed; $keyCheck = $thisUnsealedKey->isSuperTypeOf($typeKey); if ($keyCheck->no()) { if ($type->isOptionalKey($i)) { @@ -743,8 +754,8 @@ public function isSuperTypeOf(Type $type): IsSuperTypeOfResult if ($thisUnsealedness->no()) { $results[] = IsSuperTypeOfResult::createMaybe(); } else { - [$thisUnsealedKey, $thisUnsealedValue] = $this->getUnsealedTypes(); - [$typeUnsealedKey, $typeUnsealedValue] = $type->getUnsealedTypes(); + [$thisUnsealedKey, $thisUnsealedValue] = $this->unsealed; + [$typeUnsealedKey, $typeUnsealedValue] = $type->unsealed; $results[] = $thisUnsealedKey->isSuperTypeOf($typeUnsealedKey); $results[] = $thisUnsealedValue->isSuperTypeOf($typeUnsealedValue); } @@ -1717,7 +1728,14 @@ public function isIterableAtLeastOnce(): TrinaryLogic { $keysCount = count($this->keyTypes); if ($keysCount === 0) { - return TrinaryLogic::createNo(); + if ($this->unsealed === null) { + return TrinaryLogic::createNo(); + } + [$unsealedKey] = $this->unsealed; + if ($unsealedKey instanceof NeverType && $unsealedKey->isExplicit()) { + return TrinaryLogic::createNo(); + } + return TrinaryLogic::createMaybe(); } $optionalKeysCount = count($this->optionalKeys); @@ -2259,6 +2277,85 @@ public function traverseSimultaneously(Type $right, callable $cb): Type } public function isKeysSupersetOf(self $otherArray): bool + { + if ($this->unsealed === null || $otherArray->unsealed === null) { + return $this->legacyIsKeysSupersetOf($otherArray); + } + + [$thisUnsealedKey, $thisUnsealedValue] = $this->unsealed; + [$otherUnsealedKey, $otherUnsealedValue] = $otherArray->unsealed; + $thisHasExtras = !($thisUnsealedKey instanceof NeverType && $thisUnsealedKey->isExplicit()); + $otherHasExtras = !($otherUnsealedKey instanceof NeverType && $otherUnsealedKey->isExplicit()); + + $otherHasRequiredKeys = false; + foreach ($otherArray->keyTypes as $j => $keyType) { + if ($otherArray->isOptionalKey($j)) { + continue; + } + $otherHasRequiredKeys = true; + break; + } + + // Sealed empty $other (no keys, no extras): absorbing it is lossless iff $this + // already accepts []. i.e., all of $this's known keys are optional. Otherwise + // merge would add [] as a new instance. + if (!$otherHasRequiredKeys && !$otherHasExtras && count($otherArray->keyTypes) === 0) { + foreach ($this->keyTypes as $i => $keyType) { + if (!$this->isOptionalKey($i)) { + return false; + } + } + return true; + } + + // With real unsealed extras on both sides that can absorb each other's + // required keys, merging is acceptable regardless of which keys overlap. + if ($thisHasExtras && $otherHasExtras) { + return true; + } + + // Asymmetric extras: one side has real extras that can absorb the other's keys. + if ($thisHasExtras) { + if ($this->legacyIsKeysSupersetOf($otherArray)) { + return true; + } + foreach ($otherArray->keyTypes as $j => $keyType) { + if ($otherArray->isOptionalKey($j)) { + continue; + } + if ($thisUnsealedKey->isSuperTypeOf($keyType)->no()) { + return false; + } + if ($thisUnsealedValue->isSuperTypeOf($otherArray->valueTypes[$j])->no()) { + return false; + } + } + return true; + } + + if ($otherHasExtras) { + if ($this->legacyIsKeysSupersetOf($otherArray)) { + return true; + } + foreach ($this->keyTypes as $i => $keyType) { + if ($this->isOptionalKey($i)) { + continue; + } + if ($otherUnsealedKey->isSuperTypeOf($keyType)->no()) { + return false; + } + if ($otherUnsealedValue->isSuperTypeOf($this->valueTypes[$i])->no()) { + return false; + } + } + return true; + } + + // Both sealed: fall back to the legacy key/value shape check. + return $this->legacyIsKeysSupersetOf($otherArray); + } + + private function legacyIsKeysSupersetOf(self $otherArray): bool { $keyTypesCount = count($this->keyTypes); $otherKeyTypesCount = count($otherArray->keyTypes); @@ -2312,14 +2409,116 @@ public function isKeysSupersetOf(self $otherArray): bool } } - // todo unsealed - return true; } public function mergeWith(self $otherArray): self { // only call this after verifying isKeysSupersetOf, or if losing tagged unions is not an issue + if ($this->unsealed === null || $otherArray->unsealed === null) { + return $this->legacyMergeWith($otherArray); + } + + [$thisUnsealedKey, $thisUnsealedValue] = $this->unsealed; + [$otherUnsealedKey, $otherUnsealedValue] = $otherArray->unsealed; + + $mergedUnsealedKey = TypeCombinator::union($thisUnsealedKey, $otherUnsealedKey); + $mergedUnsealedValue = TypeCombinator::union($thisUnsealedValue, $otherUnsealedValue); + + $absorbIntoExtras = static function (Type $keyType, Type $valueType) use (&$mergedUnsealedKey, &$mergedUnsealedValue): void { + $mergedUnsealedKey = TypeCombinator::union($mergedUnsealedKey, $keyType); + $mergedUnsealedValue = TypeCombinator::union($mergedUnsealedValue, $valueType); + }; + + $canAbsorb = static function (Type $sideUnsealedKey, Type $sideUnsealedValue, Type $keyType, Type $valueType): bool { + if ($sideUnsealedKey instanceof NeverType && $sideUnsealedKey->isExplicit()) { + return false; + } + if ($sideUnsealedKey->isSuperTypeOf($keyType)->no()) { + return false; + } + if ($sideUnsealedValue->isSuperTypeOf($valueType)->no()) { + return false; + } + return true; + }; + + $keyTypes = []; + $valueTypes = []; + $optionalKeys = []; + $nextAutoIndexes = [0]; + + $otherKeyIndexMap = $otherArray->getKeyIndexMap(); + $processed = []; + + foreach ($this->keyTypes as $i => $keyType) { + $keyValue = $keyType->getValue(); + $processed[$keyValue] = true; + $valueType = $this->valueTypes[$i]; + + if (array_key_exists($keyValue, $otherKeyIndexMap)) { + $j = $otherKeyIndexMap[$keyValue]; + $otherValueType = $otherArray->valueTypes[$j]; + $mergedValue = TypeCombinator::union($valueType, $otherValueType); + $optional = $this->isOptionalKey($i) || $otherArray->isOptionalKey($j); + + $keyTypes[] = $keyType; + $valueTypes[] = $mergedValue; + if ($optional) { + $optionalKeys[] = count($keyTypes) - 1; + } + continue; + } + + if ($canAbsorb($otherUnsealedKey, $otherUnsealedValue, $keyType, $valueType)) { + $absorbIntoExtras($keyType, $valueType); + continue; + } + + $keyTypes[] = $keyType; + $valueTypes[] = $valueType; + $optionalKeys[] = count($keyTypes) - 1; + } + + foreach ($otherArray->keyTypes as $j => $keyType) { + $keyValue = $keyType->getValue(); + if (array_key_exists($keyValue, $processed)) { + continue; + } + $valueType = $otherArray->valueTypes[$j]; + + if ($canAbsorb($thisUnsealedKey, $thisUnsealedValue, $keyType, $valueType)) { + $absorbIntoExtras($keyType, $valueType); + continue; + } + + $keyTypes[] = $keyType; + $valueTypes[] = $valueType; + $optionalKeys[] = count($keyTypes) - 1; + } + + $resultUnsealed = [$mergedUnsealedKey, $mergedUnsealedValue]; + + $nextAutoIndexes = array_values(array_unique(array_merge($this->nextAutoIndexes, $otherArray->nextAutoIndexes))); + sort($nextAutoIndexes); + + $optionalKeys = array_values(array_unique($optionalKeys)); + + /** @var list $keyTypes */ + $keyTypes = $keyTypes; + + return $this->recreate( + $keyTypes, + $valueTypes, + $nextAutoIndexes, + $optionalKeys, + $this->isList->and($otherArray->isList), + $resultUnsealed, + ); + } + + private function legacyMergeWith(self $otherArray): self + { $valueTypes = $this->valueTypes; $optionalKeys = $this->optionalKeys; foreach ($this->keyTypes as $i => $keyType) { @@ -2340,7 +2539,7 @@ public function mergeWith(self $otherArray): self $nextAutoIndexes = array_values(array_unique(array_merge($this->nextAutoIndexes, $otherArray->nextAutoIndexes))); sort($nextAutoIndexes); - return $this->recreate($this->keyTypes, $valueTypes, $nextAutoIndexes, $optionalKeys, $this->isList->and($otherArray->isList), $this->unsealed); // todo unsealed + return $this->recreate($this->keyTypes, $valueTypes, $nextAutoIndexes, $optionalKeys, $this->isList->and($otherArray->isList), $this->unsealed); } /** diff --git a/src/Type/Constant/ConstantArrayTypeBuilder.php b/src/Type/Constant/ConstantArrayTypeBuilder.php index 100828e52d5..d3c69eb3a62 100644 --- a/src/Type/Constant/ConstantArrayTypeBuilder.php +++ b/src/Type/Constant/ConstantArrayTypeBuilder.php @@ -401,6 +401,13 @@ public function getArray(): Type { $keyTypesCount = count($this->keyTypes); if ($keyTypesCount === 0) { + if ($this->unsealed !== null) { + [$unsealedKey, $unsealedValue] = $this->unsealed; + $isExplicitNever = $unsealedKey instanceof NeverType && $unsealedKey->isExplicit(); + if (!$isExplicitNever) { + return new ArrayType($unsealedKey, $unsealedValue); + } + } return new ConstantArrayType([], [], unsealed: $this->unsealed); } diff --git a/src/Type/TypeCombinator.php b/src/Type/TypeCombinator.php index 0019f2462ee..869811f517a 100644 --- a/src/Type/TypeCombinator.php +++ b/src/Type/TypeCombinator.php @@ -31,6 +31,7 @@ use function array_filter; use function array_key_exists; use function array_key_first; +use function array_keys; use function array_merge; use function array_slice; use function array_splice; @@ -919,7 +920,7 @@ private static function processArrayTypes(array $arrayTypes): array $filledArrays++; } - if ($generalArrayOccurred || !$isConstantArray) { + if (!$isConstantArray) { foreach ($arrayType->getArrays() as $type) { $keyTypesForGeneralArray[] = $type->getIterableKeyType(); $valueTypesForGeneralArray[] = $type->getItemType(); @@ -1232,7 +1233,14 @@ private static function reduceArrays(array $constantArrays, bool $preserveTagged } if ($emptyArray !== null) { - $newArrays[] = $emptyArray; + if ($preserveTaggedUnions && $emptyArray instanceof ConstantArrayType) { + // Let the empty array participate in merging — the passes below will absorb + // it into any array that already accepts [] (all-optional keys, compatible + // unsealed extras). If no such array exists, it remains as-is in the result. + $arraysToProcess[] = $emptyArray; + } else { + $newArrays[] = $emptyArray; + } } $arraysToProcessPerKey = []; @@ -1317,6 +1325,61 @@ private static function reduceArrays(array $constantArrays, bool $preserveTagged } } + // Second pass: for arrays with definite sealedness, try to merge pairs that + // don't share any known key (the eligibleCombinations loop above only considers + // shared-key pairs). + $indices = array_keys($arraysToProcess); + $indicesCount = count($indices); + for ($ii = 0; $ii < $indicesCount - 1; $ii++) { + $i = $indices[$ii]; + if (!array_key_exists($i, $arraysToProcess)) { + continue; + } + if ($arraysToProcess[$i]->getUnsealedTypes() === null) { + continue; + } + for ($jj = $ii + 1; $jj < $indicesCount; $jj++) { + $j = $indices[$jj]; + if (!array_key_exists($j, $arraysToProcess)) { + continue; + } + if ($arraysToProcess[$j]->getUnsealedTypes() === null) { + continue; + } + if ($arraysToProcess[$j]->isKeysSupersetOf($arraysToProcess[$i])) { + $arraysToProcess[$j] = $arraysToProcess[$j]->mergeWith($arraysToProcess[$i]); + unset($arraysToProcess[$i]); + continue 2; + } + if (!$arraysToProcess[$i]->isKeysSupersetOf($arraysToProcess[$j])) { + continue; + } + + $arraysToProcess[$i] = $arraysToProcess[$i]->mergeWith($arraysToProcess[$j]); + unset($arraysToProcess[$j]); + } + } + + // Final pass: if merging left us with a ConstantArrayType that has no known keys + // but has real unsealed extras, collapse it to a plain ArrayType (mirrors the same + // logic in ConstantArrayTypeBuilder::getArray — but applies to results produced by + // ConstantArrayType::mergeWith, which doesn't go through the builder). + foreach ($arraysToProcess as $idx => $arr) { + if (count($arr->getKeyTypes()) !== 0) { + continue; + } + $unsealed = $arr->getUnsealedTypes(); + if ($unsealed === null) { + continue; + } + [$unsealedKey, $unsealedValue] = $unsealed; + if ($unsealedKey instanceof NeverType && $unsealedKey->isExplicit()) { + continue; + } + $newArrays[] = new ArrayType($unsealedKey, $unsealedValue); + unset($arraysToProcess[$idx]); + } + // Final pass: collapse the loop-accumulator pattern where each iteration // produced a longer non-empty list variant. When several non-empty list // ConstantArrayTypes survive earlier merging and together push the @@ -1363,6 +1426,7 @@ private static function reduceArrays(array $constantArrays, bool $preserveTagged } } + return array_merge($newArrays, $arraysToProcess); } @@ -1594,6 +1658,7 @@ public static function intersect(Type ...$types): Type && $types[$j] instanceof NonEmptyArrayType && (count($types[$i]->getKeyTypes()) === 1 || $types[$i]->isList()->yes()) && $types[$i]->isOptionalKey(0) + && !$types[$i]->isUnsealed()->yes() ) { $types[$i] = $types[$i]->makeOffsetRequired($types[$i]->getKeyTypes()[0]); array_splice($types, $j--, 1); @@ -1606,6 +1671,7 @@ public static function intersect(Type ...$types): Type && $types[$i] instanceof NonEmptyArrayType && (count($types[$j]->getKeyTypes()) === 1 || $types[$j]->isList()->yes()) && $types[$j]->isOptionalKey(0) + && !$types[$j]->isUnsealed()->yes() ) { $types[$j] = $types[$j]->makeOffsetRequired($types[$j]->getKeyTypes()[0]); array_splice($types, $i--, 1); @@ -1787,7 +1853,7 @@ private static function intersectDefiniteConstantArrays(ConstantArrayType $a, Co { $aSealed = $a->isUnsealed()->no(); $bSealed = $b->isUnsealed()->no(); - $bothUnsealed = !$aSealed && !$bSealed; + $bothUnsealed = !$aSealed && !$bSealed && $a->getUnsealedTypes() !== null && $b->getUnsealedTypes() !== null; $aKeyByValue = []; foreach ($a->getKeyTypes() as $k => $keyType) { diff --git a/tests/PHPStan/Analyser/nsrt/array-append-count.php b/tests/PHPStan/Analyser/nsrt/array-append-count.php new file mode 100644 index 00000000000..d39ed604943 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/array-append-count.php @@ -0,0 +1,32 @@ + 0) { + $types[] = 'x'; + } elseif ($a < 0) { + $types[] = 'y'; + } + if ($b > 0) { + $types[] = 'z'; + } + if ($c === 1) { + $types[] = 'p'; + } elseif ($c === 2) { + $types[] = 'q'; + } + + // $types could have 1 (just 'base'), or 2/3/4 depending on which + // elseif arms fire. count should at least allow 1. + assertType('int<1, 4>', count($types)); + + if (count($types) === 1) { + // reachable: all three ifs miss — $types stays as ['base']. + assertType("array{'base'}", $types); + } +} diff --git a/tests/PHPStan/Analyser/nsrt/bug-14314.php b/tests/PHPStan/Analyser/nsrt/bug-14314.php index ed6b323a051..ea451aa59e8 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-14314.php +++ b/tests/PHPStan/Analyser/nsrt/bug-14314.php @@ -79,7 +79,7 @@ public function testIntRangeWithUnionAndEmpty(array $arr, int $twoToFour): void assertType('array{string, string, string, string}', $arr); return; } - assertType('array{}|array{string, string, string, string}|array{string}', $arr); + assertType('array{}|array{string}', $arr); } } diff --git a/tests/PHPStan/Analyser/nsrt/bug-5584.php b/tests/PHPStan/Analyser/nsrt/bug-5584.php index 45e6efeaa3f..7800f1364a0 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-5584.php +++ b/tests/PHPStan/Analyser/nsrt/bug-5584.php @@ -19,6 +19,6 @@ public function unionSum(): void $b = ['b' => 6]; } - assertType('array{}|array{b?: 6, a?: 5}', $a + $b); + assertType('array{b?: 6, a?: 5}', $a + $b); } } diff --git a/tests/PHPStan/Analyser/nsrt/bug-9985.php b/tests/PHPStan/Analyser/nsrt/bug-9985.php index 09a7ad92eac..9f1e979c014 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-9985.php +++ b/tests/PHPStan/Analyser/nsrt/bug-9985.php @@ -17,7 +17,7 @@ function (): void { $warnings['c'] = true; } - assertType('array{}|array{a?: true, b: true}|array{a?: true, c?: true}', $warnings); + assertType('array{a?: true, b: true}|array{a?: true, c?: true}', $warnings); if (!empty($warnings)) { assertType('array{a?: true, b: true}|non-empty-array{a?: true, c?: true}', $warnings); diff --git a/tests/PHPStan/Analyser/nsrt/conditional-array-key-exists.php b/tests/PHPStan/Analyser/nsrt/conditional-array-key-exists.php new file mode 100644 index 00000000000..0f88f37807d --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/conditional-array-key-exists.php @@ -0,0 +1,25 @@ + $options */ +function apply(array $options): void +{ + $range = []; + if (isset($options['min_range'])) { + $range['min'] = 1; + } + if (isset($options['max_range'])) { + $range['max'] = 2; + } + + // $range can be {}, {min}, {max}, or {min, max} + assertType('array{min?: 1, max?: 2}', $range); + + if (array_key_exists('min', $range) || array_key_exists('max', $range)) { + // reachable: either key could be set. + assertType('non-empty-array{min?: 1, max?: 2}', $range); + } +} diff --git a/tests/PHPStan/Analyser/nsrt/generalize-scope-recursive.php b/tests/PHPStan/Analyser/nsrt/generalize-scope-recursive.php index d4a82c8dcb4..8d13c5526fe 100644 --- a/tests/PHPStan/Analyser/nsrt/generalize-scope-recursive.php +++ b/tests/PHPStan/Analyser/nsrt/generalize-scope-recursive.php @@ -16,7 +16,7 @@ public function doFoo(array $array, array $values) } } - assertType('array{}|array{foo?: array}', $data); + assertType('array{foo?: array}', $data); } /** diff --git a/tests/PHPStan/Analyser/nsrt/has-offset-type-bug.php b/tests/PHPStan/Analyser/nsrt/has-offset-type-bug.php index 09955bde2ea..525fc619c8d 100644 --- a/tests/PHPStan/Analyser/nsrt/has-offset-type-bug.php +++ b/tests/PHPStan/Analyser/nsrt/has-offset-type-bug.php @@ -63,14 +63,14 @@ public function doBar(array $result): void */ public function testIsset($range): void { - assertType("array{}|array{min?: bool|float|int|string|null, max?: bool|float|int|string|null}", $range); + assertType("array{min?: bool|float|int|string|null, max?: bool|float|int|string|null}", $range); if (isset($range['min']) || isset($range['max'])) { assertType("non-empty-array{min?: bool|float|int|string|null, max?: bool|float|int|string|null}", $range); } else { - assertType("array{}|array{min?: bool|float|int|string|null, max?: bool|float|int|string|null}", $range); + assertType("array{min?: bool|float|int|string|null, max?: bool|float|int|string|null}", $range); } - assertType("array{}|array{min?: bool|float|int|string|null, max?: bool|float|int|string|null}", $range); + assertType("array{min?: bool|float|int|string|null, max?: bool|float|int|string|null}", $range); } } diff --git a/tests/PHPStan/Analyser/nsrt/list-count.php b/tests/PHPStan/Analyser/nsrt/list-count.php index 24bfc6fa63f..d9bd37e9b52 100644 --- a/tests/PHPStan/Analyser/nsrt/list-count.php +++ b/tests/PHPStan/Analyser/nsrt/list-count.php @@ -291,7 +291,7 @@ protected function testOptionalKeysInListsOfTaggedUnion($row): void } if (count($row) === 1) { - assertType('array{0: int, 1?: string|null}|array{string}', $row); + assertType('array{int}|array{string}', $row); } else { assertType('array{int, string|null}', $row); } @@ -299,7 +299,7 @@ protected function testOptionalKeysInListsOfTaggedUnion($row): void if (count($row) === 2) { assertType('array{int, string|null}', $row); } else { - assertType('array{0: int, 1?: string|null}|array{string}', $row); + assertType('array{int}|array{string}', $row); } if (count($row) === 3) { @@ -354,7 +354,7 @@ protected function testOptionalKeysInUnionListWithIntRange($row, $listRow, $twoO if (count($row) >= $twoOrThree) { assertType('list{0: int, 1: string|null, 2?: int|null, 3?: float|null}', $row); } else { - assertType('array{string}|list{0: int, 1?: string|null, 2?: int|null, 3?: float|null}', $row); + assertType('array{0: int, 1?: string|null}|array{string}', $row); } if (count($row) >= $tenOrEleven) { @@ -372,7 +372,7 @@ protected function testOptionalKeysInUnionListWithIntRange($row, $listRow, $twoO if (count($row) >= $maxThree) { assertType('array{string}|list{0: int, 1?: string|null, 2?: int|null, 3?: float|null}', $row); } else { - assertType('array{string}|list{0: int, 1?: string|null, 2?: int|null, 3?: float|null}', $row); + assertType('array{0: int, 1?: string|null}|array{string}', $row); } if (count($row) >= $threeOrMoreInRangeLimit) { diff --git a/tests/PHPStan/Analyser/nsrt/narrow-tagged-union.php b/tests/PHPStan/Analyser/nsrt/narrow-tagged-union.php index 8ecf3438e77..b8bdfe121c8 100644 --- a/tests/PHPStan/Analyser/nsrt/narrow-tagged-union.php +++ b/tests/PHPStan/Analyser/nsrt/narrow-tagged-union.php @@ -101,7 +101,7 @@ public function arrayIntRangeSize(): void } if (count($x) === 1) { - assertType("array{'xy'}|array{0: 'ab', 1?: 'xy'}", $x); + assertType("array{'ab'}|array{'xy'}", $x); } else { assertType("array{}|array{0: 'ab', 1?: 'xy'}", $x); } diff --git a/tests/PHPStan/Analyser/nsrt/unsealed-array-shapes.php b/tests/PHPStan/Analyser/nsrt/unsealed-array-shapes.php index f80da767b68..0fa24cd435d 100644 --- a/tests/PHPStan/Analyser/nsrt/unsealed-array-shapes.php +++ b/tests/PHPStan/Analyser/nsrt/unsealed-array-shapes.php @@ -77,7 +77,7 @@ public function wrongKeyButResolvedToIntString(array $a): void */ public function edgeCases(array $a, array $b, array $c): void { - assertType('array{...}', $a); + assertType('array', $a); assertType('array{a: int, b?: string, c?: string}', $b); assertType('array{a: int, b: float|string, c?: string}', $c); } diff --git a/tests/PHPStan/Rules/Comparison/data/bug-7898.php b/tests/PHPStan/Rules/Comparison/data/bug-7898.php index 16e4b813ce4..6fb89d3bd2b 100644 --- a/tests/PHPStan/Rules/Comparison/data/bug-7898.php +++ b/tests/PHPStan/Rules/Comparison/data/bug-7898.php @@ -175,7 +175,7 @@ public function getCountryCode(): string public function getHasDaycationTaxesAndFees(): bool { assertType("array{US: array{bar: array{sales_tax: array{type: 'rate', unit: 'per-room-per-night'}, city_tax: array{type: 'both', unit: 'per-room-per-night'}, resort_fee: array{type: 'both', unit: 'per-room-per-night'}, additional_tax_or_fee: array{type: 'both', unit: 'per-room-per-night'}}, foo: array{tax: array{type: 'rate', unit: 'per-room-per-night'}}}, CA: array{bar: array{goods_and_services_tax: array{type: 'rate', unit: 'per-room-per-night'}, provincial_sales_tax: array{type: 'rate', unit: 'per-room-per-night'}, harmonized_sales_tax: array{type: 'rate', unit: 'per-room-per-night'}, municipal_and_regional_district_tax: array{type: 'rate', unit: 'per-room-per-night'}, additional_tax_or_fee: array{type: 'both', unit: 'per-room-per-night'}}}, SG: array{bar: array{service_charge: array{type: 'rate', unit: 'per-room-per-night'}, tax: array{type: 'rate', unit: 'per-room-per-night'}}}, TH: array{bar: array{service_charge: array{type: 'rate', unit: 'per-room-per-night'}, tax: array{type: 'rate', unit: 'per-room-per-night'}, city_tax: array{type: 'rate', unit: 'per-room-per-night'}}}, AE: array{bar: array{vat: array{type: 'rate', unit: 'per-room-per-night'}, service_charge: array{type: 'rate', unit: 'per-room-per-night'}, municipality_fee: array{type: 'rate', unit: 'per-room-per-night'}, tourism_fee: array{type: 'both', unit: 'per-room-per-night'}, destination_fee: array{type: 'both', unit: 'per-room-per-night'}}}, BH: array{bar: array{vat: array{type: 'rate', unit: 'per-room-per-night'}, service_charge: array{type: 'rate', unit: 'per-room-per-night'}, city_tax: array{type: 'rate', unit: 'per-room-per-night'}}}, HK: array{bar: array{service_charge: array{type: 'rate', unit: 'per-room-per-night'}, tax: array{type: 'rate', unit: 'per-room-per-night'}}}, ES: array{bar: array{city_tax: array{type: 'both', unit: 'per-room-per-night'}}}}", FooEnum::APPLICABLE_TAX_AND_FEES_BY_TYPE); - assertType("array{bar: array{city_tax: array{type: 'both', unit: 'per-room-per-night'}}|array{sales_tax: array{type: 'rate', unit: 'per-room-per-night'}, city_tax: array{type: 'both', unit: 'per-room-per-night'}, resort_fee: array{type: 'both', unit: 'per-room-per-night'}, additional_tax_or_fee: array{type: 'both', unit: 'per-room-per-night'}}, foo?: array{tax: array{type: 'rate', unit: 'per-room-per-night'}}}|array{bar: array{goods_and_services_tax: array{type: 'rate', unit: 'per-room-per-night'}, provincial_sales_tax: array{type: 'rate', unit: 'per-room-per-night'}, harmonized_sales_tax: array{type: 'rate', unit: 'per-room-per-night'}, municipal_and_regional_district_tax: array{type: 'rate', unit: 'per-room-per-night'}, additional_tax_or_fee: array{type: 'both', unit: 'per-room-per-night'}}}|array{bar: array{service_charge: array{type: 'rate', unit: 'per-room-per-night'}, tax: array{type: 'rate', unit: 'per-room-per-night'}, city_tax?: array{type: 'rate', unit: 'per-room-per-night'}}}|array{bar: array{vat: array{type: 'rate', unit: 'per-room-per-night'}, service_charge: array{type: 'rate', unit: 'per-room-per-night'}, city_tax: array{type: 'rate', unit: 'per-room-per-night'}}}|array{bar: array{vat: array{type: 'rate', unit: 'per-room-per-night'}, service_charge: array{type: 'rate', unit: 'per-room-per-night'}, municipality_fee: array{type: 'rate', unit: 'per-room-per-night'}, tourism_fee: array{type: 'both', unit: 'per-room-per-night'}, destination_fee: array{type: 'both', unit: 'per-room-per-night'}}}", FooEnum::APPLICABLE_TAX_AND_FEES_BY_TYPE[$this->getCountryCode()]); + assertType("array{bar: array{city_tax: array{type: 'both', unit: 'per-room-per-night'}}}|array{bar: array{goods_and_services_tax: array{type: 'rate', unit: 'per-room-per-night'}, provincial_sales_tax: array{type: 'rate', unit: 'per-room-per-night'}, harmonized_sales_tax: array{type: 'rate', unit: 'per-room-per-night'}, municipal_and_regional_district_tax: array{type: 'rate', unit: 'per-room-per-night'}, additional_tax_or_fee: array{type: 'both', unit: 'per-room-per-night'}}}|array{bar: array{sales_tax: array{type: 'rate', unit: 'per-room-per-night'}, city_tax: array{type: 'both', unit: 'per-room-per-night'}, resort_fee: array{type: 'both', unit: 'per-room-per-night'}, additional_tax_or_fee: array{type: 'both', unit: 'per-room-per-night'}}, foo: array{tax: array{type: 'rate', unit: 'per-room-per-night'}}}|array{bar: array{service_charge: array{type: 'rate', unit: 'per-room-per-night'}, tax: array{type: 'rate', unit: 'per-room-per-night'}, city_tax?: array{type: 'rate', unit: 'per-room-per-night'}}}|array{bar: array{vat: array{type: 'rate', unit: 'per-room-per-night'}, service_charge: array{type: 'rate', unit: 'per-room-per-night'}, city_tax: array{type: 'rate', unit: 'per-room-per-night'}}}|array{bar: array{vat: array{type: 'rate', unit: 'per-room-per-night'}, service_charge: array{type: 'rate', unit: 'per-room-per-night'}, municipality_fee: array{type: 'rate', unit: 'per-room-per-night'}, tourism_fee: array{type: 'both', unit: 'per-room-per-night'}, destination_fee: array{type: 'both', unit: 'per-room-per-night'}}}", FooEnum::APPLICABLE_TAX_AND_FEES_BY_TYPE[$this->getCountryCode()]); return array_key_exists(FooEnum::FOO_TYPE, FooEnum::APPLICABLE_TAX_AND_FEES_BY_TYPE[$this->getCountryCode()]); } diff --git a/tests/PHPStan/Type/Constant/ConstantArrayTypeBuilderTest.php b/tests/PHPStan/Type/Constant/ConstantArrayTypeBuilderTest.php index 12fb1c2b8f4..bd97c440358 100644 --- a/tests/PHPStan/Type/Constant/ConstantArrayTypeBuilderTest.php +++ b/tests/PHPStan/Type/Constant/ConstantArrayTypeBuilderTest.php @@ -2,7 +2,9 @@ namespace PHPStan\Type\Constant; +use PHPStan\DependencyInjection\BleedingEdgeToggle; use PHPStan\Testing\PHPStanTestCase; +use PHPStan\Type\ArrayType; use PHPStan\Type\BooleanType; use PHPStan\Type\ErrorType; use PHPStan\Type\IntegerType; @@ -313,4 +315,45 @@ public function testOptionalNullOffsetOnEmptyArrayIsPossiblyEmpty(): void $this->assertSame('array{0?: 1}', $array->describe(VerbosityLevel::precise())); } + public function testGetArrayEmptyWithUnknownSealednessStaysConstantArrayType(): void + { + $builder = ConstantArrayTypeBuilder::createEmpty(); + $array = $builder->getArray(); + $this->assertInstanceOf(ConstantArrayType::class, $array); + $this->assertSame('array{}', $array->describe(VerbosityLevel::precise())); + } + + public function testGetArraySealedEmptyStaysConstantArrayType(): void + { + $bleedingEdgeBackup = BleedingEdgeToggle::isBleedingEdge(); + BleedingEdgeToggle::setBleedingEdge(true); + try { + $builder = ConstantArrayTypeBuilder::createEmpty(); + $array = $builder->getArray(); + $this->assertInstanceOf(ConstantArrayType::class, $array); + $this->assertSame('array{}', $array->describe(VerbosityLevel::precise())); + } finally { + BleedingEdgeToggle::setBleedingEdge($bleedingEdgeBackup); + } + } + + public function testGetArrayEmptyWithRealUnsealedCollapsesToArrayType(): void + { + $builder = ConstantArrayTypeBuilder::createEmpty(); + $builder->makeUnsealed(new IntegerType(), new StringType()); + $array = $builder->getArray(); + $this->assertInstanceOf(ArrayType::class, $array); + $this->assertSame('array', $array->describe(VerbosityLevel::precise())); + } + + public function testGetArrayWithKnownKeysAndRealUnsealedStaysConstantArrayType(): void + { + $builder = ConstantArrayTypeBuilder::createEmpty(); + $builder->setOffsetValueType(new ConstantStringType('a'), new IntegerType()); + $builder->makeUnsealed(new StringType(), new StringType()); + $array = $builder->getArray(); + $this->assertInstanceOf(ConstantArrayType::class, $array); + $this->assertSame('array{a: int, ...}', $array->describe(VerbosityLevel::precise())); + } + } diff --git a/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php b/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php index dab3e8bf4bb..933d268cc4b 100644 --- a/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php +++ b/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php @@ -983,9 +983,7 @@ public function testIsSuperTypeOf($type, $otherType, TrinaryLogic $expectedResul try { $resolver = self::getContainer()->getByType(TypeStringResolver::class); if (is_string($type)) { - $resolved = $resolver->resolve($type, null); - $this->assertInstanceOf(ConstantArrayType::class, $resolved); - $type = $resolved; + $type = $resolver->resolve($type, null); } if (is_string($otherType)) { $otherType = $resolver->resolve($otherType, null); @@ -1392,9 +1390,10 @@ public function testSealedness(): void $builder = ConstantArrayTypeBuilder::createEmpty(); $builder->makeUnsealed(new IntegerType(), new StringType()); $array = $builder->getArray(); - $this->assertInstanceOf(ConstantArrayType::class, $array); - $this->assertSame(TrinaryLogic::createNo()->describe(), $array->isSealed()->describe()); - $this->assertSame(TrinaryLogic::createYes()->describe(), $array->isUnsealed()->describe()); + // No known keys + real unsealed extras now collapses to a general ArrayType + // (see ConstantArrayTypeBuilder::getArray). + $this->assertInstanceOf(ArrayType::class, $array); + $this->assertSame('array', $array->describe(VerbosityLevel::precise())); } finally { BleedingEdgeToggle::setBleedingEdge($bleedingEdgeBackup); } diff --git a/tests/PHPStan/Type/TypeCombinatorTest.php b/tests/PHPStan/Type/TypeCombinatorTest.php index a1c2f1a1ff3..834c79975d5 100644 --- a/tests/PHPStan/Type/TypeCombinatorTest.php +++ b/tests/PHPStan/Type/TypeCombinatorTest.php @@ -18,7 +18,6 @@ use PHPStan\Fixture\FinalClass; use PHPStan\Generics\FunctionsAssertType\C; use PHPStan\PhpDoc\TypeStringResolver; -use PHPStan\PhpDocParser\Parser\ParserException; use PHPStan\Reflection\Callables\SimpleImpurePoint; use PHPStan\Testing\PHPStanTestCase; use PHPStan\TrinaryLogic; @@ -735,7 +734,7 @@ public static function dataUnion(): iterable ]), ], UnionType::class, - 'array{foo: DateTimeImmutable, bar: int}|array{foo: null, bar: string}', + 'array{bar: int, foo: DateTimeImmutable}|array{bar: string, foo: null}', ], [ [ @@ -753,7 +752,7 @@ public static function dataUnion(): iterable ]), ], UnionType::class, - 'array{foo: DateTimeImmutable, bar: int}|array{foo: null}', + 'array{bar: int, foo: DateTimeImmutable}|array{foo: null}', ], [ [ @@ -775,7 +774,7 @@ public static function dataUnion(): iterable ]), ], UnionType::class, - 'array{foo: DateTimeImmutable, bar: int}|array{foo: null, bar: string, baz: int}', + 'array{bar: int, foo: DateTimeImmutable}|array{bar: string, baz: int, foo: null}', ], [ [ @@ -2658,7 +2657,7 @@ public static function dataUnion(): iterable new NonAcceptingNeverType(), ], NeverType::class, - 'never', + 'never=explicit', ]; yield [ [ @@ -2932,10 +2931,260 @@ public static function dataUnion(): iterable StringType::class, 'string', ]; + + yield [ + [ + new ConstantArrayType( + [new ConstantIntegerType(0), new ConstantIntegerType(1)], + [new IntegerType(), new UnionType([ + new ConstantStringType('0'), + new ConstantStringType('foo'), + ])], + ), + new ConstantArrayType( + [new ConstantIntegerType(0), new ConstantIntegerType(1)], + [IntegerRangeType::createAllGreaterThanOrEqualTo(0), new IntersectionType([ + new StringType(), + new AccessoryNonFalsyStringType(), + ])], + ), + ], + ConstantArrayType::class, + 'array{int, non-empty-string}', + ]; + + // current behaviour (unknown sealedness) + yield [ + [ + new ConstantArrayType( + [ + new ConstantStringType('a'), + new ConstantStringType('b'), + ], + [ + new IntegerType(), + new StringType(), + ], + ), + new ConstantArrayType( + [ + new ConstantStringType('a'), + ], + [ + new IntegerType(), + ], + ), + ], + ConstantArrayType::class, + 'array{a: int, b?: string}', + ]; + + // new behaviour with definitely sealed arrays + yield [ + [ + 'array{a: int, b: string}', + 'array{a: int}', + ], + ConstantArrayType::class, + 'array{a: int, b?: string}', + ]; + + yield [ + [ + 'array{a: true, b: string}', + 'array{a: false}', + ], + UnionType::class, + 'array{a: false}|array{a: true, b: string}', + ]; + + yield [ + [ + 'array{int, 0|\'foo\'}', + 'array{int<0, max>, non-falsy-string}', + ], + ConstantArrayType::class, + 'array{int, 0|non-falsy-string}', + ]; + + yield [ + [ + 'array{a: int, b: string}', + 'array{a: int<0, max>, ...}', + ], + ConstantArrayType::class, + 'array{a: int, ...}', + ]; + + yield [ + [ + 'array{a: int, b: string}', + 'array{a: int<0, max>, ...}', + ], + ConstantArrayType::class, + 'array{a: int, b?: string, ...}', + ]; + + yield [ + [ + 'array{a: int, b: string, ...}', + 'array{a: int<0, max>, ...}', + ], + ConstantArrayType::class, + 'array{a: int, ...}', + ]; + + yield [ + [ + 'array{a: int, ...}', + 'array{b: string, ...}', + ], + IntersectionType::class, + 'non-empty-array', + ]; + + yield [ + [ + 'array{a: int, ...}', + 'array{b: string, ...}', + ], + IntersectionType::class, + 'non-empty-array{a?: int, b?: string, ...}', + ]; + + yield [ + [ + 'array{a: int, ...}', + 'array{b: string, ...}', + ], + IntersectionType::class, + 'non-empty-array{a?: int, ...}', + ]; + + yield [ + [ + 'array{a: string, ...}', + 'array{b: string, ...}', + ], + IntersectionType::class, + 'non-empty-array', + ]; + + yield [ + [ + 'array{a: int, ...}', + 'array{a: int, ...}', + ], + ConstantArrayType::class, + 'array{a: int, ...}', + ]; + + yield [ + [ + 'array{a: int, ...}', + 'array{a: int, ...}', + ], + ConstantArrayType::class, + 'array{a: int, ...}', + ]; + + yield [ + [ + 'array{...>}', + 'array{...>}', + ], + ArrayType::class, + 'array', + ]; + + yield [ + [ + 'array{...}', + 'array{...}', + ], + ArrayType::class, + 'array', + ]; + + yield [ + [ + 'array{...}', + 'array{...}', + ], + ArrayType::class, + 'array', + ]; + + yield [ + [ + 'array{a: int, ...}', + 'array{...}', + ], + ArrayType::class, + 'array', + ]; + + yield [ + [ + 'array{a: non-empty-string, ...}', + 'array{...}', + ], + ArrayType::class, + 'array', + ]; + + yield [ + [ + 'array{a: int, ...}', + 'array{a: string, ...}', + ], + ConstantArrayType::class, + 'array{a: int|string, ...}', + ]; + + yield [ + [ + 'array{a: int}', + 'array{...}', + ], + ArrayType::class, + 'array<\'a\'|int, int>', + ]; + + yield [ + [ + 'array{a: int}', + 'array{...}', + ], + ArrayType::class, + 'array', + ]; + + // Both unsealed with a shared known key → result preserves the shape as ConstantArrayType + // (only the "empty known keys + real unsealed extras" combination collapses to ArrayType). + yield [ + [ + 'array{a: int, ...}', + 'array{a: int, ...}', + ], + ConstantArrayType::class, + 'array{a: int, ...}', + ]; + + // Sealed empty arrays stay as ConstantArrayType — explicit-Never unsealed + // is NOT "real" extras, so it doesn't trigger the ArrayType collapse. + yield [ + [ + 'array{}', + 'array{}', + ], + ConstantArrayType::class, + 'array{}', + ]; } /** - * @param list $types + * @param list|list $types * @param class-string $expectedTypeClass */ #[DataProvider('dataUnion')] @@ -2945,29 +3194,22 @@ public function testUnion( string $expectedTypeDescription, ): void { - $actualType = TypeCombinator::union(...$types); - $actualTypeDescription = $actualType->describe(VerbosityLevel::precise()); - if ($actualType instanceof MixedType) { - if ($actualType->isExplicitMixed()) { - $actualTypeDescription .= '=explicit'; - } else { - $actualTypeDescription .= '=implicit'; - } - } - if (get_class($actualType) === ObjectType::class) { - $actualClassReflection = $actualType->getClassReflection(); - if ( - $actualClassReflection !== null - && $actualClassReflection->hasFinalByKeywordOverride() - && $actualClassReflection->isFinal() - ) { - $actualTypeDescription .= '=final'; + $bleedingEdgeBackup = BleedingEdgeToggle::isBleedingEdge(); + $typeStringResolver = self::getContainer()->getByType(TypeStringResolver::class); + foreach ($types as $i => $type) { + BleedingEdgeToggle::setBleedingEdge(true); + if (!is_string($type)) { + continue; } + + $types[$i] = $typeStringResolver->resolve($type, null); } + BleedingEdgeToggle::setBleedingEdge($bleedingEdgeBackup); + $actualType = TypeCombinator::union(...$types); $this->assertSame( $expectedTypeDescription, - $actualTypeDescription, + self::describeForIntersectTest($actualType), sprintf('union(%s)', implode(', ', array_map( static fn (Type $type): string => $type->describe(VerbosityLevel::precise()), $types, @@ -2991,7 +3233,7 @@ public function testUnion( } /** - * @param list $types + * @param list|list $types * @param class-string $expectedTypeClass */ #[DataProvider('dataUnion')] @@ -3002,28 +3244,23 @@ public function testUnionInversed( ): void { $types = array_reverse($types); - $actualType = TypeCombinator::union(...$types); - $actualTypeDescription = $actualType->describe(VerbosityLevel::precise()); - if ($actualType instanceof MixedType) { - if ($actualType->isExplicitMixed()) { - $actualTypeDescription .= '=explicit'; - } else { - $actualTypeDescription .= '=implicit'; - } - } - if (get_class($actualType) === ObjectType::class) { - $actualClassReflection = $actualType->getClassReflection(); - if ( - $actualClassReflection !== null - && $actualClassReflection->hasFinalByKeywordOverride() - && $actualClassReflection->isFinal() - ) { - $actualTypeDescription .= '=final'; + $bleedingEdgeBackup = BleedingEdgeToggle::isBleedingEdge(); + $typeStringResolver = self::getContainer()->getByType(TypeStringResolver::class); + foreach ($types as $i => $type) { + BleedingEdgeToggle::setBleedingEdge(true); + if (!is_string($type)) { + continue; } + + $types[$i] = $typeStringResolver->resolve($type, null); } + + BleedingEdgeToggle::setBleedingEdge($bleedingEdgeBackup); + + $actualType = TypeCombinator::union(...$types); $this->assertSame( $expectedTypeDescription, - $actualTypeDescription, + self::describeForIntersectTest($actualType), sprintf('union(%s)', implode(', ', array_map( static fn (Type $type): string => $type->describe(VerbosityLevel::precise()), $types, @@ -5244,8 +5481,17 @@ public static function dataIntersect(): iterable 'array{...>}', 'array{...>}', ], + ArrayType::class, + 'array>', + ]; + + yield [ + [ + 'array{a: int, ...>}', + 'array{a: int, ...>}', + ], ConstantArrayType::class, - 'array{...>}', + 'array{a: int, ...>}', ]; // both unsealed, unsealed key types incompatible — no valid key overlap @@ -5274,8 +5520,8 @@ public static function dataIntersect(): iterable 'array{a: int, ...}', 'array{...}', ], - NeverType::class, - '*NEVER*=implicit', + ConstantArrayType::class, + 'array{a: *NEVER*}', ]; // both unsealed: known key value is compatible with other side's unsealed value @@ -5285,7 +5531,7 @@ public static function dataIntersect(): iterable 'array{...}', ], ConstantArrayType::class, - 'array{a: non-empty-string, ...}', + 'array{a: non-empty-string}', ]; // both unsealed with same known key, value types incompatible at that key @@ -5344,11 +5590,13 @@ public function testIntersect( BleedingEdgeToggle::setBleedingEdge($bleedingEdgeBackup); $actualType = TypeCombinator::intersect(...$types); - $actualTypeDescription = self::describeForIntersectTest($actualType); - $this->assertSame( - self::sortExpectedDescription($expectedTypeDescription, $typeStringResolver), - $actualTypeDescription, + $expectedTypeDescription, + self::describeForIntersectTest($actualType), + sprintf('intersect(%s)', implode(', ', array_map( + static fn (Type $type): string => $type->describe(VerbosityLevel::precise()), + $types, + ))), ); $this->assertInstanceOf($expectedTypeClass, $actualType); } @@ -5378,20 +5626,26 @@ public function testIntersectInversed( BleedingEdgeToggle::setBleedingEdge($bleedingEdgeBackup); $actualType = TypeCombinator::intersect(...array_reverse($types)); - $actualTypeDescription = self::describeForIntersectTest($actualType); - $this->assertSame( - self::sortExpectedDescription($expectedTypeDescription, $typeStringResolver), - $actualTypeDescription, + $expectedTypeDescription, + self::describeForIntersectTest($actualType), + sprintf('union(%s)', implode(', ', array_map( + static fn (Type $type): string => $type->describe(VerbosityLevel::precise()), + $types, + ))), ); $this->assertInstanceOf($expectedTypeClass, $actualType); } private static function describeForIntersectTest(Type $type): string { - if ($type instanceof ConstantArrayType) { - $type = $type->sortKeys(); - } + $type = TypeTraverser::map($type, static function (Type $type, callable $traverse): Type { + if ($type instanceof ConstantArrayType) { + return $traverse($type->sortKeys()); + } + + return $traverse($type); + }); $description = $type->describe(VerbosityLevel::precise()); if ($type instanceof MixedType) { $description .= $type->isExplicitMixed() ? '=explicit' : '=implicit'; @@ -5412,25 +5666,6 @@ private static function describeForIntersectTest(Type $type): string return $description; } - private static function sortExpectedDescription(string $description, TypeStringResolver $resolver): string - { - $bleedingEdgeBackup = BleedingEdgeToggle::isBleedingEdge(); - BleedingEdgeToggle::setBleedingEdge(true); - try { - $type = $resolver->resolve($description, null); - } catch (ParserException) { - return $description; - } finally { - BleedingEdgeToggle::setBleedingEdge($bleedingEdgeBackup); - } - - if ($type instanceof ConstantArrayType) { - return $type->sortKeys()->describe(VerbosityLevel::precise()); - } - - return $description; - } - public static function dataRemove(): array { return [ From a394713023bccb14cf5ecbfa0dd9ee213061a93c Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Thu, 23 Apr 2026 12:54:48 +0200 Subject: [PATCH 07/66] Regression tests --- tests/PHPStan/Analyser/nsrt/bug-14032.php | 47 ++ .../Rules/Methods/ReturnTypeRuleTest.php | 6 + .../PHPStan/Rules/Methods/data/bug-12110.php | 670 ++++++++++++++++++ 3 files changed, 723 insertions(+) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-14032.php create mode 100644 tests/PHPStan/Rules/Methods/data/bug-12110.php diff --git a/tests/PHPStan/Analyser/nsrt/bug-14032.php b/tests/PHPStan/Analyser/nsrt/bug-14032.php new file mode 100644 index 00000000000..ceca2a00494 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-14032.php @@ -0,0 +1,47 @@ +analyse([__DIR__ . '/../../Analyser/nsrt/bug-14553.php'], []); } + #[RequiresPhp('>= 8.2.0')] + public function testBug12110(): void + { + $this->analyse([__DIR__ . '/data/bug-12110.php'], []); + } + } diff --git a/tests/PHPStan/Rules/Methods/data/bug-12110.php b/tests/PHPStan/Rules/Methods/data/bug-12110.php new file mode 100644 index 00000000000..fbd1e2f8c81 --- /dev/null +++ b/tests/PHPStan/Rules/Methods/data/bug-12110.php @@ -0,0 +1,670 @@ += 8.2 + +namespace Bug12110; + +class OwnerModel {}; +class PermissionsModel {}; + +final readonly class TemplateRepositoryModel implements \JsonSerializable +{ + public function __construct( + /** + * @var null|int + */ + public null|int $id = null, + /** + * @var null|string + */ + public null|string $node_id = null, + /** + * @var null|string + */ + public null|string $name = null, + /** + * @var null|string + */ + public null|string $full_name = null, + /** + * @var null|OwnerModel + */ + public null|OwnerModel $owner = null, + /** + * @var null|bool + */ + public null|bool $private = null, + /** + * @var null|string + */ + public null|string $html_url = null, + /** + * @var null|string + */ + public null|string $description = null, + /** + * @var null|bool + */ + public null|bool $fork = null, + /** + * @var null|string + */ + public null|string $url = null, + /** + * @var null|string + */ + public null|string $archive_url = null, + /** + * @var null|string + */ + public null|string $assignees_url = null, + /** + * @var null|string + */ + public null|string $blobs_url = null, + /** + * @var null|string + */ + public null|string $branches_url = null, + /** + * @var null|string + */ + public null|string $collaborators_url = null, + /** + * @var null|string + */ + public null|string $comments_url = null, + /** + * @var null|string + */ + public null|string $commits_url = null, + /** + * @var null|string + */ + public null|string $compare_url = null, + /** + * @var null|string + */ + public null|string $contents_url = null, + /** + * @var null|string + */ + public null|string $contributors_url = null, + /** + * @var null|string + */ + public null|string $deployments_url = null, + /** + * @var null|string + */ + public null|string $downloads_url = null, + /** + * @var null|string + */ + public null|string $events_url = null, + /** + * @var null|string + */ + public null|string $forks_url = null, + /** + * @var null|string + */ + public null|string $git_commits_url = null, + /** + * @var null|string + */ + public null|string $git_refs_url = null, + /** + * @var null|string + */ + public null|string $git_tags_url = null, + /** + * @var null|string + */ + public null|string $git_url = null, + /** + * @var null|string + */ + public null|string $issue_comment_url = null, + /** + * @var null|string + */ + public null|string $issue_events_url = null, + /** + * @var null|string + */ + public null|string $issues_url = null, + /** + * @var null|string + */ + public null|string $keys_url = null, + /** + * @var null|string + */ + public null|string $labels_url = null, + /** + * @var null|string + */ + public null|string $languages_url = null, + /** + * @var null|string + */ + public null|string $merges_url = null, + /** + * @var null|string + */ + public null|string $milestones_url = null, + /** + * @var null|string + */ + public null|string $notifications_url = null, + /** + * @var null|string + */ + public null|string $pulls_url = null, + /** + * @var null|string + */ + public null|string $releases_url = null, + /** + * @var null|string + */ + public null|string $ssh_url = null, + /** + * @var null|string + */ + public null|string $stargazers_url = null, + /** + * @var null|string + */ + public null|string $statuses_url = null, + /** + * @var null|string + */ + public null|string $subscribers_url = null, + /** + * @var null|string + */ + public null|string $subscription_url = null, + /** + * @var null|string + */ + public null|string $tags_url = null, + /** + * @var null|string + */ + public null|string $teams_url = null, + /** + * @var null|string + */ + public null|string $trees_url = null, + /** + * @var null|string + */ + public null|string $clone_url = null, + /** + * @var null|string + */ + public null|string $mirror_url = null, + /** + * @var null|string + */ + public null|string $hooks_url = null, + /** + * @var null|string + */ + public null|string $svn_url = null, + /** + * @var null|string + */ + public null|string $homepage = null, + /** + * @var null|string + */ + public null|string $language = null, + /** + * @var null|int + */ + public null|int $forks_count = null, + /** + * @var null|int + */ + public null|int $stargazers_count = null, + /** + * @var null|int + */ + public null|int $watchers_count = null, + /** + * @var null|int + */ + public null|int $size = null, + /** + * @var null|string + */ + public null|string $default_branch = null, + /** + * @var null|int + */ + public null|int $open_issues_count = null, + /** + * @var null|bool + */ + public null|bool $is_template = null, + /** + * @var null|list + */ + public null|array $topics = null, + /** + * @var null|bool + */ + public null|bool $has_issues = null, + /** + * @var null|bool + */ + public null|bool $has_projects = null, + /** + * @var null|bool + */ + public null|bool $has_wiki = null, + /** + * @var null|bool + */ + public null|bool $has_pages = null, + /** + * @var null|bool + */ + public null|bool $has_downloads = null, + /** + * @var null|bool + */ + public null|bool $archived = null, + /** + * @var null|bool + */ + public null|bool $disabled = null, + /** + * @var null|string + */ + public null|string $visibility = null, + /** + * @var null|string + */ + public null|string $pushed_at = null, + /** + * @var null|string + */ + public null|string $created_at = null, + /** + * @var null|string + */ + public null|string $updated_at = null, + /** + * @var null|PermissionsModel + */ + public null|PermissionsModel $permissions = null, + /** + * @var null|bool + */ + public null|bool $allow_rebase_merge = null, + /** + * @var null|string + */ + public null|string $template_repository = null, + /** + * @var null|string + */ + public null|string $temp_clone_token = null, + /** + * @var null|bool + */ + public null|bool $allow_squash_merge = null, + /** + * @var null|bool + */ + public null|bool $delete_branch_on_merge = null, + /** + * @var null|bool + */ + public null|bool $allow_merge_commit = null, + /** + * @var null|int + */ + public null|int $subscribers_count = null, + /** + * @var null|int + */ + public null|int $network_count = null, + ) {} + + /** + * @return array{ + * 'id'?: int, + * 'node_id'?: string, + * 'name'?: string, + * 'full_name'?: string, + * 'owner'?: OwnerModel, + * 'private'?: bool, + * 'html_url'?: string, + * 'description'?: string, + * 'fork'?: bool, + * 'url'?: string, + * 'archive_url'?: string, + * 'assignees_url'?: string, + * 'blobs_url'?: string, + * 'branches_url'?: string, + * 'collaborators_url'?: string, + * 'comments_url'?: string, + * 'commits_url'?: string, + * 'compare_url'?: string, + * 'contents_url'?: string, + * 'contributors_url'?: string, + * 'deployments_url'?: string, + * 'downloads_url'?: string, + * 'events_url'?: string, + * 'forks_url'?: string, + * 'git_commits_url'?: string, + * 'git_refs_url'?: string, + * 'git_tags_url'?: string, + * 'git_url'?: string, + * 'issue_comment_url'?: string, + * 'issue_events_url'?: string, + * 'issues_url'?: string, + * 'keys_url'?: string, + * 'labels_url'?: string, + * 'languages_url'?: string, + * 'merges_url'?: string, + * 'milestones_url'?: string, + * 'notifications_url'?: string, + * 'pulls_url'?: string, + * 'releases_url'?: string, + * 'ssh_url'?: string, + * 'stargazers_url'?: string, + * 'statuses_url'?: string, + * 'subscribers_url'?: string, + * 'subscription_url'?: string, + * 'tags_url'?: string, + * 'teams_url'?: string, + * 'trees_url'?: string, + * 'clone_url'?: string, + * 'mirror_url'?: string, + * 'hooks_url'?: string, + * 'svn_url'?: string, + * 'homepage'?: string, + * 'language'?: string, + * 'forks_count'?: int, + * 'stargazers_count'?: int, + * 'watchers_count'?: int, + * 'size'?: int, + * 'default_branch'?: string, + * 'open_issues_count'?: int, + * 'is_template'?: bool, + * 'topics'?: list, + * 'has_issues'?: bool, + * 'has_projects'?: bool, + * 'has_wiki'?: bool, + * 'has_pages'?: bool, + * 'has_downloads'?: bool, + * 'archived'?: bool, + * 'disabled'?: bool, + * 'visibility'?: string, + * 'pushed_at'?: string, + * 'created_at'?: string, + * 'updated_at'?: string, + * 'permissions'?: PermissionsModel, + * 'allow_rebase_merge'?: bool, + * 'template_repository'?: string, + * 'temp_clone_token'?: string, + * 'allow_squash_merge'?: bool, + * 'delete_branch_on_merge'?: bool, + * 'allow_merge_commit'?: bool, + * 'subscribers_count'?: int, + * 'network_count'?: int, + * } + */ + public function jsonSerialize(): array + { + $properties = []; + if ($this->id !== null) { + $properties['id'] = $this->id; + } + if ($this->node_id !== null) { + $properties['node_id'] = $this->node_id; + } + if ($this->name !== null) { + $properties['name'] = $this->name; + } + if ($this->full_name !== null) { + $properties['full_name'] = $this->full_name; + } + if ($this->owner !== null) { + $properties['owner'] = $this->owner; + } + if ($this->private !== null) { + $properties['private'] = $this->private; + } + if ($this->html_url !== null) { + $properties['html_url'] = $this->html_url; + } + if ($this->description !== null) { + $properties['description'] = $this->description; + } + if ($this->fork !== null) { + $properties['fork'] = $this->fork; + } + if ($this->url !== null) { + $properties['url'] = $this->url; + } + if ($this->archive_url !== null) { + $properties['archive_url'] = $this->archive_url; + } + if ($this->assignees_url !== null) { + $properties['assignees_url'] = $this->assignees_url; + } + if ($this->blobs_url !== null) { + $properties['blobs_url'] = $this->blobs_url; + } + if ($this->branches_url !== null) { + $properties['branches_url'] = $this->branches_url; + } + if ($this->collaborators_url !== null) { + $properties['collaborators_url'] = $this->collaborators_url; + } + if ($this->comments_url !== null) { + $properties['comments_url'] = $this->comments_url; + } + if ($this->commits_url !== null) { + $properties['commits_url'] = $this->commits_url; + } + if ($this->compare_url !== null) { + $properties['compare_url'] = $this->compare_url; + } + if ($this->contents_url !== null) { + $properties['contents_url'] = $this->contents_url; + } + if ($this->contributors_url !== null) { + $properties['contributors_url'] = $this->contributors_url; + } + if ($this->deployments_url !== null) { + $properties['deployments_url'] = $this->deployments_url; + } + if ($this->downloads_url !== null) { + $properties['downloads_url'] = $this->downloads_url; + } + if ($this->events_url !== null) { + $properties['events_url'] = $this->events_url; + } + if ($this->forks_url !== null) { + $properties['forks_url'] = $this->forks_url; + } + if ($this->git_commits_url !== null) { + $properties['git_commits_url'] = $this->git_commits_url; + } + if ($this->git_refs_url !== null) { + $properties['git_refs_url'] = $this->git_refs_url; + } + if ($this->git_tags_url !== null) { + $properties['git_tags_url'] = $this->git_tags_url; + } + if ($this->git_url !== null) { + $properties['git_url'] = $this->git_url; + } + if ($this->issue_comment_url !== null) { + $properties['issue_comment_url'] = $this->issue_comment_url; + } + if ($this->issue_events_url !== null) { + $properties['issue_events_url'] = $this->issue_events_url; + } + if ($this->issues_url !== null) { + $properties['issues_url'] = $this->issues_url; + } + if ($this->keys_url !== null) { + $properties['keys_url'] = $this->keys_url; + } + if ($this->labels_url !== null) { + $properties['labels_url'] = $this->labels_url; + } + if ($this->languages_url !== null) { + $properties['languages_url'] = $this->languages_url; + } + if ($this->merges_url !== null) { + $properties['merges_url'] = $this->merges_url; + } + if ($this->milestones_url !== null) { + $properties['milestones_url'] = $this->milestones_url; + } + if ($this->notifications_url !== null) { + $properties['notifications_url'] = $this->notifications_url; + } + if ($this->pulls_url !== null) { + $properties['pulls_url'] = $this->pulls_url; + } + if ($this->releases_url !== null) { + $properties['releases_url'] = $this->releases_url; + } + if ($this->ssh_url !== null) { + $properties['ssh_url'] = $this->ssh_url; + } + if ($this->stargazers_url !== null) { + $properties['stargazers_url'] = $this->stargazers_url; + } + if ($this->statuses_url !== null) { + $properties['statuses_url'] = $this->statuses_url; + } + if ($this->subscribers_url !== null) { + $properties['subscribers_url'] = $this->subscribers_url; + } + if ($this->subscription_url !== null) { + $properties['subscription_url'] = $this->subscription_url; + } + if ($this->tags_url !== null) { + $properties['tags_url'] = $this->tags_url; + } + if ($this->teams_url !== null) { + $properties['teams_url'] = $this->teams_url; + } + if ($this->trees_url !== null) { + $properties['trees_url'] = $this->trees_url; + } + if ($this->clone_url !== null) { + $properties['clone_url'] = $this->clone_url; + } + if ($this->mirror_url !== null) { + $properties['mirror_url'] = $this->mirror_url; + } + if ($this->hooks_url !== null) { + $properties['hooks_url'] = $this->hooks_url; + } + if ($this->svn_url !== null) { + $properties['svn_url'] = $this->svn_url; + } + if ($this->homepage !== null) { + $properties['homepage'] = $this->homepage; + } + if ($this->language !== null) { + $properties['language'] = $this->language; + } + if ($this->forks_count !== null) { + $properties['forks_count'] = $this->forks_count; + } + if ($this->stargazers_count !== null) { + $properties['stargazers_count'] = $this->stargazers_count; + } + if ($this->watchers_count !== null) { + $properties['watchers_count'] = $this->watchers_count; + } + if ($this->size !== null) { + $properties['size'] = $this->size; + } + if ($this->default_branch !== null) { + $properties['default_branch'] = $this->default_branch; + } + if ($this->open_issues_count !== null) { + $properties['open_issues_count'] = $this->open_issues_count; + } + if ($this->is_template !== null) { + $properties['is_template'] = $this->is_template; + } + if ($this->topics !== null) { + $properties['topics'] = $this->topics; + } + if ($this->has_issues !== null) { + $properties['has_issues'] = $this->has_issues; + } + if ($this->has_projects !== null) { + $properties['has_projects'] = $this->has_projects; + } + if ($this->has_wiki !== null) { + $properties['has_wiki'] = $this->has_wiki; + } + if ($this->has_pages !== null) { + $properties['has_pages'] = $this->has_pages; + } + if ($this->has_downloads !== null) { + $properties['has_downloads'] = $this->has_downloads; + } + if ($this->archived !== null) { + $properties['archived'] = $this->archived; + } + if ($this->disabled !== null) { + $properties['disabled'] = $this->disabled; + } + if ($this->visibility !== null) { + $properties['visibility'] = $this->visibility; + } + if ($this->pushed_at !== null) { + $properties['pushed_at'] = $this->pushed_at; + } + if ($this->created_at !== null) { + $properties['created_at'] = $this->created_at; + } + if ($this->updated_at !== null) { + $properties['updated_at'] = $this->updated_at; + } + if ($this->permissions !== null) { + $properties['permissions'] = $this->permissions; + } + if ($this->allow_rebase_merge !== null) { + $properties['allow_rebase_merge'] = $this->allow_rebase_merge; + } + if ($this->template_repository !== null) { + $properties['template_repository'] = $this->template_repository; + } + if ($this->temp_clone_token !== null) { + $properties['temp_clone_token'] = $this->temp_clone_token; + } + if ($this->allow_squash_merge !== null) { + $properties['allow_squash_merge'] = $this->allow_squash_merge; + } + if ($this->delete_branch_on_merge !== null) { + $properties['delete_branch_on_merge'] = $this->delete_branch_on_merge; + } + if ($this->allow_merge_commit !== null) { + $properties['allow_merge_commit'] = $this->allow_merge_commit; + } + if ($this->subscribers_count !== null) { + $properties['subscribers_count'] = $this->subscribers_count; + } + if ($this->network_count !== null) { + $properties['network_count'] = $this->network_count; + } + return $properties; + } +} From e22e4200ae38bb9643a8f5e8353f8719b62c7179 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Thu, 23 Apr 2026 14:38:29 +0200 Subject: [PATCH 08/66] Optimization --- phpstan-baseline.neon | 8 +- src/Analyser/Ignore/IgnoredError.php | 5 +- src/Analyser/Ignore/IgnoredErrorHelper.php | 18 +++- .../Ignore/IgnoredErrorHelperResult.php | 91 ++++++++++++++----- src/Node/AnonymousClassNode.php | 2 +- src/Type/Constant/ConstantArrayType.php | 1 + src/Type/FileTypeMapper.php | 4 +- src/Type/TypeCombinator.php | 84 ++++++++++++----- .../Analyser/Ignore/IgnoreLexerTest.php | 2 +- .../BaselineNeonErrorFormatterTest.php | 2 +- 10 files changed, 155 insertions(+), 62 deletions(-) diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 4d07589b5bb..eb573b5d723 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -48,12 +48,6 @@ parameters: count: 1 path: src/Analyser/ExprHandler/PreIncHandler.php - - - rawMessage: Cannot assign offset 'realCount' to array|string. - identifier: offsetAssign.dimType - count: 1 - path: src/Analyser/Ignore/IgnoredErrorHelperResult.php - - rawMessage: Casting to string something that's already string. identifier: cast.useless @@ -1722,7 +1716,7 @@ parameters: - rawMessage: 'Doing instanceof PHPStan\Type\Constant\ConstantArrayType is error-prone and deprecated. Use Type::getConstantArrays() instead.' identifier: phpstanApi.instanceofType - count: 19 + count: 20 path: src/Type/TypeCombinator.php - diff --git a/src/Analyser/Ignore/IgnoredError.php b/src/Analyser/Ignore/IgnoredError.php index 33fd610a02d..9476547059d 100644 --- a/src/Analyser/Ignore/IgnoredError.php +++ b/src/Analyser/Ignore/IgnoredError.php @@ -14,11 +14,14 @@ use function sprintf; use function str_replace; +/** + * @phpstan-import-type ExpandedIgnoredErrorData from IgnoredErrorHelperResult + */ final class IgnoredError { /** - * @param array{message?: string, rawMessage?: string, identifier?: string, identifiers?: list, path?: string, paths?: list}|string $ignoredError + * @param ExpandedIgnoredErrorData|string $ignoredError */ public static function getIgnoredErrorLabel(array|string $ignoredError): string { diff --git a/src/Analyser/Ignore/IgnoredErrorHelper.php b/src/Analyser/Ignore/IgnoredErrorHelper.php index d3394bcb0bb..dd165407ba6 100644 --- a/src/Analyser/Ignore/IgnoredErrorHelper.php +++ b/src/Analyser/Ignore/IgnoredErrorHelper.php @@ -14,12 +14,26 @@ use function is_file; use function sprintf; +/** + * @phpstan-type IgnoredErrorData = array{ + * message?: string, + * messages?: list, + * rawMessage?: string, + * rawMessages?: list, + * identifier?: string, + * identifiers?: list, + * path?: string, + * paths?: list, + * count?: int, + * reportUnmatched?: bool, + * } + */ #[AutowiredService] final class IgnoredErrorHelper { /** - * @param (string|mixed[])[] $ignoreErrors + * @param (string|IgnoredErrorData)[] $ignoreErrors */ public function __construct( private FileHelper $fileHelper, @@ -106,7 +120,7 @@ public function initialize(): IgnoredErrorHelperResult continue; } - $reportUnmatched = (bool) ($uniquedExpandedIgnoreErrors[$key]['reportUnmatched'] ?? $this->reportUnmatchedIgnoredErrors); + $reportUnmatched = $uniquedExpandedIgnoreErrors[$key]['reportUnmatched'] ?? $this->reportUnmatchedIgnoredErrors; if (!$reportUnmatched) { $reportUnmatched = $ignoreError['reportUnmatched'] ?? $this->reportUnmatchedIgnoredErrors; } diff --git a/src/Analyser/Ignore/IgnoredErrorHelperResult.php b/src/Analyser/Ignore/IgnoredErrorHelperResult.php index ea4c1295309..5334fb7f6ea 100644 --- a/src/Analyser/Ignore/IgnoredErrorHelperResult.php +++ b/src/Analyser/Ignore/IgnoredErrorHelperResult.php @@ -13,14 +13,39 @@ use function is_string; use function sprintf; +/** + * `IgnoredErrorHelper` may collapse several configured ignores into one + * merged entry, so `message`/`rawMessage`/`identifier` are nullable here. + * It also attaches `realPath` once the configured path is resolved. The + * `messages`/`rawMessages`/`identifiers` keys remain in the inferred shape + * even after expansion + unset (PHPStan does not strip optional keys via + * negative isset on sealed shapes), so the type lists them explicitly here + * — they are never read, only tolerated. `paths` is `array, + * string>` rather than `list` because `process()` unsets matched + * entries by index, breaking list-ness. + * + * @phpstan-type ExpandedIgnoredErrorData = array{ + * message?: string|null, + * rawMessage?: string|null, + * identifier?: string|null, + * messages?: list, + * rawMessages?: list, + * identifiers?: list, + * path?: string, + * paths?: array, string>, + * count?: int, + * reportUnmatched?: bool, + * realPath?: string, + * } + */ final class IgnoredErrorHelperResult { /** * @param list $errors - * @param array> $otherIgnoreErrors - * @param array>> $ignoreErrorsByFile - * @param (string|mixed[])[] $ignoreErrors + * @param array, ignoreError: string|ExpandedIgnoredErrorData}> $otherIgnoreErrors + * @param array, ignoreError: string|ExpandedIgnoredErrorData}>> $ignoreErrorsByFile + * @param (string|ExpandedIgnoredErrorData)[] $ignoreErrors */ public function __construct( private FileHelper $fileHelper, @@ -55,7 +80,14 @@ public function process( $unmatchedIgnoredErrors = $this->ignoreErrors; $stringErrors = []; - $processIgnoreError = function (Error $error, int $i, $ignore) use (&$unmatchedIgnoredErrors, &$stringErrors): bool { + // Per-entry runtime state for `count`-bounded ignores. Tracked in side + // maps keyed by the same index so `$unmatchedIgnoredErrors` keeps the + // `(string|ExpandedIgnoredErrorData)[]` shape across the closure's + // offset writes — otherwise PHPStan widens it to `array`. + $realCounts = []; + $matchedAt = []; + + $processIgnoreError = function (Error $error, int $i, $ignore) use (&$unmatchedIgnoredErrors, &$stringErrors, &$realCounts, &$matchedAt): bool { $shouldBeIgnored = false; if (is_string($ignore)) { $shouldBeIgnored = IgnoredError::shouldIgnore($this->fileHelper, $error, ignoredErrorPattern: $ignore, ignoredErrorMessage: null, identifier: null, path: null); @@ -67,13 +99,11 @@ public function process( $shouldBeIgnored = IgnoredError::shouldIgnore($this->fileHelper, $error, ignoredErrorPattern: $ignore['message'] ?? null, ignoredErrorMessage: $ignore['rawMessage'] ?? null, identifier: $ignore['identifier'] ?? null, path: $ignore['path']); if ($shouldBeIgnored) { if (isset($ignore['count'])) { - $realCount = $unmatchedIgnoredErrors[$i]['realCount'] ?? 0; - $realCount++; - $unmatchedIgnoredErrors[$i]['realCount'] = $realCount; + $realCount = ($realCounts[$i] ?? 0) + 1; + $realCounts[$i] = $realCount; - if (!isset($unmatchedIgnoredErrors[$i]['file'])) { - $unmatchedIgnoredErrors[$i]['file'] = $error->getFile(); - $unmatchedIgnoredErrors[$i]['line'] = $error->getLine(); + if (!isset($matchedAt[$i])) { + $matchedAt[$i] = ['file' => $error->getFile(), 'line' => $error->getLine()]; } if ($realCount > $ignore['count']) { @@ -171,48 +201,59 @@ public function process( $errors = array_values($errors); - foreach ($unmatchedIgnoredErrors as $unmatchedIgnoredError) { - if (!isset($unmatchedIgnoredError['count']) || !isset($unmatchedIgnoredError['realCount'])) { + foreach ($unmatchedIgnoredErrors as $i => $unmatchedIgnoredError) { + if (!is_array($unmatchedIgnoredError) || !isset($unmatchedIgnoredError['count']) || !isset($realCounts[$i])) { continue; } - if ($unmatchedIgnoredError['realCount'] <= $unmatchedIgnoredError['count']) { + $realCount = $realCounts[$i]; + if ($realCount <= $unmatchedIgnoredError['count']) { continue; } + $matchedFile = $matchedAt[$i]['file'] ?? null; + $matchedLine = $matchedAt[$i]['line'] ?? null; + $errors[] = (new Error(sprintf( '%s %s is expected to occur %d %s, but occurred %d %s.', IgnoredError::getIgnoredErrorLabel($unmatchedIgnoredError), IgnoredError::stringifyPattern($unmatchedIgnoredError), $unmatchedIgnoredError['count'], $unmatchedIgnoredError['count'] === 1 ? 'time' : 'times', - $unmatchedIgnoredError['realCount'], - $unmatchedIgnoredError['realCount'] === 1 ? 'time' : 'times', - ), $unmatchedIgnoredError['file'], $unmatchedIgnoredError['line'], false))->withIdentifier('ignore.count'); + $realCount, + $realCount === 1 ? 'time' : 'times', + ), $matchedFile ?? '', $matchedLine, false))->withIdentifier('ignore.count'); } $analysedFilesKeys = array_fill_keys($analysedFiles, true); if (!$hasInternalErrors) { - foreach ($unmatchedIgnoredErrors as $unmatchedIgnoredError) { - $reportUnmatched = $unmatchedIgnoredError['reportUnmatched'] ?? $this->reportUnmatchedIgnoredErrors; + foreach ($unmatchedIgnoredErrors as $i => $unmatchedIgnoredError) { + $reportUnmatched = is_array($unmatchedIgnoredError) + ? ($unmatchedIgnoredError['reportUnmatched'] ?? $this->reportUnmatchedIgnoredErrors) + : $this->reportUnmatchedIgnoredErrors; if ($reportUnmatched === false) { continue; } + $realCount = $realCounts[$i] ?? null; if ( - isset($unmatchedIgnoredError['count'], $unmatchedIgnoredError['realCount']) + isset($unmatchedIgnoredError['count']) + && $realCount !== null && (isset($unmatchedIgnoredError['realPath']) || !$onlyFiles) ) { - if ($unmatchedIgnoredError['realCount'] < $unmatchedIgnoredError['count']) { + if ($realCount < $unmatchedIgnoredError['count']) { + $matchedFile = $matchedAt[$i]['file'] ?? null; + $matchedLine = $matchedAt[$i]['line'] ?? null; + // $realCount is at least 1 (it was incremented in the closure) + // and strictly less than count, so count is always >= 2. $errors[] = (new Error(sprintf( - '%s %s is expected to occur %d %s, but occurred only %d %s.', + '%s %s is expected to occur %d times, but occurred only %d %s.', IgnoredError::getIgnoredErrorLabel($unmatchedIgnoredError), IgnoredError::stringifyPattern($unmatchedIgnoredError), $unmatchedIgnoredError['count'], - $unmatchedIgnoredError['count'] === 1 ? 'time' : 'times', - $unmatchedIgnoredError['realCount'], - $unmatchedIgnoredError['realCount'] === 1 ? 'time' : 'times', - ), $unmatchedIgnoredError['file'], $unmatchedIgnoredError['line'], false))->withIdentifier('ignore.count'); + $realCount, + $realCount === 1 ? 'time' : 'times', + ), $matchedFile ?? '', $matchedLine, false))->withIdentifier('ignore.count'); } } elseif (isset($unmatchedIgnoredError['realPath'])) { if (!array_key_exists($unmatchedIgnoredError['realPath'], $analysedFilesKeys)) { diff --git a/src/Node/AnonymousClassNode.php b/src/Node/AnonymousClassNode.php index afed122f56c..0a60ed358b0 100644 --- a/src/Node/AnonymousClassNode.php +++ b/src/Node/AnonymousClassNode.php @@ -14,7 +14,7 @@ final class AnonymousClassNode extends Class_ public static function createFromClassNode(Class_ $node): self { $subNodes = []; - foreach ($node->getSubNodeNames() as $subNodeName) { + foreach (['attrGroups', 'flags', 'extends', 'implements', 'stmts'] as $subNodeName) { $subNodes[$subNodeName] = $node->$subNodeName; } diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index 01adc9a3b4a..886e374bf66 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -187,6 +187,7 @@ public function isUnsealed(): TrinaryLogic } /** + * @phpstan-pure * @return array{Type, Type}|null */ public function getUnsealedTypes(): ?array diff --git a/src/Type/FileTypeMapper.php b/src/Type/FileTypeMapper.php index ddc6dc87af6..431954eb380 100644 --- a/src/Type/FileTypeMapper.php +++ b/src/Type/FileTypeMapper.php @@ -349,7 +349,7 @@ private function getNameScopeMap(string $fileName): array } $this->cache->save($cacheKey, $variableCacheKey, [$nameScopeMap, $filesWithHashes]); } else { - [$nameScopeMap, $files] = $cached; + [$nameScopeMap] = $cached; } if ($this->memoryCacheCount >= $this->nameScopeMapMemoryCacheCountMax) { $this->memoryCache = array_slice( @@ -360,7 +360,7 @@ private function getNameScopeMap(string $fileName): array $this->memoryCacheCount--; } - $this->memoryCache[$fileName] = [$nameScopeMap, $files]; + $this->memoryCache[$fileName] = [$nameScopeMap]; $this->memoryCacheCount++; } diff --git a/src/Type/TypeCombinator.php b/src/Type/TypeCombinator.php index 869811f517a..568249dbf2c 100644 --- a/src/Type/TypeCombinator.php +++ b/src/Type/TypeCombinator.php @@ -1325,38 +1325,77 @@ private static function reduceArrays(array $constantArrays, bool $preserveTagged } } - // Second pass: for arrays with definite sealedness, try to merge pairs that - // don't share any known key (the eligibleCombinations loop above only considers - // shared-key pairs). + // Second pass: merge pairs that the eligibleCombinations loop above couldn't touch. + // That loop only considers pairs sharing at least one known key, so it never fires + // for e.g. `array{}` ∪ `array{a?: 1}` (disjoint, one empty) or for two + // unsealed-extras arrays with disjoint required keys. Both collapse losslessly if + // one side's extras or optional-key shape can absorb the other side's content. + // + // Performance: two sealed, non-empty, no-extras arrays with disjoint keys cannot + // merge losslessly (legacyIsKeysSupersetOf returns false immediately on the first + // missing key). Skip those pairs via a candidate flag to avoid an O(n²) scan that + // dominated analyse time on files accumulating many sealed ConstantArrayType + // variants (bug-7581 / bug-8146a). A pair is worth checking only if at least one + // side is (a) empty, or (b) has real unsealed extras, or (c) has optional keys — + // the last case covers the narrowing shape used by e.g. array_key_exists checks + // over large optional-key shapes (bug-14032). $indices = array_keys($arraysToProcess); $indicesCount = count($indices); - for ($ii = 0; $ii < $indicesCount - 1; $ii++) { - $i = $indices[$ii]; - if (!array_key_exists($i, $arraysToProcess)) { - continue; - } - if ($arraysToProcess[$i]->getUnsealedTypes() === null) { - continue; - } - for ($jj = $ii + 1; $jj < $indicesCount; $jj++) { - $j = $indices[$jj]; - if (!array_key_exists($j, $arraysToProcess)) { + if ($indicesCount > 1) { + $candidateFlags = []; + foreach ($indices as $idx) { + $arr = $arraysToProcess[$idx]; + $unsealed = $arr->getUnsealedTypes(); + if ($unsealed === null) { + $candidateFlags[$idx] = false; continue; } - if ($arraysToProcess[$j]->getUnsealedTypes() === null) { + [$unsealedKey] = $unsealed; + $hasRealExtras = !($unsealedKey instanceof NeverType && $unsealedKey->isExplicit()); + if ($hasRealExtras) { + $candidateFlags[$idx] = true; continue; } - if ($arraysToProcess[$j]->isKeysSupersetOf($arraysToProcess[$i])) { - $arraysToProcess[$j] = $arraysToProcess[$j]->mergeWith($arraysToProcess[$i]); - unset($arraysToProcess[$i]); - continue 2; + $keyTypesCount = count($arr->getKeyTypes()); + if ($keyTypesCount === 0) { + $candidateFlags[$idx] = true; + continue; } - if (!$arraysToProcess[$i]->isKeysSupersetOf($arraysToProcess[$j])) { + $hasOptional = count($arr->getOptionalKeys()) > 0; + $candidateFlags[$idx] = $hasOptional; + } + + for ($ii = 0; $ii < $indicesCount - 1; $ii++) { + $i = $indices[$ii]; + if (!array_key_exists($i, $arraysToProcess)) { continue; } + if ($arraysToProcess[$i]->getUnsealedTypes() === null) { + continue; + } + for ($jj = $ii + 1; $jj < $indicesCount; $jj++) { + $j = $indices[$jj]; + if (!array_key_exists($j, $arraysToProcess)) { + continue; + } + if (!$candidateFlags[$i] && !$candidateFlags[$j]) { + continue; + } + if ($arraysToProcess[$j]->getUnsealedTypes() === null) { + continue; + } + if ($arraysToProcess[$j]->isKeysSupersetOf($arraysToProcess[$i])) { + $arraysToProcess[$j] = $arraysToProcess[$j]->mergeWith($arraysToProcess[$i]); + unset($arraysToProcess[$i]); + continue 2; + } + if (!$arraysToProcess[$i]->isKeysSupersetOf($arraysToProcess[$j])) { + continue; + } - $arraysToProcess[$i] = $arraysToProcess[$i]->mergeWith($arraysToProcess[$j]); - unset($arraysToProcess[$j]); + $arraysToProcess[$i] = $arraysToProcess[$i]->mergeWith($arraysToProcess[$j]); + unset($arraysToProcess[$j]); + } } } @@ -1941,6 +1980,7 @@ private static function intersectDefiniteConstantArrays(ConstantArrayType $a, Co $value = self::intersect($aValue, $bValue); $optional = $a->isOptionalKey($aIdx); } else { + /** @var int<0, max> $bIdx */ $keyType = $b->getKeyTypes()[$bIdx]; $bValue = $b->getValueTypes()[$bIdx]; $aValue = $resolveOtherValue($a, $keyType); diff --git a/tests/PHPStan/Analyser/Ignore/IgnoreLexerTest.php b/tests/PHPStan/Analyser/Ignore/IgnoreLexerTest.php index 6e46630df6d..d2c34bad04f 100644 --- a/tests/PHPStan/Analyser/Ignore/IgnoreLexerTest.php +++ b/tests/PHPStan/Analyser/Ignore/IgnoreLexerTest.php @@ -81,7 +81,7 @@ public static function dataTokenize(): iterable } /** - * @param list $expectedTokens + * @param list $expectedTokens */ #[DataProvider('dataTokenize')] public function testTokenize(string $input, array $expectedTokens): void diff --git a/tests/PHPStan/Command/ErrorFormatter/BaselineNeonErrorFormatterTest.php b/tests/PHPStan/Command/ErrorFormatter/BaselineNeonErrorFormatterTest.php index 1f9f4bfd27d..c9463f9675a 100644 --- a/tests/PHPStan/Command/ErrorFormatter/BaselineNeonErrorFormatterTest.php +++ b/tests/PHPStan/Command/ErrorFormatter/BaselineNeonErrorFormatterTest.php @@ -383,7 +383,7 @@ public function testOutputOrdering(array $errors): void } /** - * @return Generator}> + * @return Generator, existingBaselineContent: string, expectedNewlinesCount: int}> */ public static function endOfFileNewlinesProvider(): Generator { From f2ddada38ae1568156a4cc1edad556d8c50d69b7 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Mon, 27 Apr 2026 10:54:48 +0200 Subject: [PATCH 09/66] Remove unrelated tip --- src/Type/Constant/ConstantArrayType.php | 4 +++ tests/PHPStan/Analyser/data/bug-7963.php | 2 +- tests/PHPStan/Analyser/nsrt/bug-14489.php | 2 +- .../CallToFunctionParametersRuleTest.php | 4 +-- .../Rules/Functions/data/bug-11518.php | 2 +- .../Rules/Functions/data/bug-11533.php | 2 +- .../PHPStan/Rules/Functions/data/bug-2911.php | 2 +- .../PHPStan/Rules/Functions/data/bug-3931.php | 6 ++-- .../PHPStan/Rules/Functions/data/bug-7156.php | 2 +- .../Rules/Methods/CallMethodsRuleTest.php | 33 ++++++++++++++++++- .../Methods/CallStaticMethodsRuleTest.php | 2 ++ .../Rules/Methods/MethodSignatureRuleTest.php | 8 +++++ .../Rules/Methods/ReturnTypeRuleTest.php | 4 +-- tests/PHPStan/Rules/Methods/data/bug-5232.php | 2 +- tests/PHPStan/Rules/Methods/data/bug-5258.php | 20 +++++++++++ tests/PHPStan/Rules/Methods/data/bug-6552.php | 2 +- .../Rules/Methods/data/bug-8146b-errors.php | 2 +- .../Rules/Methods/data/method-signature.php | 24 ++++++++++++++ .../Type/Constant/ConstantArrayTypeTest.php | 10 ++++++ 19 files changed, 116 insertions(+), 17 deletions(-) diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index 886e374bf66..27988d3f119 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -465,6 +465,10 @@ public function accepts(Type $type, bool $strictTypes): AcceptsResult return $result; } + if ($result->no()) { + return $result; + } + [$unsealedKeyType, $unsealedValueType] = $this->unsealed; if ($isUnsealed->no()) { diff --git a/tests/PHPStan/Analyser/data/bug-7963.php b/tests/PHPStan/Analyser/data/bug-7963.php index ac7d433943b..c2d278bc7e5 100644 --- a/tests/PHPStan/Analyser/data/bug-7963.php +++ b/tests/PHPStan/Analyser/data/bug-7963.php @@ -31,7 +31,7 @@ interface FieldDescriptionInterface class HelloWorld { /** - * @phpstan-return array}> + * @phpstan-return array, ...}> */ public function getRenderViewElementTests(): array { diff --git a/tests/PHPStan/Analyser/nsrt/bug-14489.php b/tests/PHPStan/Analyser/nsrt/bug-14489.php index f1471e754a7..cab77ee02ae 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-14489.php +++ b/tests/PHPStan/Analyser/nsrt/bug-14489.php @@ -42,7 +42,7 @@ function () { $cData[$c] = $ids; } } - assertType('array{}|array{c1?: array{1}|array{4}, c2?: array{1}|array{4}}', $cData); + assertType('array{c1?: array{1}|array{4}, c2?: array{1}|array{4}}', $cData); }; /** diff --git a/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php b/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php index 40ae1db11cc..c82118cbff5 100644 --- a/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php +++ b/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php @@ -1337,7 +1337,7 @@ public function testBug2911(): void { $this->analyse([__DIR__ . '/data/bug-2911.php'], [ [ - 'Parameter #1 $array of function Bug2911\bar expects array{bar: string}, non-empty-array given.', + 'Parameter #1 $array of function Bug2911\bar expects array{bar: string, ...}, non-empty-array given.', 23, ], ]); @@ -2967,7 +2967,7 @@ public function testBug11494(): void [ 'Parameter #1 $a of function Bug11494\test expects array{long: string, details: string}|array{short: string}, array{short: \'thing\', extra: \'other\'} given.', 18, - "• Type #1 from the union: Sealed array shape does not accept array with extra key 'extra'.\n• Type #2 from the union: Sealed array shape does not accept array with extra key 'extra'.", + "• Type #1 from the union: Array does not have offset 'long'.\n• Type #2 from the union: Sealed array shape does not accept array with extra key 'extra'.", ], ]); } diff --git a/tests/PHPStan/Rules/Functions/data/bug-11518.php b/tests/PHPStan/Rules/Functions/data/bug-11518.php index 0e9ad45d9a1..0c5472039c1 100644 --- a/tests/PHPStan/Rules/Functions/data/bug-11518.php +++ b/tests/PHPStan/Rules/Functions/data/bug-11518.php @@ -4,7 +4,7 @@ /** * @param mixed[] $a - * @return array{thing: mixed} + * @return array{thing: mixed, ...} * */ function blah(array $a): array { diff --git a/tests/PHPStan/Rules/Functions/data/bug-11533.php b/tests/PHPStan/Rules/Functions/data/bug-11533.php index 0b1a98401ba..69e3ee684e2 100644 --- a/tests/PHPStan/Rules/Functions/data/bug-11533.php +++ b/tests/PHPStan/Rules/Functions/data/bug-11533.php @@ -13,7 +13,7 @@ function hello(array $param): void world($param); } -/** @param array{need: string, field: string} $param */ +/** @param array{need: string, field: string, ...} $param */ function world(array $param): void { } diff --git a/tests/PHPStan/Rules/Functions/data/bug-2911.php b/tests/PHPStan/Rules/Functions/data/bug-2911.php index 194b8a3c0a3..4eec57aa481 100644 --- a/tests/PHPStan/Rules/Functions/data/bug-2911.php +++ b/tests/PHPStan/Rules/Functions/data/bug-2911.php @@ -25,7 +25,7 @@ function foo2(array $array): void { /** - * @param array{bar: string} $array + * @param array{bar: string, ...} $array */ function bar(array $array): void { } diff --git a/tests/PHPStan/Rules/Functions/data/bug-3931.php b/tests/PHPStan/Rules/Functions/data/bug-3931.php index d5eb4d83a3a..ec98f36e845 100644 --- a/tests/PHPStan/Rules/Functions/data/bug-3931.php +++ b/tests/PHPStan/Rules/Functions/data/bug-3931.php @@ -7,11 +7,11 @@ /** * @template T of array * @param T $arr - * @return T & array{mykey: int} + * @return T & array{mykey: int, ...} */ function addSomeKey(array $arr, int $value): array { $arr['mykey'] = $value; - assertType("T of array (function Bug3931\\addSomeKey(), argument)&hasOffsetValue('mykey', int)&non-empty-array", $arr); + assertType("T of array (function Bug3931\addSomeKey(), argument)&hasOffsetValue('mykey', int)&non-empty-array", $arr); return $arr; } @@ -22,5 +22,5 @@ function addSomeKey(array $arr, int $value): array { function test(array $arr): void { $r = addSomeKey($arr, 1); - assertType("array{mykey: int}", $r); // could be better, the T part currently disappears + assertType("T of array (function Bug3931\addSomeKey(), argument)&hasOffsetValue('mykey', int)&non-empty-array", $r); } diff --git a/tests/PHPStan/Rules/Functions/data/bug-7156.php b/tests/PHPStan/Rules/Functions/data/bug-7156.php index 209a9decf54..3757e952dc1 100644 --- a/tests/PHPStan/Rules/Functions/data/bug-7156.php +++ b/tests/PHPStan/Rules/Functions/data/bug-7156.php @@ -6,7 +6,7 @@ use function PHPStan\Testing\assertType; /** - * @param array{value: string} $foo + * @param array{value: string, ...} $foo */ function foo($foo): void { print_r($foo); diff --git a/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php b/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php index e496bad3976..84a305a297f 100644 --- a/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php +++ b/tests/PHPStan/Rules/Methods/CallMethodsRuleTest.php @@ -532,6 +532,16 @@ public function testCallMethods(): void 1589, "Array does not have offset 'id'.", ], + [ + 'Parameter #1 $param of method Test\ConstantArrayAccepts::doBar() expects array{name: string, color?: string}, array{name: string, color: string, year: int} given.', + 1614, + "Sealed array shape does not accept array with extra key 'year'.", + ], + [ + 'Parameter #1 $params of method Test\ConstantArrayAcceptsOptionalKey::doFoo() expects array{wrapperClass?: class-string}, array{wrapperClass: \'stdClass\', undocumented: 42} given.', + 1638, + "Sealed array shape does not accept array with extra key 'undocumented'.", + ], [ 'Parameter #1 $test of method Test\NumericStringParam::sayHello() expects numeric-string, 123 given.', 1657, @@ -859,6 +869,16 @@ public function testCallMethodsOnThisOnly(): void 1589, "Array does not have offset 'id'.", ], + [ + 'Parameter #1 $param of method Test\ConstantArrayAccepts::doBar() expects array{name: string, color?: string}, array{name: string, color: string, year: int} given.', + 1614, + "Sealed array shape does not accept array with extra key 'year'.", + ], + [ + 'Parameter #1 $params of method Test\ConstantArrayAcceptsOptionalKey::doFoo() expects array{wrapperClass?: class-string}, array{wrapperClass: \'stdClass\', undocumented: 42} given.', + 1638, + "Sealed array shape does not accept array with extra key 'undocumented'.", + ], [ 'Parameter #1 $test of method Test\NumericStringParam::sayHello() expects numeric-string, 123 given.', 1657, @@ -2204,7 +2224,18 @@ public function testBug5258(): void $this->checkThisOnly = false; $this->checkNullables = true; $this->checkUnionTypes = true; - $this->analyse([__DIR__ . '/data/bug-5258.php'], []); + $this->analyse([__DIR__ . '/data/bug-5258.php'], [ + [ + 'Parameter #1 $params of method Bug5258\HelloWorld::method2() expects array{other_key: string}, array{some_key: non-falsy-string, other_key: string} given.', + 12, + "Sealed array shape does not accept array with extra key 'some_key'.", + ], + [ + 'Parameter #1 $params of method Bug5258\HelloWorld::method2() expects array{other_key: string}, array{some_key?: string, other_key: non-falsy-string} given.', + 14, + "Sealed array shape does not accept array with extra key 'some_key'.", + ], + ]); } public function testBug5591(): void diff --git a/tests/PHPStan/Rules/Methods/CallStaticMethodsRuleTest.php b/tests/PHPStan/Rules/Methods/CallStaticMethodsRuleTest.php index 36c036efecf..32bc7c9aab7 100644 --- a/tests/PHPStan/Rules/Methods/CallStaticMethodsRuleTest.php +++ b/tests/PHPStan/Rules/Methods/CallStaticMethodsRuleTest.php @@ -563,10 +563,12 @@ public function testDiscussion7004(): void [ 'Parameter #1 $data of static method Discussion7004\Foo::fromArray2() expects array{array{newsletterName: string, subscriberCount: int}}, array given.', 47, + 'Sealed array shape can only accept a constant array. Extra keys are not allowed.', ], [ 'Parameter #1 $data of static method Discussion7004\Foo::fromArray3() expects array{newsletterName: string, subscriberCount: int}, array given.', 48, + 'Sealed array shape can only accept a constant array. Extra keys are not allowed.', ], ]); } diff --git a/tests/PHPStan/Rules/Methods/MethodSignatureRuleTest.php b/tests/PHPStan/Rules/Methods/MethodSignatureRuleTest.php index 51b42d8d00c..d06e1339742 100644 --- a/tests/PHPStan/Rules/Methods/MethodSignatureRuleTest.php +++ b/tests/PHPStan/Rules/Methods/MethodSignatureRuleTest.php @@ -83,6 +83,10 @@ public function testReturnTypeRule(): void 'Parameter #1 $node (PhpParser\Node\Expr\StaticCall) of method MethodSignature\Rule::processNode() should be contravariant with parameter $node (PhpParser\Node) of method MethodSignature\GenericRule::processNode()', 454, ], + [ + 'Return type (array{foo: string, bar: string}) of method MethodSignature\ConstantArrayClass::foobar() should be compatible with return type (array{foo: string}) of method MethodSignature\ConstantArrayInterface::foobar()', + 476, + ], ], ); } @@ -186,6 +190,10 @@ public function testReturnTypeRuleWithoutMaybes(): void 'Return type (MethodSignature\Cat) of method MethodSignature\SubClass::returnTypeTest5() should be compatible with return type (MethodSignature\Dog) of method MethodSignature\BaseInterface::returnTypeTest5()', 358, ], + [ + 'Return type (array{foo: string, bar: string}) of method MethodSignature\ConstantArrayClass::foobar() should be compatible with return type (array{foo: string}) of method MethodSignature\ConstantArrayInterface::foobar()', + 476, + ], ], ); } diff --git a/tests/PHPStan/Rules/Methods/ReturnTypeRuleTest.php b/tests/PHPStan/Rules/Methods/ReturnTypeRuleTest.php index 70c6529be67..f6196b99cab 100644 --- a/tests/PHPStan/Rules/Methods/ReturnTypeRuleTest.php +++ b/tests/PHPStan/Rules/Methods/ReturnTypeRuleTest.php @@ -876,9 +876,9 @@ public function testBug8146bErrors(): void $this->checkBenevolentUnionTypes = true; $this->analyse([__DIR__ . '/data/bug-8146b-errors.php'], [ [ - "Method Bug8146bError\LocationFixtures::getData() should return array, coordinates: array{lat: float, lng: float}}>> but returns array{Bács-Kiskun: array{Ágasegyháza: array{constituencies: array{'Bács-Kiskun 4.', true, false, Bug8146bError\X, null}, coordinates: array{lat: 46.8386043, lng: 19.4502899}}, Akasztó: array{constituencies: array{'Bács-Kiskun 3.'}, coordinates: array{lat: 46.6898175, lng: 19.205086}}, Apostag: array{constituencies: array{'Bács-Kiskun 3.'}, coordinates: array{lat: 46.8812652, lng: 18.9648478}}, Bácsalmás: array{constituencies: array{'Bács-Kiskun 5.'}, coordinates: array{lat: 46.1250396, lng: 19.3357509}}, Bácsbokod: array{constituencies: array{'Bács-Kiskun 6.'}, coordinates: array{lat: 46.1234737, lng: 19.155708}}, Bácsborsód: array{constituencies: array{'Bács-Kiskun 6.'}, coordinates: array{lat: 46.0989373, lng: 19.1566725}}, Bácsszentgyörgy: array{constituencies: array{'Bács-Kiskun 6.'}, coordinates: array{lat: 45.9746039, lng: 19.0398066}}, Bácsszőlős: array{constituencies: array{'Bács-Kiskun 5.'}, coordinates: array{lat: 46.1352003, lng: 19.4215997}}, ...}, Baranya: non-empty-array|(literal-string&non-falsy-string), float|(literal-string&non-falsy-string)>>>, Békés: array{Almáskamarás: array{constituencies: array{'Békés 4.'}, coordinates: array{lat: 46.4617785, lng: 21.092448}}, Battonya: array{constituencies: array{'Békés 4.'}, coordinates: array{lat: 46.2902462, lng: 21.0199215}}, Békés: array{constituencies: array{'Békés 2.'}, coordinates: array{lat: 46.6704899, lng: 21.0434996}}, Békéscsaba: array{constituencies: array{'Békés 1.'}, coordinates: array{lat: 46.6735939, lng: 21.0877309}}, Békéssámson: array{constituencies: array{'Békés 4.'}, coordinates: array{lat: 46.4208677, lng: 20.6176498}}, Békésszentandrás: array{constituencies: array{'Békés 2.'}, coordinates: array{lat: 46.8715996, lng: 20.48336}}, Bélmegyer: array{constituencies: array{'Békés 3.'}, coordinates: array{lat: 46.8726019, lng: 21.1832832}}, Biharugra: array{constituencies: array{'Békés 3.'}, coordinates: array{lat: 46.9691009, lng: 21.5987651}}, ...}, Borsod-Abaúj-Zemplén: non-empty-array|(literal-string&non-falsy-string), float|(literal-string&non-falsy-string)>>>, Budapest: array{'Budapest I. ker.': array{constituencies: array{'Budapest 01.'}, coordinates: array{lat: 47.4968219, lng: 19.037458}}, 'Budapest II. ker.': array{constituencies: array{'Budapest 03.', 'Budapest 04.'}, coordinates: array{lat: 47.5393329, lng: 18.986934}}, 'Budapest III. ker.': array{constituencies: array{'Budapest 04.', 'Budapest 10.'}, coordinates: array{lat: 47.5671768, lng: 19.0368517}}, 'Budapest IV. ker.': array{constituencies: array{'Budapest 11.', 'Budapest 12.'}, coordinates: array{lat: 47.5648915, lng: 19.0913149}}, 'Budapest V. ker.': array{constituencies: array{'Budapest 01.'}, coordinates: array{lat: 47.5002319, lng: 19.0520181}}, 'Budapest VI. ker.': array{constituencies: array{'Budapest 05.'}, coordinates: array{lat: 47.509863, lng: 19.0625813}}, 'Budapest VII. ker.': array{constituencies: array{'Budapest 05.'}, coordinates: array{lat: 47.5027289, lng: 19.073376}}, 'Budapest VIII. ker.': array{constituencies: array{'Budapest 01.', 'Budapest 06.'}, coordinates: array{lat: 47.4894184, lng: 19.070668}}, ...}, Csongrád-Csanád: array{Algyő: array{constituencies: array{'Csongrád-Csanád 4.'}, coordinates: array{lat: 46.3329625, lng: 20.207889}}, Ambrózfalva: array{constituencies: array{'Csongrád-Csanád 4.'}, coordinates: array{lat: 46.3501417, lng: 20.7313995}}, Apátfalva: array{constituencies: array{'Csongrád-Csanád 4.'}, coordinates: array{lat: 46.173317, lng: 20.5800472}}, Árpádhalom: array{constituencies: array{'Csongrád-Csanád 3.'}, coordinates: array{lat: 46.6158286, lng: 20.547733}}, Ásotthalom: array{constituencies: array{'Csongrád-Csanád 2.'}, coordinates: array{lat: 46.1995983, lng: 19.7833756}}, Baks: array{constituencies: array{'Csongrád-Csanád 3.'}, coordinates: array{lat: 46.5518708, lng: 20.1064166}}, Balástya: array{constituencies: array{'Csongrád-Csanád 3.'}, coordinates: array{lat: 46.4261828, lng: 20.004933}}, Bordány: array{constituencies: array{'Csongrád-Csanád 2.'}, coordinates: array{lat: 46.3194213, lng: 19.9227063}}, ...}, Fejér: array{Aba: array{constituencies: array{'Fejér 5.'}, coordinates: array{lat: 47.0328193, lng: 18.522359}}, Adony: array{constituencies: array{'Fejér 4.'}, coordinates: array{lat: 47.119831, lng: 18.8612469}}, Alap: array{constituencies: array{'Fejér 5.'}, coordinates: array{lat: 46.8075763, lng: 18.684028}}, Alcsútdoboz: array{constituencies: array{'Fejér 3.'}, coordinates: array{lat: 47.4277067, lng: 18.6030325}}, Alsószentiván: array{constituencies: array{'Fejér 5.'}, coordinates: array{lat: 46.7910573, lng: 18.732161}}, Bakonycsernye: array{constituencies: array{'Fejér 2.'}, coordinates: array{lat: 47.321719, lng: 18.0907379}}, Bakonykúti: array{constituencies: array{'Fejér 2.'}, coordinates: array{lat: 47.2458464, lng: 18.195769}}, Balinka: array{constituencies: array{'Fejér 2.'}, coordinates: array{lat: 47.3135736, lng: 18.1907168}}, ...}, Győr-Moson-Sopron: array{Abda: array{constituencies: array{'Győr-Moson-Sopron 5.'}, coordinates: array{lat: 47.6962149, lng: 17.5445786}}, Acsalag: array{constituencies: array{'Győr-Moson-Sopron 3.'}, coordinates: array{lat: 47.676095, lng: 17.1977771}}, Ágfalva: array{constituencies: array{'Győr-Moson-Sopron 4.'}, coordinates: array{lat: 47.688862, lng: 16.5110233}}, Agyagosszergény: array{constituencies: array{'Győr-Moson-Sopron 3.'}, coordinates: array{lat: 47.608545, lng: 16.9409912}}, Árpás: array{constituencies: array{'Győr-Moson-Sopron 3.'}, coordinates: array{lat: 47.5134127, lng: 17.3931579}}, Ásványráró: array{constituencies: array{'Győr-Moson-Sopron 5.'}, coordinates: array{lat: 47.8287695, lng: 17.499195}}, Babót: array{constituencies: array{'Győr-Moson-Sopron 3.'}, coordinates: array{lat: 47.5752269, lng: 17.0758604}}, Bágyogszovát: array{constituencies: array{'Győr-Moson-Sopron 3.'}, coordinates: array{lat: 47.5866036, lng: 17.3617273}}, ...}, ...}.", + "Method Bug8146bError\LocationFixtures::getData() should return array, coordinates: array{lat: float, lng: float, ...}}>> but returns array{Bács-Kiskun: array{Ágasegyháza: array{constituencies: array{'Bács-Kiskun 4.', true, false, Bug8146bError\X, null}, coordinates: array{lat: 46.8386043, lng: 19.4502899}}, Akasztó: array{constituencies: array{'Bács-Kiskun 3.'}, coordinates: array{lat: 46.6898175, lng: 19.205086}}, Apostag: array{constituencies: array{'Bács-Kiskun 3.'}, coordinates: array{lat: 46.8812652, lng: 18.9648478}}, Bácsalmás: array{constituencies: array{'Bács-Kiskun 5.'}, coordinates: array{lat: 46.1250396, lng: 19.3357509}}, Bácsbokod: array{constituencies: array{'Bács-Kiskun 6.'}, coordinates: array{lat: 46.1234737, lng: 19.155708}}, Bácsborsód: array{constituencies: array{'Bács-Kiskun 6.'}, coordinates: array{lat: 46.0989373, lng: 19.1566725}}, Bácsszentgyörgy: array{constituencies: array{'Bács-Kiskun 6.'}, coordinates: array{lat: 45.9746039, lng: 19.0398066}}, Bácsszőlős: array{constituencies: array{'Bács-Kiskun 5.'}, coordinates: array{lat: 46.1352003, lng: 19.4215997}}, ...}, Baranya: non-empty-array|(literal-string&non-falsy-string), float|(literal-string&non-falsy-string)>>>, Békés: array{Almáskamarás: array{constituencies: array{'Békés 4.'}, coordinates: array{lat: 46.4617785, lng: 21.092448}}, Battonya: array{constituencies: array{'Békés 4.'}, coordinates: array{lat: 46.2902462, lng: 21.0199215}}, Békés: array{constituencies: array{'Békés 2.'}, coordinates: array{lat: 46.6704899, lng: 21.0434996}}, Békéscsaba: array{constituencies: array{'Békés 1.'}, coordinates: array{lat: 46.6735939, lng: 21.0877309}}, Békéssámson: array{constituencies: array{'Békés 4.'}, coordinates: array{lat: 46.4208677, lng: 20.6176498}}, Békésszentandrás: array{constituencies: array{'Békés 2.'}, coordinates: array{lat: 46.8715996, lng: 20.48336}}, Bélmegyer: array{constituencies: array{'Békés 3.'}, coordinates: array{lat: 46.8726019, lng: 21.1832832}}, Biharugra: array{constituencies: array{'Békés 3.'}, coordinates: array{lat: 46.9691009, lng: 21.5987651}}, ...}, Borsod-Abaúj-Zemplén: non-empty-array|(literal-string&non-falsy-string), float|(literal-string&non-falsy-string)>>>, Budapest: array{'Budapest I. ker.': array{constituencies: array{'Budapest 01.'}, coordinates: array{lat: 47.4968219, lng: 19.037458}}, 'Budapest II. ker.': array{constituencies: array{'Budapest 03.', 'Budapest 04.'}, coordinates: array{lat: 47.5393329, lng: 18.986934}}, 'Budapest III. ker.': array{constituencies: array{'Budapest 04.', 'Budapest 10.'}, coordinates: array{lat: 47.5671768, lng: 19.0368517}}, 'Budapest IV. ker.': array{constituencies: array{'Budapest 11.', 'Budapest 12.'}, coordinates: array{lat: 47.5648915, lng: 19.0913149}}, 'Budapest V. ker.': array{constituencies: array{'Budapest 01.'}, coordinates: array{lat: 47.5002319, lng: 19.0520181}}, 'Budapest VI. ker.': array{constituencies: array{'Budapest 05.'}, coordinates: array{lat: 47.509863, lng: 19.0625813}}, 'Budapest VII. ker.': array{constituencies: array{'Budapest 05.'}, coordinates: array{lat: 47.5027289, lng: 19.073376}}, 'Budapest VIII. ker.': array{constituencies: array{'Budapest 01.', 'Budapest 06.'}, coordinates: array{lat: 47.4894184, lng: 19.070668}}, ...}, Csongrád-Csanád: array{Algyő: array{constituencies: array{'Csongrád-Csanád 4.'}, coordinates: array{lat: 46.3329625, lng: 20.207889}}, Ambrózfalva: array{constituencies: array{'Csongrád-Csanád 4.'}, coordinates: array{lat: 46.3501417, lng: 20.7313995}}, Apátfalva: array{constituencies: array{'Csongrád-Csanád 4.'}, coordinates: array{lat: 46.173317, lng: 20.5800472}}, Árpádhalom: array{constituencies: array{'Csongrád-Csanád 3.'}, coordinates: array{lat: 46.6158286, lng: 20.547733}}, Ásotthalom: array{constituencies: array{'Csongrád-Csanád 2.'}, coordinates: array{lat: 46.1995983, lng: 19.7833756}}, Baks: array{constituencies: array{'Csongrád-Csanád 3.'}, coordinates: array{lat: 46.5518708, lng: 20.1064166}}, Balástya: array{constituencies: array{'Csongrád-Csanád 3.'}, coordinates: array{lat: 46.4261828, lng: 20.004933}}, Bordány: array{constituencies: array{'Csongrád-Csanád 2.'}, coordinates: array{lat: 46.3194213, lng: 19.9227063}}, ...}, Fejér: array{Aba: array{constituencies: array{'Fejér 5.'}, coordinates: array{lat: 47.0328193, lng: 18.522359}}, Adony: array{constituencies: array{'Fejér 4.'}, coordinates: array{lat: 47.119831, lng: 18.8612469}}, Alap: array{constituencies: array{'Fejér 5.'}, coordinates: array{lat: 46.8075763, lng: 18.684028}}, Alcsútdoboz: array{constituencies: array{'Fejér 3.'}, coordinates: array{lat: 47.4277067, lng: 18.6030325}}, Alsószentiván: array{constituencies: array{'Fejér 5.'}, coordinates: array{lat: 46.7910573, lng: 18.732161}}, Bakonycsernye: array{constituencies: array{'Fejér 2.'}, coordinates: array{lat: 47.321719, lng: 18.0907379}}, Bakonykúti: array{constituencies: array{'Fejér 2.'}, coordinates: array{lat: 47.2458464, lng: 18.195769}}, Balinka: array{constituencies: array{'Fejér 2.'}, coordinates: array{lat: 47.3135736, lng: 18.1907168}}, ...}, Győr-Moson-Sopron: array{Abda: array{constituencies: array{'Győr-Moson-Sopron 5.'}, coordinates: array{lat: 47.6962149, lng: 17.5445786}}, Acsalag: array{constituencies: array{'Győr-Moson-Sopron 3.'}, coordinates: array{lat: 47.676095, lng: 17.1977771}}, Ágfalva: array{constituencies: array{'Győr-Moson-Sopron 4.'}, coordinates: array{lat: 47.688862, lng: 16.5110233}}, Agyagosszergény: array{constituencies: array{'Győr-Moson-Sopron 3.'}, coordinates: array{lat: 47.608545, lng: 16.9409912}}, Árpás: array{constituencies: array{'Győr-Moson-Sopron 3.'}, coordinates: array{lat: 47.5134127, lng: 17.3931579}}, Ásványráró: array{constituencies: array{'Győr-Moson-Sopron 5.'}, coordinates: array{lat: 47.8287695, lng: 17.499195}}, Babót: array{constituencies: array{'Győr-Moson-Sopron 3.'}, coordinates: array{lat: 47.5752269, lng: 17.0758604}}, Bágyogszovát: array{constituencies: array{'Győr-Moson-Sopron 3.'}, coordinates: array{lat: 47.5866036, lng: 17.3617273}}, ...}, ...}.", 12, - "Offset 'constituencies' (non-empty-list) does not accept type array{'Bács-Kiskun 4.', true, false, Bug8146bError\X, null}.", + "• Offset 'constituencies' (non-empty-list) does not accept type array{'Bács-Kiskun 4.', true, false, Bug8146bError\X, null}.\n• Sealed array shape can only accept a constant array. Extra keys are not allowed.", ], ]); } diff --git a/tests/PHPStan/Rules/Methods/data/bug-5232.php b/tests/PHPStan/Rules/Methods/data/bug-5232.php index 4089988ff72..1d047d30f19 100644 --- a/tests/PHPStan/Rules/Methods/data/bug-5232.php +++ b/tests/PHPStan/Rules/Methods/data/bug-5232.php @@ -5,7 +5,7 @@ abstract class HelloWorld { /** - * @phpstan-return array{workId: string, collectionNumber: string, uuid: string|null} + * @phpstan-return array{workId: string, collectionNumber: string, uuid: string|null, ...} */ public function sayHello(string $content): array { diff --git a/tests/PHPStan/Rules/Methods/data/bug-5258.php b/tests/PHPStan/Rules/Methods/data/bug-5258.php index 27a751f8591..a2df20d2baf 100644 --- a/tests/PHPStan/Rules/Methods/data/bug-5258.php +++ b/tests/PHPStan/Rules/Methods/data/bug-5258.php @@ -21,3 +21,23 @@ public function method2(array$params): void { } } + +class HelloWorld2 +{ + /** + * @param array{some_key?:string, other_key:string} $params + */ + public function method1(array $params): void + { + if (!empty($params['some_key'])) $this->method2($params); + + if (!empty($params['other_key'])) $this->method2($params); + } + + /** + * @param array{other_key:string, ...} $params + **/ + public function method2(array$params): void + { + } +} diff --git a/tests/PHPStan/Rules/Methods/data/bug-6552.php b/tests/PHPStan/Rules/Methods/data/bug-6552.php index 51a4c32e075..e9b464d742b 100644 --- a/tests/PHPStan/Rules/Methods/data/bug-6552.php +++ b/tests/PHPStan/Rules/Methods/data/bug-6552.php @@ -6,7 +6,7 @@ class HelloWorld { /** * @param mixed $a - * @return array{schemaVersion: mixed}|null + * @return array{schemaVersion: mixed, ...}|null */ public function sayHello($a) { diff --git a/tests/PHPStan/Rules/Methods/data/bug-8146b-errors.php b/tests/PHPStan/Rules/Methods/data/bug-8146b-errors.php index 27509dcc963..aa7298045c8 100644 --- a/tests/PHPStan/Rules/Methods/data/bug-8146b-errors.php +++ b/tests/PHPStan/Rules/Methods/data/bug-8146b-errors.php @@ -6,7 +6,7 @@ class X{} class LocationFixtures { - /** @return array, coordinates: array{lat: float, lng: float}}>> */ + /** @return array, coordinates: array{lat: float, lng: float, ...}}>> */ public function getData(): array { return [ diff --git a/tests/PHPStan/Rules/Methods/data/method-signature.php b/tests/PHPStan/Rules/Methods/data/method-signature.php index c9170738825..80a905ff3ce 100644 --- a/tests/PHPStan/Rules/Methods/data/method-signature.php +++ b/tests/PHPStan/Rules/Methods/data/method-signature.php @@ -481,3 +481,27 @@ public function foobar(): array ]; } } + +interface ConstantArrayInterfaceUnsealed +{ + + /** + * @return array{foo: string, ...} + */ + public function foobar(): array; + +} + +class ConstantArrayClass2 implements ConstantArrayInterfaceUnsealed +{ + /** + * @return array{foo: string, bar: string} + */ + public function foobar(): array + { + return [ + 'foo' => '', + 'bar' => '', + ]; + } +} diff --git a/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php b/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php index 933d268cc4b..28167c294c9 100644 --- a/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php +++ b/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php @@ -601,6 +601,16 @@ public static function dataAccepts(): iterable ], ]; + yield [ + new ConstantArrayType([new ConstantStringType('a')], [new StringType()]), + new UnionType([ + new ConstantArrayType([new ConstantStringType('a')], [new StringType()]), + new StringType(), + ]), + TrinaryLogic::createMaybe(), + [], + ]; + BleedingEdgeToggle::setBleedingEdge($bleedingEdgeBackup); } From d2a82f6acff08227c40852d049f5b284d6c17648 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Mon, 27 Apr 2026 12:53:11 +0200 Subject: [PATCH 10/66] Preserve unsealed extras when intersecting array{...} with another array MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `TypeCombinator::intersect` rebuilds the constant-array side from scratch via `ConstantArrayTypeBuilder::createEmpty()` whenever the other side is a non-constant `ArrayType` (or when the maybe-unsealed branch fires). The fresh builder is sealed, so `array{k: int, ...} & array<…>` silently collapsed to a sealed `array{k: int}` — losing the openness the user explicitly wrote in the source shape. When the source `ConstantArrayType` is unsealed, copy its unsealed extras onto the new builder, intersecting key/value with the other side's iterable key/value so the open part keeps both sides' refinements. If either side of the intersected extras becomes `never`, leave the new shape sealed. Update the bug-3931 fixture and two `TypeCombinatorTest` data sets to reflect the now-preserved unsealed marker on the result. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Type/TypeCombinator.php | 13 +++++++++++++ tests/PHPStan/Analyser/data/bug-13978.php | 3 +++ tests/PHPStan/Analyser/data/bug-7963.php | 2 +- tests/PHPStan/Rules/Functions/data/bug-3931.php | 2 +- tests/PHPStan/Type/TypeCombinatorTest.php | 4 ++-- 5 files changed, 20 insertions(+), 4 deletions(-) diff --git a/src/Type/TypeCombinator.php b/src/Type/TypeCombinator.php index 568249dbf2c..39e97fc3ddb 100644 --- a/src/Type/TypeCombinator.php +++ b/src/Type/TypeCombinator.php @@ -1789,6 +1789,19 @@ public static function intersect(Type ...$types): Type $newArrayType = $merged; } else { $newArray = ConstantArrayTypeBuilder::createEmpty(); + // Preserve unsealed extras from the source shape so the + // rebuild doesn't silently turn `array{k: int, ...} & X` + // into a sealed `array{k: int}` — intersect with the other + // side's iterable key/value so the open part keeps both + // sides' refinements. + $constUnsealed = $constArray->getUnsealedTypes(); + if ($constUnsealed !== null && $constArray->isUnsealed()->yes()) { + $newUnsealedKey = self::intersect($constUnsealed[0], $otherArray->getIterableKeyType()); + $newUnsealedValue = self::intersect($constUnsealed[1], $otherArray->getIterableValueType()); + if (!$newUnsealedKey instanceof NeverType && !$newUnsealedValue instanceof NeverType) { + $newArray->makeUnsealed($newUnsealedKey, $newUnsealedValue); + } + } $valueTypes = $constArray->getValueTypes(); foreach ($constArray->getKeyTypes() as $k => $keyType) { $hasOffset = $otherArray->hasOffsetValueType($keyType); diff --git a/tests/PHPStan/Analyser/data/bug-13978.php b/tests/PHPStan/Analyser/data/bug-13978.php index fde757bb025..534fbdeab71 100644 --- a/tests/PHPStan/Analyser/data/bug-13978.php +++ b/tests/PHPStan/Analyser/data/bug-13978.php @@ -11,6 +11,9 @@ * @param-out array{ * key1: int * }|array{ + * key1: int, + * key2: float + * }|array{ * key2: float * } $item * diff --git a/tests/PHPStan/Analyser/data/bug-7963.php b/tests/PHPStan/Analyser/data/bug-7963.php index c2d278bc7e5..589fe85bbb3 100644 --- a/tests/PHPStan/Analyser/data/bug-7963.php +++ b/tests/PHPStan/Analyser/data/bug-7963.php @@ -31,7 +31,7 @@ interface FieldDescriptionInterface class HelloWorld { /** - * @phpstan-return array, ...}> + * @phpstan-return array> */ public function getRenderViewElementTests(): array { diff --git a/tests/PHPStan/Rules/Functions/data/bug-3931.php b/tests/PHPStan/Rules/Functions/data/bug-3931.php index ec98f36e845..637927871d4 100644 --- a/tests/PHPStan/Rules/Functions/data/bug-3931.php +++ b/tests/PHPStan/Rules/Functions/data/bug-3931.php @@ -22,5 +22,5 @@ function addSomeKey(array $arr, int $value): array { function test(array $arr): void { $r = addSomeKey($arr, 1); - assertType("T of array (function Bug3931\addSomeKey(), argument)&hasOffsetValue('mykey', int)&non-empty-array", $r); + assertType('array{mykey: int, ...}', $r); } diff --git a/tests/PHPStan/Type/TypeCombinatorTest.php b/tests/PHPStan/Type/TypeCombinatorTest.php index 834c79975d5..de64a500225 100644 --- a/tests/PHPStan/Type/TypeCombinatorTest.php +++ b/tests/PHPStan/Type/TypeCombinatorTest.php @@ -5521,7 +5521,7 @@ public static function dataIntersect(): iterable 'array{...}', ], ConstantArrayType::class, - 'array{a: *NEVER*}', + 'array{a: *NEVER*, ...}', ]; // both unsealed: known key value is compatible with other side's unsealed value @@ -5531,7 +5531,7 @@ public static function dataIntersect(): iterable 'array{...}', ], ConstantArrayType::class, - 'array{a: non-empty-string}', + 'array{a: non-empty-string, ...}', ]; // both unsealed with same known key, value types incompatible at that key From 6b57c1eeb2dc6a5598c9fc2e00c79225d0988934 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Mon, 27 Apr 2026 16:44:44 +0200 Subject: [PATCH 11/66] Tip in assertNoErrors --- src/Testing/PHPStanTestCase.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Testing/PHPStanTestCase.php b/src/Testing/PHPStanTestCase.php index ac028fdcf39..03b2e4335c7 100644 --- a/src/Testing/PHPStanTestCase.php +++ b/src/Testing/PHPStanTestCase.php @@ -149,7 +149,7 @@ protected function assertNoErrors(array $errors): void $messages = []; foreach ($errors as $error) { if ($error instanceof Error) { - $messages[] = sprintf("- %s\n in %s on line %d\n", rtrim($error->getMessage(), '.'), $error->getFile(), $error->getLine() ?? 0); + $messages[] = sprintf("- %s\n in %s on line %d%s\n", rtrim($error->getMessage(), '.'), $error->getFile(), $error->getLine() ?? 0, $error->getTip() !== null ? sprintf("\n💡 %s", $error->getTip()) : ''); } else { $messages[] = $error; } From 77dcce4692a1e7e9911af8aebe7e801e59e414d5 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Mon, 27 Apr 2026 18:48:22 +0200 Subject: [PATCH 12/66] Re-tighten bug-7963 @phpstan-return on unsealed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two-stage collapse merged from 2.1.x preserves the per-position record shape on unsealed too (the unsealed-types passes in reduceArrays absorb same-signature variants before the list-collapse, and the list-collapse now skips when every variant shares one signature). The earlier "Fix tests: bug-7963, bug-13978" commit's loosening of this @phpstan-return is therefore obsolete on unsealed — revert that one part to match the sealed form already on 2.2.x. Co-Authored-By: Claude Opus 4.7 (1M context) --- phpstan-baseline.neon | 2 +- src/Type/TypeCombinator.php | 1 - tests/PHPStan/Analyser/data/bug-7963.php | 2 +- 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index eb573b5d723..2b3748ff2c6 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -1716,7 +1716,7 @@ parameters: - rawMessage: 'Doing instanceof PHPStan\Type\Constant\ConstantArrayType is error-prone and deprecated. Use Type::getConstantArrays() instead.' identifier: phpstanApi.instanceofType - count: 20 + count: 21 path: src/Type/TypeCombinator.php - diff --git a/src/Type/TypeCombinator.php b/src/Type/TypeCombinator.php index 39e97fc3ddb..b3a0e1b7579 100644 --- a/src/Type/TypeCombinator.php +++ b/src/Type/TypeCombinator.php @@ -1465,7 +1465,6 @@ private static function reduceArrays(array $constantArrays, bool $preserveTagged } } - return array_merge($newArrays, $arraysToProcess); } diff --git a/tests/PHPStan/Analyser/data/bug-7963.php b/tests/PHPStan/Analyser/data/bug-7963.php index 589fe85bbb3..ac7d433943b 100644 --- a/tests/PHPStan/Analyser/data/bug-7963.php +++ b/tests/PHPStan/Analyser/data/bug-7963.php @@ -31,7 +31,7 @@ interface FieldDescriptionInterface class HelloWorld { /** - * @phpstan-return array> + * @phpstan-return array}> */ public function getRenderViewElementTests(): array { From 219b075cb0e7dd0fff317d8420726886afd36d64 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 28 Apr 2026 14:50:49 +0200 Subject: [PATCH 13/66] Update levels tests --- tests/PHPStan/Levels/data/acceptTypes-5.json | 22 +++++++++++++++++++- tests/PHPStan/Levels/data/acceptTypes-7.json | 12 +---------- 2 files changed, 22 insertions(+), 12 deletions(-) diff --git a/tests/PHPStan/Levels/data/acceptTypes-5.json b/tests/PHPStan/Levels/data/acceptTypes-5.json index 4bb076e0554..d64081a6c04 100644 --- a/tests/PHPStan/Levels/data/acceptTypes-5.json +++ b/tests/PHPStan/Levels/data/acceptTypes-5.json @@ -129,6 +129,16 @@ "line": 494, "ignorable": true }, + { + "message": "Parameter #1 $one of method Levels\\AcceptTypes\\ArrayShapes::doBar() expects array{foo: callable(): mixed}, array given.", + "line": 577, + "ignorable": true + }, + { + "message": "Parameter #1 $one of method Levels\\AcceptTypes\\ArrayShapes::doBar() expects array{foo: callable(): mixed}, array given.", + "line": 578, + "ignorable": true + }, { "message": "Parameter #1 $one of method Levels\\AcceptTypes\\ArrayShapes::doBar() expects array{foo: callable(): mixed}, array given.", "line": 579, @@ -144,6 +154,11 @@ "line": 582, "ignorable": true }, + { + "message": "Parameter #1 $one of method Levels\\AcceptTypes\\ArrayShapes::doBar() expects array{foo: callable(): mixed}, array{foo: 'date', bar: 'date'} given.", + "line": 583, + "ignorable": true + }, { "message": "Parameter #1 $one of method Levels\\AcceptTypes\\ArrayShapes::doBar() expects array{foo: callable(): mixed}, array{foo: 'nonexistent'} given.", "line": 584, @@ -154,6 +169,11 @@ "line": 585, "ignorable": true }, + { + "message": "Parameter #1 $one of method Levels\\AcceptTypes\\ArrayShapes::doBar() expects array{foo: callable(): mixed}, non-empty-array given.", + "line": 588, + "ignorable": true + }, { "message": "Parameter #1 $static of method Levels\\AcceptTypes\\RequireObjectWithoutClassType::requireStatic() expects static(Levels\\AcceptTypes\\RequireObjectWithoutClassType), object given.", "line": 648, @@ -189,4 +209,4 @@ "line": 763, "ignorable": true } -] +] \ No newline at end of file diff --git a/tests/PHPStan/Levels/data/acceptTypes-7.json b/tests/PHPStan/Levels/data/acceptTypes-7.json index 216fad89879..c9bcbcd7517 100644 --- a/tests/PHPStan/Levels/data/acceptTypes-7.json +++ b/tests/PHPStan/Levels/data/acceptTypes-7.json @@ -104,16 +104,6 @@ "line": 543, "ignorable": true }, - { - "message": "Parameter #1 $one of method Levels\\AcceptTypes\\ArrayShapes::doBar() expects array{foo: callable(): mixed}, array given.", - "line": 577, - "ignorable": true - }, - { - "message": "Parameter #1 $one of method Levels\\AcceptTypes\\ArrayShapes::doBar() expects array{foo: callable(): mixed}, array given.", - "line": 578, - "ignorable": true - }, { "message": "Parameter #1 $one of method Levels\\AcceptTypes\\ArrayShapes::doBar() expects array{foo: callable(): mixed}, array{}|array{foo: 'date'} given.", "line": 596, @@ -169,4 +159,4 @@ "line": 756, "ignorable": true } -] +] \ No newline at end of file From 71b347e1fc0ba569e0c6543e979392f728b322a9 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 28 Apr 2026 15:47:29 +0200 Subject: [PATCH 14/66] Refactor - allow other message to be passed from getIterableTypesWithMissingValueTypehint --- src/Rules/Classes/LocalTypeAliasesCheck.php | 5 ++--- src/Rules/Classes/MethodTagCheck.php | 5 ++--- src/Rules/Classes/MixinCheck.php | 5 ++--- src/Rules/Classes/PropertyTagCheck.php | 5 ++--- .../MissingClassConstantTypehintRule.php | 5 ++--- .../MissingFunctionParameterTypehintRule.php | 5 ++--- .../MissingFunctionReturnTypehintRule.php | 5 ++--- .../MissingMethodParameterTypehintRule.php | 5 ++--- .../MissingMethodReturnTypehintRule.php | 5 ++--- .../Methods/MissingMethodSelfOutTypeRule.php | 5 ++--- src/Rules/MissingTypehintCheck.php | 19 ++++++++++++------- src/Rules/PhpDoc/AssertRuleHelper.php | 5 ++--- .../PhpDoc/InvalidPhpDocVarTagTypeRule.php | 6 ++---- .../MissingPropertyTypehintRule.php | 5 ++--- .../SetPropertyHookParameterRule.php | 5 ++--- 15 files changed, 40 insertions(+), 50 deletions(-) diff --git a/src/Rules/Classes/LocalTypeAliasesCheck.php b/src/Rules/Classes/LocalTypeAliasesCheck.php index a849ecac8c4..8f991484e8b 100644 --- a/src/Rules/Classes/LocalTypeAliasesCheck.php +++ b/src/Rules/Classes/LocalTypeAliasesCheck.php @@ -194,10 +194,9 @@ public function checkInTraitDefinitionContext(ClassReflection $reflection): arra continue; } - foreach ($this->missingTypehintCheck->getIterableTypesWithMissingValueTypehint($resolvedType) as $iterableType) { - $iterableTypeDescription = $iterableType->describe(VerbosityLevel::typeOnly()); + foreach ($this->missingTypehintCheck->getIterableTypesWithMissingValueTypehint($resolvedType) as $iterableTypeDescription) { $errors[] = RuleErrorBuilder::message(sprintf( - '%s %s has type alias %s with no value type specified in iterable type %s.', + '%s %s has type alias %s with no value type specified in %s.', $reflection->getClassTypeDescription(), $reflection->getDisplayName(), $aliasName, diff --git a/src/Rules/Classes/MethodTagCheck.php b/src/Rules/Classes/MethodTagCheck.php index 88e5e3a4508..8928d8c694b 100644 --- a/src/Rules/Classes/MethodTagCheck.php +++ b/src/Rules/Classes/MethodTagCheck.php @@ -190,10 +190,9 @@ private function checkMethodTypeInTraitDefinitionContext(ClassReflection $classR ->build(); } - foreach ($this->missingTypehintCheck->getIterableTypesWithMissingValueTypehint($type) as $iterableType) { - $iterableTypeDescription = $iterableType->describe(VerbosityLevel::typeOnly()); + foreach ($this->missingTypehintCheck->getIterableTypesWithMissingValueTypehint($type) as $iterableTypeDescription) { $errors[] = RuleErrorBuilder::message(sprintf( - '%s %s has PHPDoc tag @method for method %s() %s with no value type specified in iterable type %s.', + '%s %s has PHPDoc tag @method for method %s() %s with no value type specified in %s.', $classReflection->getClassTypeDescription(), $classReflection->getDisplayName(), $methodName, diff --git a/src/Rules/Classes/MixinCheck.php b/src/Rules/Classes/MixinCheck.php index ecdfff0d92b..eb73d677265 100644 --- a/src/Rules/Classes/MixinCheck.php +++ b/src/Rules/Classes/MixinCheck.php @@ -76,10 +76,9 @@ public function checkInTraitDefinitionContext(ClassReflection $classReflection): continue; } - foreach ($this->missingTypehintCheck->getIterableTypesWithMissingValueTypehint($type) as $iterableType) { - $iterableTypeDescription = $iterableType->describe(VerbosityLevel::typeOnly()); + foreach ($this->missingTypehintCheck->getIterableTypesWithMissingValueTypehint($type) as $iterableTypeDescription) { $errors[] = RuleErrorBuilder::message(sprintf( - '%s %s has PHPDoc tag @mixin with no value type specified in iterable type %s.', + '%s %s has PHPDoc tag @mixin with no value type specified in %s.', $classReflection->getClassTypeDescription(), $classReflection->getDisplayName(), $iterableTypeDescription, diff --git a/src/Rules/Classes/PropertyTagCheck.php b/src/Rules/Classes/PropertyTagCheck.php index 6b4a42c905a..ccaed0038bc 100644 --- a/src/Rules/Classes/PropertyTagCheck.php +++ b/src/Rules/Classes/PropertyTagCheck.php @@ -171,10 +171,9 @@ private function checkPropertyTypeInTraitDefinitionContext(ClassReflection $clas ->build(); } - foreach ($this->missingTypehintCheck->getIterableTypesWithMissingValueTypehint($type) as $iterableType) { - $iterableTypeDescription = $iterableType->describe(VerbosityLevel::typeOnly()); + foreach ($this->missingTypehintCheck->getIterableTypesWithMissingValueTypehint($type) as $iterableTypeDescription) { $errors[] = RuleErrorBuilder::message(sprintf( - '%s %s has PHPDoc tag %s for property $%s with no value type specified in iterable type %s.', + '%s %s has PHPDoc tag %s for property $%s with no value type specified in %s.', $classReflection->getClassTypeDescription(), $classReflection->getDisplayName(), $tagName, diff --git a/src/Rules/Constants/MissingClassConstantTypehintRule.php b/src/Rules/Constants/MissingClassConstantTypehintRule.php index bb2d10164bc..d8887c773df 100644 --- a/src/Rules/Constants/MissingClassConstantTypehintRule.php +++ b/src/Rules/Constants/MissingClassConstantTypehintRule.php @@ -58,10 +58,9 @@ private function processSingleConstant(ClassReflection $classReflection, string } $errors = []; - foreach ($this->missingTypehintCheck->getIterableTypesWithMissingValueTypehint($constantType) as $iterableType) { - $iterableTypeDescription = $iterableType->describe(VerbosityLevel::typeOnly()); + foreach ($this->missingTypehintCheck->getIterableTypesWithMissingValueTypehint($constantType) as $iterableTypeDescription) { $errors[] = RuleErrorBuilder::message(sprintf( - 'Constant %s::%s type has no value type specified in iterable type %s.', + 'Constant %s::%s type has no value type specified in %s.', $constantReflection->getDeclaringClass()->getDisplayName(), $constantName, $iterableTypeDescription, diff --git a/src/Rules/Functions/MissingFunctionParameterTypehintRule.php b/src/Rules/Functions/MissingFunctionParameterTypehintRule.php index 1246dc81e9c..4476427835c 100644 --- a/src/Rules/Functions/MissingFunctionParameterTypehintRule.php +++ b/src/Rules/Functions/MissingFunctionParameterTypehintRule.php @@ -80,10 +80,9 @@ private function checkFunctionParameter(FunctionReflection $functionReflection, } $messages = []; - foreach ($this->missingTypehintCheck->getIterableTypesWithMissingValueTypehint($parameterType) as $iterableType) { - $iterableTypeDescription = $iterableType->describe(VerbosityLevel::typeOnly()); + foreach ($this->missingTypehintCheck->getIterableTypesWithMissingValueTypehint($parameterType) as $iterableTypeDescription) { $messages[] = RuleErrorBuilder::message(sprintf( - 'Function %s() has %s with no value type specified in iterable type %s.', + 'Function %s() has %s with no value type specified in %s.', $functionReflection->getName(), $parameterMessage, $iterableTypeDescription, diff --git a/src/Rules/Functions/MissingFunctionReturnTypehintRule.php b/src/Rules/Functions/MissingFunctionReturnTypehintRule.php index 0fd30c79f99..09c276a9b49 100644 --- a/src/Rules/Functions/MissingFunctionReturnTypehintRule.php +++ b/src/Rules/Functions/MissingFunctionReturnTypehintRule.php @@ -48,9 +48,8 @@ public function processNode(Node $node, Scope $scope): array } $messages = []; - foreach ($this->missingTypehintCheck->getIterableTypesWithMissingValueTypehint($returnType) as $iterableType) { - $iterableTypeDescription = $iterableType->describe(VerbosityLevel::typeOnly()); - $messages[] = RuleErrorBuilder::message(sprintf('Function %s() return type has no value type specified in iterable type %s.', $functionReflection->getName(), $iterableTypeDescription)) + foreach ($this->missingTypehintCheck->getIterableTypesWithMissingValueTypehint($returnType) as $iterableTypeDescription) { + $messages[] = RuleErrorBuilder::message(sprintf('Function %s() return type has no value type specified in %s.', $functionReflection->getName(), $iterableTypeDescription)) ->tip(MissingTypehintCheck::MISSING_ITERABLE_VALUE_TYPE_TIP) ->identifier('missingType.iterableValue') ->build(); diff --git a/src/Rules/Methods/MissingMethodParameterTypehintRule.php b/src/Rules/Methods/MissingMethodParameterTypehintRule.php index 38516866320..a0a5cd3b936 100644 --- a/src/Rules/Methods/MissingMethodParameterTypehintRule.php +++ b/src/Rules/Methods/MissingMethodParameterTypehintRule.php @@ -81,10 +81,9 @@ private function checkMethodParameter(MethodReflection $methodReflection, string } $messages = []; - foreach ($this->missingTypehintCheck->getIterableTypesWithMissingValueTypehint($parameterType) as $iterableType) { - $iterableTypeDescription = $iterableType->describe(VerbosityLevel::typeOnly()); + foreach ($this->missingTypehintCheck->getIterableTypesWithMissingValueTypehint($parameterType) as $iterableTypeDescription) { $messages[] = RuleErrorBuilder::message(sprintf( - 'Method %s::%s() has %s with no value type specified in iterable type %s.', + 'Method %s::%s() has %s with no value type specified in %s.', $methodReflection->getDeclaringClass()->getDisplayName(), $methodReflection->getName(), $parameterMessage, diff --git a/src/Rules/Methods/MissingMethodReturnTypehintRule.php b/src/Rules/Methods/MissingMethodReturnTypehintRule.php index e127a143613..ea40b006706 100644 --- a/src/Rules/Methods/MissingMethodReturnTypehintRule.php +++ b/src/Rules/Methods/MissingMethodReturnTypehintRule.php @@ -54,10 +54,9 @@ public function processNode(Node $node, Scope $scope): array } $messages = []; - foreach ($this->missingTypehintCheck->getIterableTypesWithMissingValueTypehint($returnType) as $iterableType) { - $iterableTypeDescription = $iterableType->describe(VerbosityLevel::typeOnly()); + foreach ($this->missingTypehintCheck->getIterableTypesWithMissingValueTypehint($returnType) as $iterableTypeDescription) { $messages[] = RuleErrorBuilder::message(sprintf( - 'Method %s::%s() return type has no value type specified in iterable type %s.', + 'Method %s::%s() return type has no value type specified in %s.', $methodReflection->getDeclaringClass()->getDisplayName(), $methodReflection->getName(), $iterableTypeDescription, diff --git a/src/Rules/Methods/MissingMethodSelfOutTypeRule.php b/src/Rules/Methods/MissingMethodSelfOutTypeRule.php index 63966055b63..72fb8a1c1dc 100644 --- a/src/Rules/Methods/MissingMethodSelfOutTypeRule.php +++ b/src/Rules/Methods/MissingMethodSelfOutTypeRule.php @@ -45,10 +45,9 @@ public function processNode(Node $node, Scope $scope): array $phpDocTagMessage = 'PHPDoc tag @phpstan-self-out'; $messages = []; - foreach ($this->missingTypehintCheck->getIterableTypesWithMissingValueTypehint($selfOutType) as $iterableType) { - $iterableTypeDescription = $iterableType->describe(VerbosityLevel::typeOnly()); + foreach ($this->missingTypehintCheck->getIterableTypesWithMissingValueTypehint($selfOutType) as $iterableTypeDescription) { $messages[] = RuleErrorBuilder::message(sprintf( - 'Method %s::%s() has %s with no value type specified in iterable type %s.', + 'Method %s::%s() has %s with no value type specified in %s.', $classReflection->getDisplayName(), $methodReflection->getName(), $phpDocTagMessage, diff --git a/src/Rules/MissingTypehintCheck.php b/src/Rules/MissingTypehintCheck.php index b43d51b6dd8..426875b87d1 100644 --- a/src/Rules/MissingTypehintCheck.php +++ b/src/Rules/MissingTypehintCheck.php @@ -23,6 +23,7 @@ use PHPStan\Type\ObjectType; use PHPStan\Type\Type; use PHPStan\Type\TypeTraverser; +use PHPStan\Type\VerbosityLevel; use Traversable; use function array_filter; use function array_keys; @@ -61,12 +62,16 @@ public function __construct( } /** - * @return Type[] + * Each returned string is a fully formatted phrase describing the + * offending type — e.g. `iterable type array` — so callers can drop it + * straight into their error message without further formatting. + * + * @return string[] */ public function getIterableTypesWithMissingValueTypehint(Type $type): array { - $iterablesWithMissingValueTypehint = []; - TypeTraverser::map($type, function (Type $type, callable $traverse) use (&$iterablesWithMissingValueTypehint): Type { + $descriptions = []; + TypeTraverser::map($type, function (Type $type, callable $traverse) use (&$descriptions): Type { if ($type instanceof TemplateType) { return $type; } @@ -91,8 +96,8 @@ public function getIterableTypesWithMissingValueTypehint(Type $type): array return $traverse(new IntersectionType($nonArrayInner)); } if ($type instanceof ConditionalType || $type instanceof ConditionalTypeForParameter) { - $iterablesWithMissingValueTypehint = array_merge( - $iterablesWithMissingValueTypehint, + $descriptions = array_merge( + $descriptions, $this->getIterableTypesWithMissingValueTypehint($type->getIf()), $this->getIterableTypesWithMissingValueTypehint($type->getElse()), ); @@ -102,7 +107,7 @@ public function getIterableTypesWithMissingValueTypehint(Type $type): array if ($type->isIterable()->yes()) { $iterableValue = $type->getIterableValueType(); if ($iterableValue instanceof MixedType && !$iterableValue->isExplicitMixed()) { - $iterablesWithMissingValueTypehint[] = $type; + $descriptions[] = sprintf('iterable type %s', $type->describe(VerbosityLevel::typeOnly())); } if ($type instanceof IntersectionType) { if ($type->isList()->yes()) { @@ -115,7 +120,7 @@ public function getIterableTypesWithMissingValueTypehint(Type $type): array return $traverse($type); }); - return $iterablesWithMissingValueTypehint; + return $descriptions; } /** diff --git a/src/Rules/PhpDoc/AssertRuleHelper.php b/src/Rules/PhpDoc/AssertRuleHelper.php index 0dc14b638ae..41296e7e667 100644 --- a/src/Rules/PhpDoc/AssertRuleHelper.php +++ b/src/Rules/PhpDoc/AssertRuleHelper.php @@ -175,10 +175,9 @@ public function check( continue; } - foreach ($this->missingTypehintCheck->getIterableTypesWithMissingValueTypehint($assertedType) as $iterableType) { - $iterableTypeDescription = $iterableType->describe(VerbosityLevel::typeOnly()); + foreach ($this->missingTypehintCheck->getIterableTypesWithMissingValueTypehint($assertedType) as $iterableTypeDescription) { $errors[] = RuleErrorBuilder::message(sprintf( - 'PHPDoc tag %s for %s has no value type specified in iterable type %s.', + 'PHPDoc tag %s for %s has no value type specified in %s.', $tagName, $assertedExprString, $iterableTypeDescription, diff --git a/src/Rules/PhpDoc/InvalidPhpDocVarTagTypeRule.php b/src/Rules/PhpDoc/InvalidPhpDocVarTagTypeRule.php index 7d6090f70a0..37e31c19a49 100644 --- a/src/Rules/PhpDoc/InvalidPhpDocVarTagTypeRule.php +++ b/src/Rules/PhpDoc/InvalidPhpDocVarTagTypeRule.php @@ -16,7 +16,6 @@ use PHPStan\Rules\Rule; use PHPStan\Rules\RuleErrorBuilder; use PHPStan\Type\FileTypeMapper; -use PHPStan\Type\VerbosityLevel; use function array_map; use function array_merge; use function is_string; @@ -99,10 +98,9 @@ public function processNode(Node $node, Scope $scope): array } if ($this->checkMissingVarTagTypehint) { - foreach ($this->missingTypehintCheck->getIterableTypesWithMissingValueTypehint($varTagType) as $iterableType) { - $iterableTypeDescription = $iterableType->describe(VerbosityLevel::typeOnly()); + foreach ($this->missingTypehintCheck->getIterableTypesWithMissingValueTypehint($varTagType) as $iterableTypeDescription) { $errors[] = RuleErrorBuilder::message(sprintf( - '%s has no value type specified in iterable type %s.', + '%s has no value type specified in %s.', $identifier, $iterableTypeDescription, )) diff --git a/src/Rules/Properties/MissingPropertyTypehintRule.php b/src/Rules/Properties/MissingPropertyTypehintRule.php index 889092b699b..56692cac98e 100644 --- a/src/Rules/Properties/MissingPropertyTypehintRule.php +++ b/src/Rules/Properties/MissingPropertyTypehintRule.php @@ -52,10 +52,9 @@ public function processNode(Node $node, Scope $scope): array } $messages = []; - foreach ($this->missingTypehintCheck->getIterableTypesWithMissingValueTypehint($propertyType) as $iterableType) { - $iterableTypeDescription = $iterableType->describe(VerbosityLevel::typeOnly()); + foreach ($this->missingTypehintCheck->getIterableTypesWithMissingValueTypehint($propertyType) as $iterableTypeDescription) { $messages[] = RuleErrorBuilder::message(sprintf( - 'Property %s::$%s type has no value type specified in iterable type %s.', + 'Property %s::$%s type has no value type specified in %s.', $propertyReflection->getDeclaringClass()->getDisplayName(), $node->getName(), $iterableTypeDescription, diff --git a/src/Rules/Properties/SetPropertyHookParameterRule.php b/src/Rules/Properties/SetPropertyHookParameterRule.php index 82f89362b82..1bc7cf1b965 100644 --- a/src/Rules/Properties/SetPropertyHookParameterRule.php +++ b/src/Rules/Properties/SetPropertyHookParameterRule.php @@ -119,10 +119,9 @@ public function processNode(Node $node, Scope $scope): array return $errors; } - foreach ($this->missingTypehintCheck->getIterableTypesWithMissingValueTypehint($parameterType) as $iterableType) { - $iterableTypeDescription = $iterableType->describe(VerbosityLevel::typeOnly()); + foreach ($this->missingTypehintCheck->getIterableTypesWithMissingValueTypehint($parameterType) as $iterableTypeDescription) { $errors[] = RuleErrorBuilder::message(sprintf( - 'Set hook for property %s::$%s has parameter $%s with no value type specified in iterable type %s.', + 'Set hook for property %s::$%s has parameter $%s with no value type specified in %s.', $classReflection->getDisplayName(), $hookReflection->getHookedPropertyName(), $parameter->getName(), From fab9c534e5090d221305b5ab5807457dc27cf975 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 28 Apr 2026 15:55:04 +0200 Subject: [PATCH 15/66] Better "missing iterable value type" for unsealed types --- src/Rules/MissingTypehintCheck.php | 22 ++++++++++++++++++ src/Type/Constant/ConstantArrayType.php | 15 ++++++++++++ ...MissingMethodParameterTypehintRuleTest.php | 10 ++++++++ .../missing-method-parameter-typehint.php | 23 +++++++++++++++++++ 4 files changed, 70 insertions(+) diff --git a/src/Rules/MissingTypehintCheck.php b/src/Rules/MissingTypehintCheck.php index 426875b87d1..d5f765507db 100644 --- a/src/Rules/MissingTypehintCheck.php +++ b/src/Rules/MissingTypehintCheck.php @@ -14,6 +14,7 @@ use PHPStan\Type\ClosureType; use PHPStan\Type\ConditionalType; use PHPStan\Type\ConditionalTypeForParameter; +use PHPStan\Type\Constant\ConstantArrayType; use PHPStan\Type\Generic\GenericObjectType; use PHPStan\Type\Generic\GenericStaticType; use PHPStan\Type\Generic\TemplateType; @@ -23,6 +24,7 @@ use PHPStan\Type\ObjectType; use PHPStan\Type\Type; use PHPStan\Type\TypeTraverser; +use PHPStan\Type\UnionType; use PHPStan\Type\VerbosityLevel; use Traversable; use function array_filter; @@ -105,6 +107,26 @@ public function getIterableTypesWithMissingValueTypehint(Type $type): array return $type; } if ($type->isIterable()->yes()) { + if ($type->isConstantArray()->yes()) { + $type = TypeTraverser::map($type, static function (Type $type, callable $traverse) use (&$descriptions) { + if ($type instanceof UnionType || $type instanceof IntersectionType) { + return $traverse($type); + } + + if ($type instanceof ConstantArrayType) { + $unsealed = $type->getUnsealedTypes(); + if ($unsealed !== null) { + $iterableUnsealedValue = $unsealed[1]; + if ($iterableUnsealedValue instanceof MixedType && !$iterableUnsealedValue->isExplicitMixed()) { + $descriptions[] = 'unsealed extra keys (...)'; + } + return $traverse($type->dropUnsealedTypes()); + } + } + + return $traverse($type); + }); + } $iterableValue = $type->getIterableValueType(); if ($iterableValue instanceof MixedType && !$iterableValue->isExplicitMixed()) { $descriptions[] = sprintf('iterable type %s', $type->describe(VerbosityLevel::typeOnly())); diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index 27988d3f119..77db3ff40a7 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -195,6 +195,21 @@ public function getUnsealedTypes(): ?array return $this->unsealed; } + /** + * @internal + */ + public function dropUnsealedTypes(): self + { + return $this->recreate( + $this->keyTypes, + $this->valueTypes, + $this->nextAutoIndexes, + $this->optionalKeys, + $this->isList, + null, + ); + } + /** * @param list $keyTypes * @param array $valueTypes diff --git a/tests/PHPStan/Rules/Methods/MissingMethodParameterTypehintRuleTest.php b/tests/PHPStan/Rules/Methods/MissingMethodParameterTypehintRuleTest.php index 9f2056500d4..e568f07eca3 100644 --- a/tests/PHPStan/Rules/Methods/MissingMethodParameterTypehintRuleTest.php +++ b/tests/PHPStan/Rules/Methods/MissingMethodParameterTypehintRuleTest.php @@ -86,6 +86,16 @@ public function testRule(): void 'Method MissingMethodParameterTypehint\Baz::acceptsGenericWithSomeDefaults() has parameter $c with generic class MissingMethodParameterTypehint\GenericClassWithSomeDefaults but does not specify its types: T, U (1-2 required)', 270, ], + [ + 'Method MissingMethodParameterTypehint\UnsealedArrayShape::doFoo() has parameter $a with no value type specified in unsealed extra keys (...).', + 284, + MissingTypehintCheck::MISSING_ITERABLE_VALUE_TYPE_TIP, + ], + [ + 'Method MissingMethodParameterTypehint\UnsealedArrayShape::doBar() has parameter $a with no value type specified in unsealed extra keys (...).', + 293, + MissingTypehintCheck::MISSING_ITERABLE_VALUE_TYPE_TIP, + ], ]; $this->analyse([__DIR__ . '/data/missing-method-parameter-typehint.php'], $errors); diff --git a/tests/PHPStan/Rules/Methods/data/missing-method-parameter-typehint.php b/tests/PHPStan/Rules/Methods/data/missing-method-parameter-typehint.php index 27fa039ef4d..a48a3cf8bf2 100644 --- a/tests/PHPStan/Rules/Methods/data/missing-method-parameter-typehint.php +++ b/tests/PHPStan/Rules/Methods/data/missing-method-parameter-typehint.php @@ -273,3 +273,26 @@ public function acceptsGenericWithSomeDefaults(GenericClassWithSomeDefaults $c) } } + +class UnsealedArrayShape +{ + + /** + * @param array{a: int, ...} $a + * @param array{a: int, ...} $b + */ + public function doFoo(array $a, array $b): void + { + + } + + /** + * @param non-empty-array{a?: int, b?: int, ...} $a + * @param non-empty-array{a?: int, b?: int, ...} $b + */ + public function doBar(array $a, array $b): void + { + + } + +} From 947902737d376a2255a49dd287f43eac6a657750 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 28 Apr 2026 16:06:44 +0200 Subject: [PATCH 16/66] Generics --- src/Type/Constant/ConstantArrayType.php | 37 ++++++++++++++++ .../Analyser/nsrt/unsealed-array-shapes.php | 43 +++++++++++++++++++ 2 files changed, 80 insertions(+) diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index 77db3ff40a7..55bfbe6597a 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -2181,6 +2181,43 @@ public function inferTemplateTypes(Type $receivedType): TemplateTypeMap $typeMap = $typeMap->union($valueType->inferTemplateTypes($receivedValueType)); } + $unsealed = $this->getUnsealedTypes(); + if ($unsealed !== null) { + [$unsealedKeyType, $unsealedValueType] = $unsealed; + + // Received's explicit keys not in $this's explicit keys are + // candidates for matching $this's unsealed extras pattern. + // Only contribute when the key type matches; mismatched explicit + // keys are extra entries the parameter wouldn't accept anyway, + // surfaced by the regular argument-type check. + $receivedKeyTypes = $receivedType->getKeyTypes(); + $receivedValueTypes = $receivedType->getValueTypes(); + foreach ($receivedKeyTypes as $j => $receivedKeyType) { + if ($this->hasOffsetValueType($receivedKeyType)->yes()) { + continue; + } + if (!$unsealedKeyType->isSuperTypeOf($receivedKeyType)->yes()) { + continue; + } + $typeMap = $typeMap->union($unsealedKeyType->inferTemplateTypes($receivedKeyType)); + $typeMap = $typeMap->union($unsealedValueType->inferTemplateTypes($receivedValueTypes[$j])); + } + + // Received's own unsealed extras describe "all the rest" — when + // the key type doesn't fit $this's unsealed key pattern there + // is no valid template assignment, so force NEVER. + $receivedUnsealed = $receivedType->getUnsealedTypes(); + if ($receivedUnsealed !== null) { + [$receivedUnsealedKey, $receivedUnsealedValue] = $receivedUnsealed; + if ($unsealedKeyType->isSuperTypeOf($receivedUnsealedKey)->no()) { + $typeMap = $typeMap->union($unsealedValueType->inferTemplateTypes(new NeverType())); + } else { + $typeMap = $typeMap->union($unsealedKeyType->inferTemplateTypes($receivedUnsealedKey)); + $typeMap = $typeMap->union($unsealedValueType->inferTemplateTypes($receivedUnsealedValue)); + } + } + } + return $typeMap; } diff --git a/tests/PHPStan/Analyser/nsrt/unsealed-array-shapes.php b/tests/PHPStan/Analyser/nsrt/unsealed-array-shapes.php index 0fa24cd435d..544610b2cb8 100644 --- a/tests/PHPStan/Analyser/nsrt/unsealed-array-shapes.php +++ b/tests/PHPStan/Analyser/nsrt/unsealed-array-shapes.php @@ -3,6 +3,7 @@ namespace UnsealedArrayShapes; use DateTimeImmutable; +use stdClass; use function PHPStan\Testing\assertType; class Foo @@ -83,3 +84,45 @@ public function edgeCases(array $a, array $b, array $c): void } } + +class Generics +{ + + /** + * @template T + * @param T $a + * @return array{a: int, ...} + */ + public function replace($a): array + { + + } + + /** + * @template T + * @param array{a: int, ...} $a + * @return T + */ + public function infer(array $a) + { + + } + +} + +/** + * @param Generics $g + * @param array{a: 1, b: 2, ...} $a + * @param array{a: 1, b: 2, ...} $b + * @param array $c + * @param array $d + * @return void + */ +function doFoo(Generics $g, array $a, array $b, array $c, array $d): void { + assertType('array{a: int, ...}', $g->replace(new stdClass())); + assertType('1|2|3', $g->infer([1, 2, 3, 'a' => 4])); + assertType('stdClass', $g->infer($a)); + assertType('*NEVER*', $g->infer($b)); + assertType('stdClass', $g->infer($c)); + assertType('stdClass', $g->infer($d)); +}; From c685b9c7386ede1206e2ba7cb29101da323852cd Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 29 Apr 2026 17:01:35 +0200 Subject: [PATCH 17/66] Preserve array shape and make it unsealed when non-constant key is assigned --- .../Constant/ConstantArrayTypeBuilder.php | 117 ++++++++++++------ .../Analyser/AnalyserIntegrationTest.php | 2 +- .../Analyser/nsrt/array-keys-branches.php | 4 +- tests/PHPStan/Analyser/nsrt/bug-14333.php | 4 +- .../Analyser/nsrt/constant-array-type-set.php | 10 +- tests/PHPStan/Analyser/nsrt/pr-4390.php | 2 +- .../Analyser/nsrt/unsealed-array-shapes.php | 53 ++++++++ 7 files changed, 142 insertions(+), 50 deletions(-) diff --git a/src/Type/Constant/ConstantArrayTypeBuilder.php b/src/Type/Constant/ConstantArrayTypeBuilder.php index d3c69eb3a62..c6f18b8dee8 100644 --- a/src/Type/Constant/ConstantArrayTypeBuilder.php +++ b/src/Type/Constant/ConstantArrayTypeBuilder.php @@ -283,9 +283,8 @@ public function setOffsetValueType(?Type $offsetType, Type $valueType, bool $opt } } if (count($scalarTypes) > 0 && count($scalarTypes) < self::ARRAY_COUNT_LIMIT) { - $match = true; - $hasMatch = false; $valueTypes = $this->valueTypes; + $unmatchedScalars = []; foreach ($scalarTypes as $scalarType) { $offsetMatch = false; @@ -304,61 +303,97 @@ public function setOffsetValueType(?Type $offsetType, Type $valueType, bool $opt } if ($offsetMatch) { - $hasMatch = true; continue; } - $match = false; + $unmatchedScalars[] = $scalarType; } - if ($match) { - $this->valueTypes = $valueTypes; + $this->valueTypes = $valueTypes; + + if (count($unmatchedScalars) === 0) { return; } - if (!$hasMatch) { - foreach ($scalarTypes as $scalarType) { - $this->keyTypes[] = $scalarType; - $this->valueTypes[] = $valueType; - $this->optionalKeys[] = count($this->keyTypes) - 1; + foreach ($unmatchedScalars as $scalarType) { + $this->keyTypes[] = $scalarType; + $this->valueTypes[] = $valueType; + $this->optionalKeys[] = count($this->keyTypes) - 1; - if (!($scalarType instanceof ConstantIntegerType)) { - continue; - } + if (!($scalarType instanceof ConstantIntegerType)) { + continue; + } - if (count($this->nextAutoIndexes) === 0) { - continue; - } + if (count($this->nextAutoIndexes) === 0) { + continue; + } - $max = max($this->nextAutoIndexes); - $offsetValue = $scalarType->getValue(); - if ($offsetValue < $max) { - continue; - } + $max = max($this->nextAutoIndexes); + $offsetValue = $scalarType->getValue(); + if ($offsetValue < $max) { + continue; + } - /** @var int|float $newAutoIndex */ - $newAutoIndex = $offsetValue + 1; - if (is_float($newAutoIndex)) { - continue; - } - $this->nextAutoIndexes[] = $newAutoIndex; + /** @var int|float $newAutoIndex */ + $newAutoIndex = $offsetValue + 1; + if (is_float($newAutoIndex)) { + continue; } + $this->nextAutoIndexes[] = $newAutoIndex; + } - $this->isList = TrinaryLogic::createNo(); + $this->isList = TrinaryLogic::createNo(); + + if ( + !$this->disableArrayDegradation + && count($this->keyTypes) > self::ARRAY_COUNT_LIMIT + ) { + $this->degradeToGeneralArray = true; + $this->oversized = true; + } - if ( - !$this->disableArrayDegradation - && count($this->keyTypes) > self::ARRAY_COUNT_LIMIT - ) { - $this->degradeToGeneralArray = true; - $this->oversized = true; + return; + } + + $this->isList = TrinaryLogic::createNo(); + + // If the builder is already unsealed (e.g. fresh bleeding-edge + // builder, or a PHPDoc shape like `array{a: int, ...}`), + // fold the unknown offset/value into the existing unsealed + // extras instead of dropping per-key precision by degrading to a + // general array. The actual decision between unsealed + // ConstantArrayType and general ArrayType is then made in + // getArray() based on whether any constant keys ended up + // alongside these extras. + if ($this->unsealed !== null) { + // Existing keys whose value the new offset could overwrite + // must widen to a union of (existing, new) — the assignment + // might or might not have hit them. + $residualOffset = $offsetType; + foreach ($this->keyTypes as $i => $keyType) { + if ($offsetType->isSuperTypeOf($keyType)->no()) { + continue; } + $this->valueTypes[$i] = TypeCombinator::union($this->valueTypes[$i], $valueType); + $residualOffset = TypeCombinator::remove($residualOffset, $keyType); + } + if ($residualOffset instanceof NeverType) { return; } - } - $this->isList = TrinaryLogic::createNo(); + [$existingKey, $existingValue] = $this->unsealed; + $isExplicitNever = $existingKey instanceof NeverType && $existingKey->isExplicit(); + if ($isExplicitNever) { + $this->unsealed = [$residualOffset, $valueType]; + } else { + $this->unsealed = [ + TypeCombinator::union($existingKey, $residualOffset), + TypeCombinator::union($existingValue, $valueType), + ]; + } + return; + } } if ($offsetType === null) { @@ -405,7 +440,11 @@ public function getArray(): Type [$unsealedKey, $unsealedValue] = $this->unsealed; $isExplicitNever = $unsealedKey instanceof NeverType && $unsealedKey->isExplicit(); if (!$isExplicitNever) { - return new ArrayType($unsealedKey, $unsealedValue); + $arrayType = new ArrayType($unsealedKey, $unsealedValue); + if ($this->isNonEmpty->yes()) { + return TypeCombinator::intersect($arrayType, new NonEmptyArrayType()); + } + return $arrayType; } } return new ConstantArrayType([], [], unsealed: $this->unsealed); @@ -414,7 +453,7 @@ public function getArray(): Type if (!$this->degradeToGeneralArray) { /** @var list $keyTypes */ $keyTypes = $this->keyTypes; - $array = new ConstantArrayType($keyTypes, $this->valueTypes, $this->nextAutoIndexes, $this->optionalKeys, $this->isList, , $this->unsealed); + $array = new ConstantArrayType($keyTypes, $this->valueTypes, $this->nextAutoIndexes, $this->optionalKeys, $this->isList, $this->unsealed); if ($this->isNonEmpty->yes() && !$array->isIterableAtLeastOnce()->yes()) { return TypeCombinator::intersect($array, new NonEmptyArrayType()); } diff --git a/tests/PHPStan/Analyser/AnalyserIntegrationTest.php b/tests/PHPStan/Analyser/AnalyserIntegrationTest.php index 08b8f097f3a..7f0f0fac5d3 100644 --- a/tests/PHPStan/Analyser/AnalyserIntegrationTest.php +++ b/tests/PHPStan/Analyser/AnalyserIntegrationTest.php @@ -847,7 +847,7 @@ public function testBug7094(): void $this->assertSame('Return type of call to method Bug7094\Foo::getAttribute() contains unresolvable type.', $errors[4]->getMessage()); $this->assertSame(79, $errors[4]->getLine()); - $this->assertSame('Parameter #1 $attr of method Bug7094\Foo::setAttributes() expects array{foo?: string, bar?: 5|6|7, baz?: bool}, non-empty-array<\'bar\'|\'baz\'|\'foo\'|K of string, 5|6|7|bool|string> given.', $errors[5]->getMessage()); + $this->assertSame('Parameter #1 $attr of method Bug7094\Foo::setAttributes() expects array{foo?: string, bar?: 5|6|7, baz?: bool}, array{foo?: string, bar?: 5|6|7, baz?: bool, ...} given.', $errors[5]->getMessage()); $this->assertSame(29, $errors[5]->getLine()); } diff --git a/tests/PHPStan/Analyser/nsrt/array-keys-branches.php b/tests/PHPStan/Analyser/nsrt/array-keys-branches.php index f688a124645..b803e0bb1fc 100644 --- a/tests/PHPStan/Analyser/nsrt/array-keys-branches.php +++ b/tests/PHPStan/Analyser/nsrt/array-keys-branches.php @@ -58,7 +58,7 @@ function (array $generalArray) { assertType('mixed~null', $generalArray['key']); assertType('array{0: \'foo\', 1: \'bar\', 2?: \'baz\'}', $arrayAppendedInIf); assertType('non-empty-list<\'bar\'|\'baz\'|\'foo\'>', $arrayAppendedInForeach); - assertType('non-empty-array, literal-string&lowercase-string&non-falsy-string>', $anotherArrayAppendedInForeach); + assertType("array{literal-string&lowercase-string&non-falsy-string, literal-string&lowercase-string&non-falsy-string, ..., 'baz'>}", $anotherArrayAppendedInForeach); assertType('\'str\'', $array['n']); assertType('int<0, max>', $incremented); assertType('0|1', $setFromZeroToOne); @@ -124,7 +124,7 @@ function (array $generalArray, array $xs) { assertType('mixed~null', $generalArray['key']); assertType('array{0: \'foo\', 1: \'bar\', 2?: \'baz\'}', $arrayAppendedInIf); assertType('non-empty-list<\'bar\'|\'baz\'|\'foo\'>', $arrayAppendedInForeach); - assertType('non-empty-array, literal-string&lowercase-string&non-falsy-string>', $anotherArrayAppendedInForeach); + assertType("array{literal-string&lowercase-string&non-falsy-string, literal-string&lowercase-string&non-falsy-string, ..., 'baz'>}", $anotherArrayAppendedInForeach); assertType('\'str\'', $array['n']); assertType('int<0, max>', $incremented); assertType('0|1', $setFromZeroToOne); diff --git a/tests/PHPStan/Analyser/nsrt/bug-14333.php b/tests/PHPStan/Analyser/nsrt/bug-14333.php index e989a87bc63..5251039ac1d 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-14333.php +++ b/tests/PHPStan/Analyser/nsrt/bug-14333.php @@ -45,8 +45,8 @@ function testNonConstantKeyBreaksImplicitIndex(int $key): void // Since $key is non-constant, we don't know the implicit indices of &$a and &$c // so we can't correctly track the reference propagation $b[2] = 2; - assertType("1|2|'test'|'x'", $a); // Could be 1|2 - assertType("1|2|'test'|'x'", $c); // Could be 'test'|2 + assertType("1|2|'test'", $a); // Could be 1|2 + assertType("1|2|'test'", $c); // Could be 'test'|2 } function testNested(): void diff --git a/tests/PHPStan/Analyser/nsrt/constant-array-type-set.php b/tests/PHPStan/Analyser/nsrt/constant-array-type-set.php index 9ae0b88828a..77331734230 100644 --- a/tests/PHPStan/Analyser/nsrt/constant-array-type-set.php +++ b/tests/PHPStan/Analyser/nsrt/constant-array-type-set.php @@ -11,7 +11,7 @@ public function doFoo(int $i) { $a = [1, 2, 3]; $a[$i] = 4; - assertType('non-empty-array', $a); + assertType('array{1|4, 2|4, 3|4, ...|int<3, max>, 4>}', $a); $b = [1, 2, 3]; $b[3] = 4; @@ -33,7 +33,7 @@ public function doFoo(int $i) /** @var 0|1|2|3 $offset3 */ $offset3 = doFoo(); $e[$offset3] = true; - assertType('non-empty-array<0|1|2|3, bool>', $e); + assertType('array{0: bool, 1: bool, 2: bool, 3?: true}', $e); $f = [false, false, false]; /** @var 0|1 $offset4 */ @@ -72,7 +72,7 @@ public function doBar3(int $offset): void { $a = [false, false, false, false, false]; $a[$offset] = true; - assertType('non-empty-array, bool>', $a); + assertType('array{bool, bool, bool, bool, bool, ..., true>}', $a); } /** @@ -83,7 +83,7 @@ public function doBar4(int $offset): void { $a = [false, false, false, false, false]; $a[$offset] = true; - assertType('non-empty-array, bool>', $a); + assertType('array{bool, false, false, false, false, ..., true>}', $a); } /** @@ -94,7 +94,7 @@ public function doBar5(int $offset): void { $a = [false, false, false]; $a[$offset] = true; - assertType('non-empty-array, bool>', $a); + assertType('array{0: bool, 1: bool, 2: bool, 3?: true, 4?: true}', $a); } public function doBar6(bool $offset): void diff --git a/tests/PHPStan/Analyser/nsrt/pr-4390.php b/tests/PHPStan/Analyser/nsrt/pr-4390.php index c318b9b6ee8..8f16a609665 100644 --- a/tests/PHPStan/Analyser/nsrt/pr-4390.php +++ b/tests/PHPStan/Analyser/nsrt/pr-4390.php @@ -13,6 +13,6 @@ function (string $s): void { } } - assertType('non-empty-array, non-empty-array, string>>', $locations); + assertType('non-empty-list, string>>', $locations); assertType('non-empty-array, string>', $locations[0]); }; diff --git a/tests/PHPStan/Analyser/nsrt/unsealed-array-shapes.php b/tests/PHPStan/Analyser/nsrt/unsealed-array-shapes.php index 544610b2cb8..89a4473d118 100644 --- a/tests/PHPStan/Analyser/nsrt/unsealed-array-shapes.php +++ b/tests/PHPStan/Analyser/nsrt/unsealed-array-shapes.php @@ -83,6 +83,59 @@ public function edgeCases(array $a, array $b, array $c): void assertType('array{a: int, b: float|string, c?: string}', $c); } + /** + * @param array $a + * @param array $b + * @param array $c + * @return void + */ + public function generalArray(array $a, array $b, array $c): void + { + $a[1] = 'foo'; + assertType("non-empty-array&hasOffsetValue(1, 'foo')", $a); + + $b[1] = 'foo'; + assertType("non-empty-array<1|string, string>&hasOffsetValue(1, 'foo')", $b); + + $c['foo'] = 1; + assertType("non-empty-array&hasOffsetValue('foo', 1)", $c); + } + + public function sealedBecomesUnsealed(string $s, int $i): void + { + $a = []; + $a[] = 5; + assertType('array{5}', $a); + $a[$s] = 6; + assertType('array{5, ...}', $a); + $a[$i] = 7; + assertType('array{5|7, ...|int<1,max>|string, 6|7>}', $a); + + $b = []; + $b[$s] = 1; + assertType('non-empty-array', $b); + + $b[$i] = 2; + assertType('non-empty-array', $b); + + $c = [ + 1 => 'foo', + $s => 'bar', + ]; + assertType("array{1: 'foo', ...}", $c); + + $d = [ + $s => 'foo', + 1 => 'bar', + ]; + assertType("array{1: 'bar', ...}", $d); + + $e = [ + $s => 'foo', + ]; + assertType('non-empty-array', $e); + } + } class Generics From 58e0f05d6cd8046175d6c2b7bcc96a9c46ab0362 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Thu, 30 Apr 2026 10:35:56 +0200 Subject: [PATCH 18/66] Correctly generalize unsealed array shapes --- src/Analyser/MutatingScope.php | 24 ++++- .../Analyser/AnalyserIntegrationTest.php | 2 +- .../Analyser/nsrt/array-keys-branches.php | 2 +- tests/PHPStan/Analyser/nsrt/bug-14552.php | 2 +- .../bug-yield-oversized-self-rejection.php | 2 +- tests/PHPStan/Analyser/nsrt/pr-4390.php | 2 +- .../Analyser/nsrt/unsealed-array-shapes.php | 95 ++++++++++++++++++- 7 files changed, 122 insertions(+), 7 deletions(-) diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index f6bfa8f5195..16e2112b1f5 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -4231,8 +4231,30 @@ private function generalizeType(Type $a, Type $b, int $depth): Type $resultTypes[] = $resultArrayBuilder->getArray(); } else { + // Both inputs are sealed constant array shapes — their + // key sets are finite by construction. When taking the + // fall-through ArrayType path we still recurse into + // `generalizeType` for the iterable key, which would + // widen e.g. `0|1` to `int<0, max>` and lose the loop's + // per-iteration precision. Instead, keep the literal + // union of constant keys so the loop's bound stays + // visible. + $bothSealed = true; + foreach ([...$constantArrays['a'], ...$constantArrays['b']] as $constantArrayCheck) { + foreach ($constantArrayCheck->getConstantArrays() as $constantArrayInstance) { + if (!$constantArrayInstance->isSealed()->yes()) { + $bothSealed = false; + break 2; + } + } + } + if ($bothSealed) { + $resultKeyType = TypeCombinator::union($constantArraysA->getIterableKeyType(), $constantArraysB->getIterableKeyType()); + } else { + $resultKeyType = TypeCombinator::union($this->generalizeType($constantArraysA->getIterableKeyType(), $constantArraysB->getIterableKeyType(), $depth + 1)); + } $resultType = new ArrayType( - TypeCombinator::union($this->generalizeType($constantArraysA->getIterableKeyType(), $constantArraysB->getIterableKeyType(), $depth + 1)), + $resultKeyType, TypeCombinator::union($this->generalizeType($constantArraysA->getIterableValueType(), $constantArraysB->getIterableValueType(), $depth + 1)), ); $accessories = []; diff --git a/tests/PHPStan/Analyser/AnalyserIntegrationTest.php b/tests/PHPStan/Analyser/AnalyserIntegrationTest.php index 7f0f0fac5d3..42d04482597 100644 --- a/tests/PHPStan/Analyser/AnalyserIntegrationTest.php +++ b/tests/PHPStan/Analyser/AnalyserIntegrationTest.php @@ -847,7 +847,7 @@ public function testBug7094(): void $this->assertSame('Return type of call to method Bug7094\Foo::getAttribute() contains unresolvable type.', $errors[4]->getMessage()); $this->assertSame(79, $errors[4]->getLine()); - $this->assertSame('Parameter #1 $attr of method Bug7094\Foo::setAttributes() expects array{foo?: string, bar?: 5|6|7, baz?: bool}, array{foo?: string, bar?: 5|6|7, baz?: bool, ...} given.', $errors[5]->getMessage()); + $this->assertSame('Parameter #1 $attr of method Bug7094\Foo::setAttributes() expects array{foo?: string, bar?: 5|6|7, baz?: bool}, non-empty-array{foo?: 5|6|7|bool|string, bar?: 5|6|7|bool|string, baz?: 5|6|7|bool|string, ...} given.', $errors[5]->getMessage()); $this->assertSame(29, $errors[5]->getLine()); } diff --git a/tests/PHPStan/Analyser/nsrt/array-keys-branches.php b/tests/PHPStan/Analyser/nsrt/array-keys-branches.php index b803e0bb1fc..96d6e4b4a33 100644 --- a/tests/PHPStan/Analyser/nsrt/array-keys-branches.php +++ b/tests/PHPStan/Analyser/nsrt/array-keys-branches.php @@ -124,7 +124,7 @@ function (array $generalArray, array $xs) { assertType('mixed~null', $generalArray['key']); assertType('array{0: \'foo\', 1: \'bar\', 2?: \'baz\'}', $arrayAppendedInIf); assertType('non-empty-list<\'bar\'|\'baz\'|\'foo\'>', $arrayAppendedInForeach); - assertType("array{literal-string&lowercase-string&non-falsy-string, literal-string&lowercase-string&non-falsy-string, ..., 'baz'>}", $anotherArrayAppendedInForeach); + assertType("array{literal-string&lowercase-string&non-falsy-string, literal-string&lowercase-string&non-falsy-string, ..., 'baz'>}", $anotherArrayAppendedInForeach); assertType('\'str\'', $array['n']); assertType('int<0, max>', $incremented); assertType('0|1', $setFromZeroToOne); diff --git a/tests/PHPStan/Analyser/nsrt/bug-14552.php b/tests/PHPStan/Analyser/nsrt/bug-14552.php index 6f9d06c6669..1a527fe83e5 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-14552.php +++ b/tests/PHPStan/Analyser/nsrt/bug-14552.php @@ -25,7 +25,7 @@ function possiblyEmptyListForeach(array $keys): void foreach ($keys as $k) { $out[$k] = 1; } - assertType("array{}|array{a?: 1, b?: 1}", $out); + assertType("array{a?: 1, b?: 1}", $out); } /** diff --git a/tests/PHPStan/Analyser/nsrt/bug-yield-oversized-self-rejection.php b/tests/PHPStan/Analyser/nsrt/bug-yield-oversized-self-rejection.php index 435edbb767e..c5185f7af3c 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-yield-oversized-self-rejection.php +++ b/tests/PHPStan/Analyser/nsrt/bug-yield-oversized-self-rejection.php @@ -90,7 +90,7 @@ function build(string $eventClass): array ]; } - assertType("non-empty-array&oversized-array", $r); + assertType("non-empty-array&oversized-array", $r); return $r; } diff --git a/tests/PHPStan/Analyser/nsrt/pr-4390.php b/tests/PHPStan/Analyser/nsrt/pr-4390.php index 8f16a609665..c318b9b6ee8 100644 --- a/tests/PHPStan/Analyser/nsrt/pr-4390.php +++ b/tests/PHPStan/Analyser/nsrt/pr-4390.php @@ -13,6 +13,6 @@ function (string $s): void { } } - assertType('non-empty-list, string>>', $locations); + assertType('non-empty-array, non-empty-array, string>>', $locations); assertType('non-empty-array, string>', $locations[0]); }; diff --git a/tests/PHPStan/Analyser/nsrt/unsealed-array-shapes.php b/tests/PHPStan/Analyser/nsrt/unsealed-array-shapes.php index 89a4473d118..29704a04651 100644 --- a/tests/PHPStan/Analyser/nsrt/unsealed-array-shapes.php +++ b/tests/PHPStan/Analyser/nsrt/unsealed-array-shapes.php @@ -109,7 +109,7 @@ public function sealedBecomesUnsealed(string $s, int $i): void $a[$s] = 6; assertType('array{5, ...}', $a); $a[$i] = 7; - assertType('array{5|7, ...|int<1,max>|string, 6|7>}', $a); + assertType('array{5|7, ...|int<1, max>|string, 6|7>}', $a); $b = []; $b[$s] = 1; @@ -136,6 +136,99 @@ public function sealedBecomesUnsealed(string $s, int $i): void assertType('non-empty-array', $e); } + /** + * Loop iteration's `generalizeType` previously widened the integer key + * of a constant array shape to `int<0, max>` whenever the prev/current + * iterations had different (but finite) key sets. With the fix that + * keeps the constant-array key union when both shapes are sealed, + * loop-bounded counters stay within their actual range. + */ + public function loopBoundedCounter(): void + { + $arr = []; + for ($i = 0; $i < 5; $i++) { + $arr[$i] = 'v'; + } + assertType("non-empty-array, 'v'>", $arr); + } + + public function loopBoundedCounterWithCondition(): void + { + $arr = []; + for ($i = 0; $i < 5; $i++) { + if (rand()) { + $arr[$i] = 'v'; + } + } + assertType("array, 'v'>", $arr); + } + + /** + * The existing `'x'` key keeps its sealed slot through all iterations + * while the int counter grows; generalize merges the two sealed shapes + * via key union (no widening to `int<0, max>`). + */ + public function loopWithExistingSealedKey(): void + { + $arr = ['x' => 0]; + for ($i = 0; $i < 5; $i++) { + $arr[$i] = $i; + } + assertType("non-empty-array<'x'|int<0, 4>, int<0, max>>", $arr); + } + + /** + * Each iteration the body assigns a sealed constant key, then a + * non-constant offset — that second assignment promotes the array + * from sealed to unsealed (folding the unknown offset/value into the + * unsealed extras). The iteration's converged shape stays bounded by + * the loop's cond instead of widening to `int<0, max>`. + */ + public function loopSealedBecomesUnsealedEachIteration(string $s): void + { + $arr = []; + for ($i = 0; $i < 3; $i++) { + $arr[$i] = 'sealed'; + $arr[$s . '_' . $i] = 'unsealed'; + } + assertType("non-empty-array|non-falsy-string, literal-string&lowercase-string&non-falsy-string>", $arr); + } + + /** + * Starting from a PHPDoc-declared unsealed shape, a loop adds further + * non-constant entries. The sealed prefix (`a`) survives, the existing + * unsealed extras get unioned with the loop's per-iteration extras. + */ + public function loopMergesUnsealedExtras(string $key): void + { + /** @var array{a: int, ...} $arr */ + $arr = ['a' => 1]; + for ($i = 0; $i < 3; $i++) { + $arr[$key . $i] = $i; + } + assertType("array{a: int, ...}", $arr); + } + + /** + * Joining two unsealed shapes with disjoint sealed prefixes via + * scope merging collapses the result to a general array of + * `string => int` — neither sealed prefix survives because each is + * optional from the other branch's perspective and the unsealed + * extras of both sides cover the same key/value space. + * + * @param array{a: int, ...} $u1 + * @param array{b: int, ...} $u2 + */ + public function twoUnsealedJoined(array $u1, array $u2, bool $cond): void + { + if ($cond) { + $arr = $u1; + } else { + $arr = $u2; + } + assertType("non-empty-array", $arr); + } + } class Generics From b4700eead0cd7075f9ea20d3aec8942ef32ca1fb Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Thu, 30 Apr 2026 14:52:47 +0200 Subject: [PATCH 19/66] Fix array_search --- src/Type/Constant/ConstantArrayType.php | 20 ++++++ .../Analyser/nsrt/unsealed-array-shapes.php | 71 +++++++++++++++++++ 2 files changed, 91 insertions(+) diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index 55bfbe6597a..60f04475dc9 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -1419,6 +1419,26 @@ public function searchArray(Type $needleType, ?TrinaryLogic $strict = null): Typ $matches[] = $this->keyTypes[$index]; } + // Unsealed extras can host additional entries beyond the explicit + // keys, so the search may also find the needle there. The unsealed + // extras' presence is uncertain by definition (zero or more + // entries), so they can never make the needle "definitely found" + // (`hasIdenticalValue` stays false) — `false` always remains a + // possible result. + if ($this->unsealed !== null) { + [$unsealedKeyType, $unsealedValueType] = $this->unsealed; + $isExplicitNever = $unsealedKeyType instanceof NeverType && $unsealedKeyType->isExplicit(); + if (!$isExplicitNever) { + $considerUnsealed = true; + if ($strict->yes()) { + $considerUnsealed = !$unsealedValueType->isSuperTypeOf($needleType)->no(); + } + if ($considerUnsealed) { + $matches[] = $unsealedKeyType; + } + } + } + if (count($matches) > 0) { if ($hasIdenticalValue) { return TypeCombinator::union(...$matches); diff --git a/tests/PHPStan/Analyser/nsrt/unsealed-array-shapes.php b/tests/PHPStan/Analyser/nsrt/unsealed-array-shapes.php index 29704a04651..457c9fd3180 100644 --- a/tests/PHPStan/Analyser/nsrt/unsealed-array-shapes.php +++ b/tests/PHPStan/Analyser/nsrt/unsealed-array-shapes.php @@ -229,6 +229,77 @@ public function twoUnsealedJoined(array $u1, array $u2, bool $cond): void assertType("non-empty-array", $arr); } + /** + * `array_search` on a constant array shape with unsealed extras must + * also consider the extras: a strict needle that matches the unsealed + * value type makes the unsealed key type a possible result. The + * extras are always uncertain (zero or more entries) so `false` stays + * a possible result even when an explicit value definitely matches. + * + * @param array{a: 'foo', b: 'bar', ...} $arr + */ + public function searchUnsealedExclusiveValue(array $arr): void + { + assertType("'a'", array_search('foo', $arr, true)); + assertType("'b'", array_search('bar', $arr, true)); + assertType("string|false", array_search('baz', $arr, true)); + assertType("false", array_search('quux', $arr, true)); + } + + /** + * Strict search: when the unsealed value type is a different type + * than any explicit value, only one side can match a given needle. + * + * @param array{a: int, b: string, ...} $arr + */ + public function searchUnsealedStrictTypes(array $arr): void + { + assertType("int|false", array_search(true, $arr, true)); + assertType("'a'|false", array_search(42, $arr, true)); + assertType("'b'|false", array_search('hi', $arr, true)); + } + + /** + * Both explicit values and the unsealed extras can match a generic + * `int` needle. The explicit string keys `'a'`/`'b'` simplify into + * the broader `string` from the unsealed extras' key type, so the + * union collapses to `string|false`. + * + * @param array{a: int, b: int, ...} $arr + */ + public function searchUnsealedNeedleInBothSides(array $arr): void + { + assertType("string|false", array_search(99, $arr, true)); + } + + /** + * Non-strict search skips the value-type filter — the unsealed + * extras are always considered, since loose comparison can succeed + * across many otherwise-mismatched value pairs. + * + * @param array{a: 1, b: 2, ...} $arr + */ + public function searchUnsealedNonStrict(array $arr): void + { + // `'a'` is a definite hit (constant value matches needle exactly, + // not optional) so `false` is excluded; the explicit-key match + // then merges into the unsealed-extras' broader `string` key. + assertType("string", array_search(1, $arr, false)); + assertType("string|false", array_search(99, $arr, false)); + } + + /** + * Sealed array shape: searchArray's unsealed branch is a no-op + * (the `[NEVER, NEVER]` extras marker is excluded). Only the + * explicit keys are considered. + */ + public function searchSealed(): void + { + $arr = ['a' => 'foo', 'b' => 'bar']; + assertType("'a'", array_search('foo', $arr, true)); + assertType("false", array_search('baz', $arr, true)); + } + } class Generics From 99156bfb3999f6028e6ddbfc7294207e44e05a0e Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 28 Apr 2026 16:28:09 +0200 Subject: [PATCH 20/66] Unsealed types awareness in more methods --- src/Type/Constant/ConstantArrayType.php | 39 +++++++++++++++++++ .../ExistingClassesInTypehintsRuleTest.php | 4 ++ .../Rules/Functions/data/typehints.php | 8 ++++ .../Rules/Generics/ClassAncestorsRuleTest.php | 4 ++ .../MethodSignatureVarianceRuleTest.php | 4 ++ .../Generics/data/cross-check-interfaces.php | 37 ++++++++++++++++++ .../method-signature-variance-covariant.php | 3 ++ 7 files changed, 99 insertions(+) diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index 60f04475dc9..25f0f466cdc 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -249,6 +249,16 @@ public function getReferencedClasses(): array } } + if ($this->unsealed !== null) { + [$unsealedKeyType, $unsealedValueType] = $this->unsealed; + foreach ($unsealedKeyType->getReferencedClasses() as $referencedClass) { + $referencedClasses[] = $referencedClass; + } + foreach ($unsealedValueType->getReferencedClasses() as $referencedClass) { + $referencedClasses[] = $referencedClass; + } + } + return $referencedClasses; } @@ -856,6 +866,25 @@ public function equals(Type $type): bool return false; } + // Both `unsealed === null` and `unsealed === [explicitNever, explicitNever]` + // mean "sealed", just from different code paths (pre-bleeding-edge vs. + // fresh bleeding-edge builder). Treat them as equivalent here, only + // comparing the actual extras when both sides have real ones. + $thisIsSealed = $this->isUnsealed()->no(); + $otherIsSealed = $type->isUnsealed()->no(); + if ($thisIsSealed !== $otherIsSealed) { + return false; + } + + if (!$thisIsSealed && $this->unsealed !== null && $type->unsealed !== null) { + if (!$this->unsealed[0]->equals($type->unsealed[0])) { + return false; + } + if (!$this->unsealed[1]->equals($type->unsealed[1])) { + return false; + } + } + return true; } @@ -2268,6 +2297,16 @@ public function getReferencedTemplateTypes(TemplateTypeVariance $positionVarianc } } + if ($this->unsealed !== null) { + [$unsealedKeyType, $unsealedValueType] = $this->unsealed; + foreach ($unsealedKeyType->getReferencedTemplateTypes($variance) as $reference) { + $references[] = $reference; + } + foreach ($unsealedValueType->getReferencedTemplateTypes($variance) as $reference) { + $references[] = $reference; + } + } + return $references; } diff --git a/tests/PHPStan/Rules/Functions/ExistingClassesInTypehintsRuleTest.php b/tests/PHPStan/Rules/Functions/ExistingClassesInTypehintsRuleTest.php index 87554a9f53a..63e7eed92d3 100644 --- a/tests/PHPStan/Rules/Functions/ExistingClassesInTypehintsRuleTest.php +++ b/tests/PHPStan/Rules/Functions/ExistingClassesInTypehintsRuleTest.php @@ -111,6 +111,10 @@ public function testExistingClassInTypehint(): void 'Template type T of function TestFunctionTypehints\templateTypeMissingInParameter() is not referenced in a parameter.', 96, ], + [ + 'Parameter $a of function TestFunctionTypehints\nonexistentClassesInUnsealedExtras() has invalid type TestFunctionTypehints\NonexistentUnsealedValueClass.', + 104, + ], ]); } diff --git a/tests/PHPStan/Rules/Functions/data/typehints.php b/tests/PHPStan/Rules/Functions/data/typehints.php index 67ca9b736c5..cee3041de26 100644 --- a/tests/PHPStan/Rules/Functions/data/typehints.php +++ b/tests/PHPStan/Rules/Functions/data/typehints.php @@ -97,3 +97,11 @@ function templateTypeMissingInParameter(string $a) { } + +/** + * @param array{a: int, ...} $a + */ +function nonexistentClassesInUnsealedExtras(array $a) +{ + +} diff --git a/tests/PHPStan/Rules/Generics/ClassAncestorsRuleTest.php b/tests/PHPStan/Rules/Generics/ClassAncestorsRuleTest.php index 85120fe0d2f..10614f59930 100644 --- a/tests/PHPStan/Rules/Generics/ClassAncestorsRuleTest.php +++ b/tests/PHPStan/Rules/Generics/ClassAncestorsRuleTest.php @@ -262,6 +262,10 @@ public function testCrossCheckInterfaces(): void 'Interface IteratorAggregate specifies template type TValue of interface Traversable as string but it\'s already specified as CrossCheckInterfaces\Item.', 19, ], + [ + 'Interface IteratorAggregate specifies template type TValue of interface Traversable as array{a: int, ...} but it\'s already specified as array{a: int, ...}.', + 67, + ], ]); } diff --git a/tests/PHPStan/Rules/Generics/MethodSignatureVarianceRuleTest.php b/tests/PHPStan/Rules/Generics/MethodSignatureVarianceRuleTest.php index 56a7beab2b4..7f19aa0edeb 100644 --- a/tests/PHPStan/Rules/Generics/MethodSignatureVarianceRuleTest.php +++ b/tests/PHPStan/Rules/Generics/MethodSignatureVarianceRuleTest.php @@ -103,6 +103,10 @@ public function testRule(): void 'Template type X is declared as covariant, but occurs in invariant position in return type of method MethodSignatureVariance\Covariant\C::m().', 71, ], + [ + 'Template type X is declared as covariant, but occurs in contravariant position in parameter o of method MethodSignatureVariance\Covariant\C::o().', + 77, + ], ]); $this->analyse([__DIR__ . '/data/method-signature-variance-contravariant.php'], [ diff --git a/tests/PHPStan/Rules/Generics/data/cross-check-interfaces.php b/tests/PHPStan/Rules/Generics/data/cross-check-interfaces.php index 76cbd59de86..a9d4b444d74 100644 --- a/tests/PHPStan/Rules/Generics/data/cross-check-interfaces.php +++ b/tests/PHPStan/Rules/Generics/data/cross-check-interfaces.php @@ -34,3 +34,40 @@ public function getIterator(): \Traversable return new \ArrayIterator([]); } } + +/** + * @extends \Traversable}> + */ +interface ShapedItemListInterface extends \Traversable +{ +} + +/** + * `IteratorAggregate}>` and the inherited + * `Traversable}>` resolve to the same + * unsealed array shape — `equals()` deduplicates them and no + * `interfaceConflict` is reported. + * + * @implements \IteratorAggregate}> + */ +final class ShapedItemList implements \IteratorAggregate, ShapedItemListInterface +{ + public function getIterator(): \Traversable + { + return new \ArrayIterator([]); + } +} + +/** + * Different unsealed value type on the two sides — `equals()` returns + * false on the unsealed extras, so the conflict surfaces. + * + * @implements \IteratorAggregate}> + */ +final class ShapedItemListMismatch implements \IteratorAggregate, ShapedItemListInterface +{ + public function getIterator(): \Traversable + { + return new \ArrayIterator([]); + } +} diff --git a/tests/PHPStan/Rules/Generics/data/method-signature-variance-covariant.php b/tests/PHPStan/Rules/Generics/data/method-signature-variance-covariant.php index 4837dbba5d8..77ed2062ce8 100644 --- a/tests/PHPStan/Rules/Generics/data/method-signature-variance-covariant.php +++ b/tests/PHPStan/Rules/Generics/data/method-signature-variance-covariant.php @@ -72,4 +72,7 @@ function m() {} /** @param X $n */ private function n($n) {} + + /** @param array{a: int, ...} $o */ + function o($o) {} } From 8b2a6c6b7572b6af7352aa9310b345e274bcb8a0 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sat, 2 May 2026 18:06:23 +0200 Subject: [PATCH 21/66] Fix byref --- src/Analyser/MutatingScope.php | 41 ++++++++++++++++++++--- src/Type/Constant/ConstantArrayType.php | 12 ++++++- tests/PHPStan/Analyser/nsrt/bug-14333.php | 11 +++--- 3 files changed, 55 insertions(+), 9 deletions(-) diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 16e2112b1f5..15b15e09249 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -2654,6 +2654,26 @@ public function assignVariable(string $variableName, Type $type, Type $nativeTyp continue; } + // When the byref's dim is non-constant AND not enumerable as a + // finite set of scalars (e.g. general `int` or `mixed`), the just- + // performed write to $array might or might not have hit the byref's + // slot. Union the new $array[dim] read with the byref's previous + // type and the pre-write $array[dim] so values that could still be + // at the slot (unmodified or shadowed by an explicit-key overwrite) + // survive. For finitely-enumerable dims (e.g. `bool`, `int<0, 5>`) + // the array literal builder enumerates all possibilities, so the + // new $array[dim] read already covers every reachable slot. + $unionWithOld = false; + if ($assignedExpr instanceof Expr\ArrayDimFetch && $assignedExpr->dim !== null) { + $dimType = $scope->getType($assignedExpr->dim); + if (count($dimType->getConstantScalarValues()) !== 1 && count($dimType->getFiniteTypes()) === 0) { + $unionWithOld = true; + } + } + + $assignedType = $scope->getType($assignedExpr); + $assignedNativeType = $scope->getNativeType($assignedExpr); + $has = $scope->hasExpressionType($expressionType->getExpr()->getExpr()); if ( $expressionType->getExpr()->getExpr() instanceof Variable @@ -2664,10 +2684,23 @@ public function assignVariable(string $variableName, Type $type, Type $nativeTyp if (in_array($targetVarName, $intertwinedPropagatedFrom, true)) { continue; } + if ($unionWithOld) { + $targetVarNode = new Variable($targetVarName); + $assignedType = TypeCombinator::union( + $assignedType, + $this->getType($assignedExpr), + $scope->getType($targetVarNode), + ); + $assignedNativeType = TypeCombinator::union( + $assignedNativeType, + $this->getNativeType($assignedExpr), + $scope->getNativeType($targetVarNode), + ); + } $scope = $scope->assignVariable( $targetVarName, - $scope->getType($expressionType->getExpr()->getAssignedExpr()), - $scope->getNativeType($expressionType->getExpr()->getAssignedExpr()), + $assignedType, + $assignedNativeType, $has, array_merge($intertwinedPropagatedFrom, [$variableName]), ); @@ -2678,8 +2711,8 @@ public function assignVariable(string $variableName, Type $type, Type $nativeTyp } $scope = $scope->assignExpression( $expressionType->getExpr()->getExpr(), - $scope->getType($expressionType->getExpr()->getAssignedExpr()), - $scope->getNativeType($expressionType->getExpr()->getAssignedExpr()), + $assignedType, + $assignedNativeType, ); } } diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index 25f0f466cdc..e50ed5fd5c8 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -1090,7 +1090,7 @@ private function recursiveHasOffsetValueType(Type $offsetType): TrinaryLogic public function getOffsetValueType(Type $offsetType): Type { - if (count($this->keyTypes) === 0) { + if (count($this->keyTypes) === 0 && $this->unsealed === null) { return new ErrorType(); } @@ -1116,6 +1116,16 @@ public function getOffsetValueType(Type $offsetType): Type $matchingValueTypes[] = $this->valueTypes[$i]; } + // Unsealed extras may also satisfy the offset — when their key type + // overlaps with the requested offset, their value is a possible result. + if ($this->unsealed !== null) { + [$unsealedKeyType, $unsealedValueType] = $this->unsealed; + $isExplicitNever = $unsealedKeyType instanceof NeverType && $unsealedKeyType->isExplicit(); + if (!$isExplicitNever && !$unsealedKeyType->isSuperTypeOf($offsetType)->no()) { + $matchingValueTypes[] = $unsealedValueType; + } + } + if ($all && !$this->isUnsealed()->yes()) { return $this->getIterableValueType(); } diff --git a/tests/PHPStan/Analyser/nsrt/bug-14333.php b/tests/PHPStan/Analyser/nsrt/bug-14333.php index 5251039ac1d..a01178586b6 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-14333.php +++ b/tests/PHPStan/Analyser/nsrt/bug-14333.php @@ -45,8 +45,8 @@ function testNonConstantKeyBreaksImplicitIndex(int $key): void // Since $key is non-constant, we don't know the implicit indices of &$a and &$c // so we can't correctly track the reference propagation $b[2] = 2; - assertType("1|2|'test'", $a); // Could be 1|2 - assertType("1|2|'test'", $c); // Could be 'test'|2 + assertType("1|2|'test'|'x'", $a); // Could be 1|2 + assertType("1|2|'test'|'x'", $c); // Could be 'test'|2 } function testNested(): void @@ -188,7 +188,10 @@ function moreTest(bool $bool, int $int) { assertType("'a0'", $a); assertType("'a2'", $b); assertType("'a3'", $c); - assertType("1|2|3|4|5|6|'a0'|'a1'|'a2'|'a3'|'a4'|'a5'|'aKey'", $d); - assertType("1|2|3|4|5|6|'a0'|'a1'|'a2'|'a3'|'a4'|'a5'|'aKey'", $e); + // $d's slot is at $int (general int), so it accumulates every int-keyed + // value that has ever been at $array[$int] across the lifetime of the + // byref, but never the string-keyed `'key'` slot ($int can't equal 'key'). + assertType("1|2|3|4|5|'a0'|'a1'|'a2'|'a3'|'a4'|'a5'", $d); + assertType("1|2|3|4|5|'a0'|'a1'|'a2'|'a3'|'a4'|'a5'", $e); assertType("'aKey'", $f); } From 612ae507d8d57f57901ecda65a701180aae09cae Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sat, 2 May 2026 17:57:10 +0200 Subject: [PATCH 22/66] Awareness of unsealed array shape when unrolling foreach with constant array --- src/Analyser/NodeScopeResolver.php | 38 +++++++++++++++++++ src/Type/Constant/ConstantArrayType.php | 2 +- .../Analyser/nsrt/unsealed-array-shapes.php | 16 ++++++++ 3 files changed, 55 insertions(+), 1 deletion(-) diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index d013b03359a..e4f59991783 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -4027,8 +4027,12 @@ private function tryProcessUnrolledConstantArrayForeach( } $totalKeys = 0; + $hasUnsealed = false; foreach ($constantArrays as $constantArray) { $totalKeys += count($constantArray->getKeyTypes()); + if ($constantArray->isUnsealed()->yes()) { + $hasUnsealed = true; + } } if ($totalKeys === 0 || $totalKeys > self::FOREACH_UNROLL_LIMIT) { return null; @@ -4161,6 +4165,40 @@ private function tryProcessUnrolledConstantArrayForeach( $endScope = $endScope->mergeWith($breakScope); } + // Unsealed shapes describe zero-or-more additional entries beyond the + // explicit keys. Run the scope-generalizing loop on top of the + // unrolled explicit iterations so body-scope variables (e.g. counters) + // account for the extra iterations while keeping the lower bound + // established by the non-optional explicit keys. + if ($hasUnsealed) { + $loopScope = $endScope; + $count = 0; + do { + $prevLoopScope = $loopScope; + $iterStorage = $originalStorage->duplicate(); + $iterBodyScope = $loopScope->mergeWith($endScope); + $iterBodyScope = $this->enterForeach($iterBodyScope, $iterStorage, $originalScope, $stmt, new NoopNodeCallback()); + $iterBodyScopeResult = $this->processStmtNodesInternal($stmt, $stmt->stmts, $iterBodyScope, $iterStorage, new NoopNodeCallback(), $context->enterDeep())->filterOutLoopExitPoints(); + $loopScope = $iterBodyScopeResult->getScope(); + foreach ($iterBodyScopeResult->getExitPointsByType(Continue_::class) as $continueExitPoint) { + $loopScope = $loopScope->mergeWith($continueExitPoint->getScope()); + } + foreach ($iterBodyScopeResult->getExitPointsByType(Break_::class) as $breakExitPoint) { + $endScope = $endScope->mergeWith($breakExitPoint->getScope()); + } + $bodyScope = $bodyScope->mergeWith($loopScope); + if ($loopScope->equals($prevLoopScope)) { + break; + } + if ($count >= self::GENERALIZE_AFTER_ITERATION) { + $loopScope = $prevLoopScope->generalizeWith($loopScope); + } + $count++; + } while ($count < self::LOOP_SCOPE_ITERATIONS); + + $endScope = $endScope->mergeWith($loopScope); + } + return ['bodyScope' => $bodyScope, 'endScope' => $endScope]; } diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index e50ed5fd5c8..49fe5cbd3ea 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -1090,7 +1090,7 @@ private function recursiveHasOffsetValueType(Type $offsetType): TrinaryLogic public function getOffsetValueType(Type $offsetType): Type { - if (count($this->keyTypes) === 0 && $this->unsealed === null) { + if (count($this->keyTypes) === 0 && !$this->isUnsealed()->yes()) { return new ErrorType(); } diff --git a/tests/PHPStan/Analyser/nsrt/unsealed-array-shapes.php b/tests/PHPStan/Analyser/nsrt/unsealed-array-shapes.php index 457c9fd3180..95af9f7b6ba 100644 --- a/tests/PHPStan/Analyser/nsrt/unsealed-array-shapes.php +++ b/tests/PHPStan/Analyser/nsrt/unsealed-array-shapes.php @@ -343,3 +343,19 @@ function doFoo(Generics $g, array $a, array $b, array $c, array $d): void { assertType('stdClass', $g->infer($c)); assertType('stdClass', $g->infer($d)); }; + +/** + * @param array{a: int, b: string, ...} $a + * @return void + */ +function unsealedForeach(array $a): void +{ + $i = 0; + foreach ($a as $k => $v) { + assertType("'a'|'b'|int", $k); + assertType('float|int|string', $v); + $i++; + } + + assertType('int<2, max>', $i); +} From c3e0ba2a3f327340570c990608f2bd426852da31 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sat, 2 May 2026 18:24:51 +0200 Subject: [PATCH 23/66] Less `instanceof NeverType` to detect sealed array --- src/Analyser/NodeScopeResolver.php | 5 +- src/Type/Constant/ConstantArrayType.php | 60 +++++++++++-------- .../Analyser/nsrt/unsealed-array-shapes.php | 34 +++++++++++ 3 files changed, 71 insertions(+), 28 deletions(-) diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index e4f59991783..ac1db665256 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -4030,9 +4030,10 @@ private function tryProcessUnrolledConstantArrayForeach( $hasUnsealed = false; foreach ($constantArrays as $constantArray) { $totalKeys += count($constantArray->getKeyTypes()); - if ($constantArray->isUnsealed()->yes()) { - $hasUnsealed = true; + if (!$constantArray->isUnsealed()->yes()) { + continue; } + $hasUnsealed = true; } if ($totalKeys === 0 || $totalKeys > self::FOREACH_UNROLL_LIMIT) { return null; diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index 49fe5cbd3ea..3886bc63ff4 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -112,6 +112,8 @@ class ConstantArrayType implements Type private ?Type $iterableValueType = null; + private ?Type $keyTypesUnion = null; + /** @var array|null */ private ?array $keyIndexMap = null; @@ -304,6 +306,13 @@ public function getIterableValueType(): Type return $this->iterableValueType = $valueType; } + private function getKeyTypesUnion(): Type + { + return $this->keyTypesUnion ??= count($this->keyTypes) > 0 + ? TypeCombinator::union(...$this->keyTypes) + : new NeverType(); + } + public function getKeyType(): Type { return $this->getIterableKeyType(); @@ -1116,12 +1125,14 @@ public function getOffsetValueType(Type $offsetType): Type $matchingValueTypes[] = $this->valueTypes[$i]; } - // Unsealed extras may also satisfy the offset — when their key type - // overlaps with the requested offset, their value is a possible result. - if ($this->unsealed !== null) { + // Unsealed extras describe entries at keys NOT in the explicit set — + // PHP array keys are unique, so an explicit key fully owns its slot. + // Only include the unsealed value when the offset has parts not + // covered by any explicit key AND those parts overlap the unsealed + // key range. + if ($this->isUnsealed()->yes() && $this->unsealed !== null) { [$unsealedKeyType, $unsealedValueType] = $this->unsealed; - $isExplicitNever = $unsealedKeyType instanceof NeverType && $unsealedKeyType->isExplicit(); - if (!$isExplicitNever && !$unsealedKeyType->isSuperTypeOf($offsetType)->no()) { + if (!$this->getKeyTypesUnion()->isSuperTypeOf($offsetType)->yes() && !$unsealedKeyType->isSuperTypeOf($offsetType)->no()) { $matchingValueTypes[] = $unsealedValueType; } } @@ -1464,17 +1475,14 @@ public function searchArray(Type $needleType, ?TrinaryLogic $strict = null): Typ // entries), so they can never make the needle "definitely found" // (`hasIdenticalValue` stays false) — `false` always remains a // possible result. - if ($this->unsealed !== null) { + if ($this->isUnsealed()->yes() && $this->unsealed !== null) { [$unsealedKeyType, $unsealedValueType] = $this->unsealed; - $isExplicitNever = $unsealedKeyType instanceof NeverType && $unsealedKeyType->isExplicit(); - if (!$isExplicitNever) { - $considerUnsealed = true; - if ($strict->yes()) { - $considerUnsealed = !$unsealedValueType->isSuperTypeOf($needleType)->no(); - } - if ($considerUnsealed) { - $matches[] = $unsealedKeyType; - } + $considerUnsealed = true; + if ($strict->yes()) { + $considerUnsealed = !$unsealedValueType->isSuperTypeOf($needleType)->no(); + } + if ($considerUnsealed) { + $matches[] = $unsealedKeyType; } } @@ -1807,11 +1815,7 @@ public function isIterableAtLeastOnce(): TrinaryLogic { $keysCount = count($this->keyTypes); if ($keysCount === 0) { - if ($this->unsealed === null) { - return TrinaryLogic::createNo(); - } - [$unsealedKey] = $this->unsealed; - if ($unsealedKey instanceof NeverType && $unsealedKey->isExplicit()) { + if (!$this->isUnsealed()->yes()) { return TrinaryLogic::createNo(); } return TrinaryLogic::createMaybe(); @@ -2410,8 +2414,8 @@ public function isKeysSupersetOf(self $otherArray): bool [$thisUnsealedKey, $thisUnsealedValue] = $this->unsealed; [$otherUnsealedKey, $otherUnsealedValue] = $otherArray->unsealed; - $thisHasExtras = !($thisUnsealedKey instanceof NeverType && $thisUnsealedKey->isExplicit()); - $otherHasExtras = !($otherUnsealedKey instanceof NeverType && $otherUnsealedKey->isExplicit()); + $thisHasExtras = $this->isUnsealed()->yes(); + $otherHasExtras = $otherArray->isUnsealed()->yes(); $otherHasRequiredKeys = false; foreach ($otherArray->keyTypes as $j => $keyType) { @@ -2556,10 +2560,14 @@ public function mergeWith(self $otherArray): self $mergedUnsealedValue = TypeCombinator::union($mergedUnsealedValue, $valueType); }; - $canAbsorb = static function (Type $sideUnsealedKey, Type $sideUnsealedValue, Type $keyType, Type $valueType): bool { - if ($sideUnsealedKey instanceof NeverType && $sideUnsealedKey->isExplicit()) { + $canAbsorb = static function (self $side, Type $keyType, Type $valueType): bool { + if (!$side->isUnsealed()->yes()) { + return false; + } + if ($side->unsealed === null) { return false; } + [$sideUnsealedKey, $sideUnsealedValue] = $side->unsealed; if ($sideUnsealedKey->isSuperTypeOf($keyType)->no()) { return false; } @@ -2596,7 +2604,7 @@ public function mergeWith(self $otherArray): self continue; } - if ($canAbsorb($otherUnsealedKey, $otherUnsealedValue, $keyType, $valueType)) { + if ($canAbsorb($otherArray, $keyType, $valueType)) { $absorbIntoExtras($keyType, $valueType); continue; } @@ -2613,7 +2621,7 @@ public function mergeWith(self $otherArray): self } $valueType = $otherArray->valueTypes[$j]; - if ($canAbsorb($thisUnsealedKey, $thisUnsealedValue, $keyType, $valueType)) { + if ($canAbsorb($this, $keyType, $valueType)) { $absorbIntoExtras($keyType, $valueType); continue; } diff --git a/tests/PHPStan/Analyser/nsrt/unsealed-array-shapes.php b/tests/PHPStan/Analyser/nsrt/unsealed-array-shapes.php index 95af9f7b6ba..0649d71e8c3 100644 --- a/tests/PHPStan/Analyser/nsrt/unsealed-array-shapes.php +++ b/tests/PHPStan/Analyser/nsrt/unsealed-array-shapes.php @@ -359,3 +359,37 @@ function unsealedForeach(array $a): void assertType('int<2, max>', $i); } + +/** + * Reading an offset from an unsealed array shape: explicit keys fully own + * their slots (PHP keys are unique), so the unsealed extras only contribute + * at offsets that fall outside the explicit set. Without this distinction, + * `$a['a']` would widen to `int|string` instead of the precise `int`. + * + * @param array{a: int, b: int, ...} $a + * @param array{a: int, ...} $b + * @param array{a: int, ...} $c + * @param array{a: int, ...} $d + * @param array{a: int, ...} $e + */ +function unsealedOffsetAccess(array $a, array $b, array $c, array $d, array $e, string $s, int $i): void +{ + // Explicit key fully covers offset → only the explicit value + assertType('int', $a['a']); + assertType('int', $a['b']); + + // Offset is a string constant not in the explicit set → unsealed value only + assertType('string', $b['z']); + + // Offset is a general string: 'a' part hits the explicit slot, every other + // string falls through to the unsealed extras → union of both + assertType('int|string', $c[$s]); + + // Unsealed key is `int`, offset is a non-matching string → only the + // explicit slot can contribute (string offset can't match unsealed `int` key) + assertType('int', $d['a']); + + // Open shape (`...` ≡ `...`): an int offset can never + // hit the explicit string key 'a', so it's purely from the unsealed extras + assertType('mixed', $e[$i]); +} From e7a87918a2a0bdca3adec7bc7f04934ebfaebb38 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 5 May 2026 17:35:36 +0200 Subject: [PATCH 24/66] Consider unsealed types in hasOffsetValueType --- .../NonexistentOffsetInArrayDimFetchCheck.php | 15 +++-- src/Type/Constant/ConstantArrayType.php | 32 +++++++++ .../Analyser/nsrt/array-keys-branches.php | 4 +- .../Analyser/nsrt/unsealed-array-shapes.php | 2 +- ...nexistentOffsetInArrayDimFetchRuleTest.php | 65 +++++++++++++++++++ .../data/unsealed-array-shapes-has-offset.php | 28 ++++++++ 6 files changed, 136 insertions(+), 10 deletions(-) create mode 100644 tests/PHPStan/Rules/Arrays/data/unsealed-array-shapes-has-offset.php diff --git a/src/Rules/Arrays/NonexistentOffsetInArrayDimFetchCheck.php b/src/Rules/Arrays/NonexistentOffsetInArrayDimFetchCheck.php index 263a62aebf9..15ad08f0e30 100644 --- a/src/Rules/Arrays/NonexistentOffsetInArrayDimFetchCheck.php +++ b/src/Rules/Arrays/NonexistentOffsetInArrayDimFetchCheck.php @@ -109,13 +109,14 @@ public function check( $report = true; break; } - if ( - $this->reportPossiblyNonexistentConstantArrayOffset - && $innerType->isConstantArray()->yes() - && !$innerType->hasOffsetValueType($dimTypeToCheck)->yes() - ) { - $report = true; - break; + if ($innerType->isConstantArray()->yes() && !$innerType->hasOffsetValueType($dimTypeToCheck)->yes()) { + if ($this->reportPossiblyNonexistentConstantArrayOffset) { + $report = true; + break; + } elseif ($dimTypeToCheck->isConstantScalarValue()->yes()) { + $report = true; + break; + } } if ($dimTypeToCheck instanceof BenevolentUnionType) { $flattenedInnerTypes = [$dimTypeToCheck]; diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index 3886bc63ff4..32ff8fcc108 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -381,11 +381,23 @@ public function getAllArrays(): array continue; } + if (count($keys) === 0 && $this->isUnsealed()->yes() && $this->unsealed !== null) { + // Variant with no explicit keys but real unsealed extras: the + // builder's getArray() would degrade this to a general + // ArrayType. Construct the CAT directly so the variant keeps + // its extras for downstream consumers (e.g. flattenTypes). + $arrays[] = new ConstantArrayType([], [], unsealed: $this->unsealed); + continue; + } + $builder = ConstantArrayTypeBuilder::createEmpty(); $builder->disableArrayDegradation(); foreach ($keys as $i) { $builder->setOffsetValueType($this->keyTypes[$i], $this->valueTypes[$i]); } + if ($this->isUnsealed()->yes() && $this->unsealed !== null) { + $builder->makeUnsealed($this->unsealed[0], $this->unsealed[1]); + } $array = $builder->getArray(); if (!$array instanceof self) { @@ -1072,10 +1084,16 @@ private function recursiveHasOffsetValueType(Type $offsetType): TrinaryLogic $result = TrinaryLogic::createNo(); foreach ($this->keyTypes as $i => $keyType) { + // PHP coerces decimal-integer strings to int when used as array + // keys ("123" → 123), so a non-constant string offset *could* hit + // a constant-integer slot. Skip the upgrade when the offset is + // definitely a non-decimal-integer string — those stay as strings + // and can never collide with an int key. if ( $keyType instanceof ConstantIntegerType && !$offsetType->isString()->no() && $offsetType->isConstantScalarValue()->no() + && !$offsetType->isDecimalIntegerString()->no() ) { return TrinaryLogic::createMaybe(); } @@ -1094,6 +1112,20 @@ private function recursiveHasOffsetValueType(Type $offsetType): TrinaryLogic $result = TrinaryLogic::createMaybe(); } + // Unsealed extras (zero-or-more additional entries) can never make a + // hit definite — they're uncertain by construction. They only matter + // when no explicit key matched ($result is No): if the unsealed key + // range overlaps the offset, upgrade No → Maybe. Explicit keys take + // precedence at any slot they cover (PHP keys are unique), so a + // non-No $result already reflects the strongest answer the unsealed + // extras could contribute. + if ($result->no() && $this->isUnsealed()->yes() && $this->unsealed !== null) { + [$unsealedKeyType] = $this->unsealed; + if (!$unsealedKeyType->isSuperTypeOf($offsetType)->no()) { + $result = TrinaryLogic::createMaybe(); + } + } + return $result; } diff --git a/tests/PHPStan/Analyser/nsrt/array-keys-branches.php b/tests/PHPStan/Analyser/nsrt/array-keys-branches.php index 96d6e4b4a33..f688a124645 100644 --- a/tests/PHPStan/Analyser/nsrt/array-keys-branches.php +++ b/tests/PHPStan/Analyser/nsrt/array-keys-branches.php @@ -58,7 +58,7 @@ function (array $generalArray) { assertType('mixed~null', $generalArray['key']); assertType('array{0: \'foo\', 1: \'bar\', 2?: \'baz\'}', $arrayAppendedInIf); assertType('non-empty-list<\'bar\'|\'baz\'|\'foo\'>', $arrayAppendedInForeach); - assertType("array{literal-string&lowercase-string&non-falsy-string, literal-string&lowercase-string&non-falsy-string, ..., 'baz'>}", $anotherArrayAppendedInForeach); + assertType('non-empty-array, literal-string&lowercase-string&non-falsy-string>', $anotherArrayAppendedInForeach); assertType('\'str\'', $array['n']); assertType('int<0, max>', $incremented); assertType('0|1', $setFromZeroToOne); @@ -124,7 +124,7 @@ function (array $generalArray, array $xs) { assertType('mixed~null', $generalArray['key']); assertType('array{0: \'foo\', 1: \'bar\', 2?: \'baz\'}', $arrayAppendedInIf); assertType('non-empty-list<\'bar\'|\'baz\'|\'foo\'>', $arrayAppendedInForeach); - assertType("array{literal-string&lowercase-string&non-falsy-string, literal-string&lowercase-string&non-falsy-string, ..., 'baz'>}", $anotherArrayAppendedInForeach); + assertType('non-empty-array, literal-string&lowercase-string&non-falsy-string>', $anotherArrayAppendedInForeach); assertType('\'str\'', $array['n']); assertType('int<0, max>', $incremented); assertType('0|1', $setFromZeroToOne); diff --git a/tests/PHPStan/Analyser/nsrt/unsealed-array-shapes.php b/tests/PHPStan/Analyser/nsrt/unsealed-array-shapes.php index 0649d71e8c3..98330beec35 100644 --- a/tests/PHPStan/Analyser/nsrt/unsealed-array-shapes.php +++ b/tests/PHPStan/Analyser/nsrt/unsealed-array-shapes.php @@ -191,7 +191,7 @@ public function loopSealedBecomesUnsealedEachIteration(string $s): void $arr[$i] = 'sealed'; $arr[$s . '_' . $i] = 'unsealed'; } - assertType("non-empty-array|non-falsy-string, literal-string&lowercase-string&non-falsy-string>", $arr); + assertType("non-empty-array|non-falsy-string, 'sealed'|'unsealed'>", $arr); } /** diff --git a/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php b/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php index 4eb98a13a1e..2927248dd88 100644 --- a/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php +++ b/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php @@ -1348,4 +1348,69 @@ public function testBug13688(): void $this->analyse([__DIR__ . '/data/bug-13688.php'], []); } + public static function dataUnsealedArrayShapes(): iterable + { + foreach ([false, true] as $reportPossiblyNonexistentGeneralArrayOffset) { + yield [$reportPossiblyNonexistentGeneralArrayOffset, false, [ + [ + 'Offset 2 might not exist on array{a: int, ...}.', + 16, + ], + [ + 'Offset 1 might not exist on array{int, ...}.', + 22, + ], + [ + 'Offset non-decimal-int-string does not exist on array{int, ...}.', + 25, + ], + ]]; + yield [$reportPossiblyNonexistentGeneralArrayOffset, true, [ + [ + 'Offset 2 might not exist on array{a: int, ...}.', + 16, + ], + [ + 'Offset int might not exist on array{a: int, ...}.', + 17, + ], + [ + 'Offset string might not exist on array{a: int, ...}.', + 18, + ], + [ + 'Offset non-decimal-int-string might not exist on array{a: int, ...}.', + 19, + ], + [ + 'Offset 1 might not exist on array{int, ...}.', + 22, + ], + [ + 'Offset int might not exist on array{int, ...}.', + 23, + ], + [ + 'Offset string might not exist on array{int, ...}.', + 24, + ], + [ + 'Offset non-decimal-int-string does not exist on array{int, ...}.', + 25, + ], + ]]; + } + } + + /** + * @param list $expectedErrors + */ + #[DataProvider('dataUnsealedArrayShapes')] + public function testUnsealedArrayShapes(bool $reportPossiblyNonexistentGeneralArrayOffset, bool $reportPossiblyNonexistentConstantArrayOffset, array $expectedErrors): void + { + $this->reportPossiblyNonexistentGeneralArrayOffset = $reportPossiblyNonexistentGeneralArrayOffset; + $this->reportPossiblyNonexistentConstantArrayOffset = $reportPossiblyNonexistentConstantArrayOffset; + $this->analyse([__DIR__ . '/data/unsealed-array-shapes-has-offset.php'], $expectedErrors); + } + } diff --git a/tests/PHPStan/Rules/Arrays/data/unsealed-array-shapes-has-offset.php b/tests/PHPStan/Rules/Arrays/data/unsealed-array-shapes-has-offset.php new file mode 100644 index 00000000000..1f0bd437607 --- /dev/null +++ b/tests/PHPStan/Rules/Arrays/data/unsealed-array-shapes-has-offset.php @@ -0,0 +1,28 @@ +} $a + * @param array{0: int, ...} $b + * @param non-decimal-int-string $nonDecimalIntString + */ + public function doFoo(array $a, array $b, int $i, string $s, string $nonDecimalIntString): array + { + echo $a['a']; + echo $a[2]; + echo $a[$i]; + echo $a[$s]; + echo $a[$nonDecimalIntString]; + + echo $b[0]; + echo $b[1]; + echo $b[$i]; + echo $b[$s]; + echo $b[$nonDecimalIntString]; + } + +} From d960da36c5b378ac21eeb8102bd7183671e1fae3 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 6 May 2026 12:26:07 +0200 Subject: [PATCH 25/66] makeOffsetRequired --- src/Type/Constant/ConstantArrayType.php | 38 +++++++++++++- .../Analyser/nsrt/unsealed-array-shapes.php | 52 +++++++++++++++++++ 2 files changed, 89 insertions(+), 1 deletion(-) diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index 32ff8fcc108..c7ba038bfe0 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -2764,7 +2764,36 @@ public function makeOffsetRequired(Type $offsetType): self return $this->recreate($this->keyTypes, $this->valueTypes, $this->nextAutoIndexes, array_values($optionalKeys), $this->isList, $this->unsealed); } - break; + return $this; + } + + // Offset isn't in the explicit set. If the unsealed extras' key range + // covers it (e.g. `array{a: int, ...}` narrowing on + // `array_key_exists('b', $arr)`), promote it into the explicit set as + // a required slot with the unsealed value type. The unsealed extras + // stay around — additional entries at other matching keys are still + // possible. + if ( + $this->isUnsealed()->yes() + && $this->unsealed !== null + && ($offsetType instanceof ConstantIntegerType || $offsetType instanceof ConstantStringType) + ) { + [$unsealedKeyType, $unsealedValueType] = $this->unsealed; + if (!$unsealedKeyType->isSuperTypeOf($offsetType)->no()) { + $keyTypes = $this->keyTypes; + $valueTypes = $this->valueTypes; + $keyTypes[] = $offsetType; + $valueTypes[] = $unsealedValueType; + + return $this->recreate( + $keyTypes, + $valueTypes, + $this->nextAutoIndexes, + $this->optionalKeys, + TrinaryLogic::createNo(), + $this->unsealed, + ); + } } return $this; @@ -2797,6 +2826,7 @@ public function makeListMaybe(): Type $this->nextAutoIndexes, $this->optionalKeys, TrinaryLogic::createMaybe(), + $this->unsealed, ); } @@ -2807,12 +2837,17 @@ public function mapValueType(callable $cb): Type $newValueTypes[] = $cb($valueType); } + $newUnsealed = $this->unsealed === null + ? null + : [$this->unsealed[0], $cb($this->unsealed[1])]; + return $this->recreate( $this->keyTypes, $newValueTypes, $this->nextAutoIndexes, $this->optionalKeys, $this->isList, + $newUnsealed, ); } @@ -2838,6 +2873,7 @@ public function makeAllArrayKeysOptional(): Type $this->nextAutoIndexes, range(0, $keyCount - 1), $this->isList, + $this->unsealed, ); } diff --git a/tests/PHPStan/Analyser/nsrt/unsealed-array-shapes.php b/tests/PHPStan/Analyser/nsrt/unsealed-array-shapes.php index 98330beec35..a5bc969eaac 100644 --- a/tests/PHPStan/Analyser/nsrt/unsealed-array-shapes.php +++ b/tests/PHPStan/Analyser/nsrt/unsealed-array-shapes.php @@ -393,3 +393,55 @@ function unsealedOffsetAccess(array $a, array $b, array $c, array $d, array $e, // hit the explicit string key 'a', so it's purely from the unsealed extras assertType('mixed', $e[$i]); } + +/** + * `array_key_exists`/`isset` over an unsealed shape should promote the + * matching key out of the unsealed extras into a definite explicit slot, + * carrying the unsealed value type. The remaining unsealed extras stay + * around — there can still be additional entries at other keys. + * + * @param array{a: int, ...} $stringExtras + * @param array{a: int, ...} $intExtras + * @param array{a: int, ...} $open + * @param array{a?: int, ...} $optionalExplicit + */ +function unsealedNarrowing(array $stringExtras, array $intExtras, array $open, array $optionalExplicit, int $i): void +{ + // Promote 'b' (matches the unsealed string key) into the explicit set + // with the unsealed value type `float`. The unsealed extras remain. + if (array_key_exists('b', $stringExtras)) { + assertType('array{a: int, b: float, ...}', $stringExtras); + } + + if (isset($stringExtras['b'])) { + // `isset` additionally rules out null at the offset — but `float` + // already excludes null, so the shape is the same as above. + assertType('array{a: int, b: float, ...}', $stringExtras); + } + + // Same idea with an integer-keyed unsealed range: 5 gets pulled out. + if (array_key_exists(5, $intExtras)) { + assertType('array{a: int, 5: float, ...}', $intExtras); + } + + // Open shape `...` is `...`: any constant key matches + // the unsealed range, so we promote with `mixed`. + if (array_key_exists('foo', $open)) { + assertType('array{a: int, foo: mixed, ...}', $open); + } + + // Existing optional explicit key — promotion is the existing + // `optional → required` flip; no new key is added. + if (array_key_exists('a', $optionalExplicit)) { + assertType('array{a: int, ...}', $optionalExplicit); + } + + // `isset` produces a `HasOffsetValueType` whose offset doesn't match the + // only explicit key (`'a'`) and lies outside the unsealed key range + // (`int 5` vs. `string` extras). The array can't hold this offset under + // any concrete instance, so the truthy branch's intersection collapses + // to `*NEVER*`. + if (isset($stringExtras[5])) { + assertType('*NEVER*', $stringExtras); + } +} From 94d65a4a11128ca9f56f47f6f604bf621b032c4f Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Thu, 7 May 2026 21:38:20 +0200 Subject: [PATCH 26/66] Make `ConstantArrayType::filterArrayRemovingFalsey()` unsealed-aware MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Group 3 unsealed semantics for `array_filter()` (no callback). The explicit-key path already drops definitely-falsey entries and marks maybe-falsey ones optional with the falsey types subtracted. The unsealed slot was silently dropped — for an `array{a: int, ...}`, the result was `array{a?: int|int<1, max>}`, losing the unsealed extras. Carry through the unsealed slot with the falsey union subtracted from the unsealed value type. If the residue is `NeverType` the unsealed extras can no longer hold any value and the slot is dropped. The new NSRT case in `unsealed-derivations.php` fails before the fix (`Actual: array{a?: int|int<1, max>}`) and passes after. --- src/Type/Constant/ConstantArrayType.php | 7 ++++++ .../Analyser/nsrt/unsealed-derivations.php | 25 +++++++++++++++++++ 2 files changed, 32 insertions(+) create mode 100644 tests/PHPStan/Analyser/nsrt/unsealed-derivations.php diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index c7ba038bfe0..ee855aa8ff3 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -2912,6 +2912,13 @@ public function filterArrayRemovingFalsey(): Type $builder->setOffsetValueType($keyType, $value, $this->isOptionalKey($i)); } + if ($this->unsealed !== null) { + $unsealedValue = TypeCombinator::remove($this->unsealed[1], $falseyTypes); + if (!$unsealedValue instanceof NeverType) { + $builder->makeUnsealed($this->unsealed[0], $unsealedValue); + } + } + return $builder->getArray(); } diff --git a/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php b/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php new file mode 100644 index 00000000000..6f93c1223a7 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php @@ -0,0 +1,25 @@ +} $arr + */ + public function filterUnsealed(array $arr): void + { + // `array_filter` drops falsey entries from both the explicit slot + // and the unsealed extras. The unsealed value type must have the + // falsey union (`null|false|0|0.0|''|'0'|[]`) subtracted too — + // here `int|null` collapses to non-zero `int`. + assertType( + 'array{a?: int|int<1, max>, ...|int<1, max>>}', + array_filter($arr), + ); + } + +} From ace369da2905127fb57fe9c61b668bdb9189730f Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Thu, 7 May 2026 22:07:33 +0200 Subject: [PATCH 27/66] Make `ConstantArrayType::changeKeyCaseArray()` unsealed-aware MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Group 3 unsealed semantics for `array_change_key_case()`. The explicit-key path folds case on `ConstantStringType` keys and passes non-constant keys through. The unsealed slot was silently dropped — for `array{Foo: int, ...}`, the result was `array{foo: int}`, losing the unsealed extras entirely. Carry the unsealed slot through with the same per-leaf rule: - `ConstantStringType` unsealed keys: case-fold the literal value (delegates to the existing `foldConstantStringKeyCase` helper). - Other definite-string unsealed keys: intersect with `AccessoryLowercaseStringType` (CASE_LOWER) / `AccessoryUppercaseStringType` (CASE_UPPER), or a union of both for `null`. After CASE_LOWER, every key in the unsealed range is guaranteed lowercase, so the accessory tracks that precisely. - `UnionType` unsealed keys (e.g. `...`): distribute recursively so the int portion stays intact while only the string portion picks up the accessory. - Non-string unsealed keys (e.g. `...`): pass through — `array_change_key_case` only folds string keys. The new NSRT cases (`lowerCaseUnsealed`, `upperCaseUnsealed`, `mixedKeyUnsealed`) all fail before the fix and pass after. --- src/Type/Constant/ConstantArrayType.php | 61 ++++++++++ .../Analyser/nsrt/unsealed-derivations.php | 112 ++++++++++++++++++ 2 files changed, 173 insertions(+) diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index ee855aa8ff3..2877743fa51 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -25,6 +25,11 @@ use PHPStan\TrinaryLogic; use PHPStan\Type\AcceptsResult; use PHPStan\Type\Accessory\AccessoryArrayListType; +use PHPStan\Type\Accessory\AccessoryLowercaseStringType; +use PHPStan\Type\Accessory\AccessoryNonEmptyStringType; +use PHPStan\Type\Accessory\AccessoryNonFalsyStringType; +use PHPStan\Type\Accessory\AccessoryNumericStringType; +use PHPStan\Type\Accessory\AccessoryUppercaseStringType; use PHPStan\Type\Accessory\HasOffsetType; use PHPStan\Type\Accessory\HasOffsetValueType; use PHPStan\Type\Accessory\NonEmptyArrayType; @@ -2888,6 +2893,11 @@ public function changeKeyCaseArray(?int $case): Type } $builder->setOffsetValueType($newKeyType, $this->valueTypes[$i], $this->isOptionalKey($i)); } + + if ($this->unsealed !== null) { + $builder->makeUnsealed(self::foldUnsealedKeyCase($this->unsealed[0], $case), $this->unsealed[1]); + } + $result = $builder->getArray(); if ($this->isList()->yes()) { $result = TypeCombinator::intersect($result, new AccessoryArrayListType()); @@ -2937,6 +2947,57 @@ private static function foldConstantStringKeyCase(ConstantStringType $type, ?int ); } + private static function foldUnsealedKeyCase(Type $key, ?int $case): Type + { + if ($key instanceof ConstantStringType) { + return self::foldConstantStringKeyCase($key, $case); + } + + if ($key instanceof UnionType) { + $folded = []; + foreach ($key->getTypes() as $innerKey) { + $folded[] = self::foldUnsealedKeyCase($innerKey, $case); + } + + return TypeCombinator::union(...$folded); + } + + // `array_change_key_case` only folds string keys — int keys + // (e.g. `...`) pass through unchanged. + if (!$key->isString()->yes()) { + return $key; + } + + // Rebuild from a clean `string` plus the non-case accessories that + // case-folding preserves (length is unchanged, so numeric / non- + // falsy / non-empty all survive). Any prior lowercase/uppercase + // accessory is dropped — matches the `ArrayType::changeKeyCaseArray` + // behavior where `strtoupper(lowercase-string)` reads as + // `uppercase-string`, not the contradictory intersection. + $preserved = [new StringType()]; + if ($key->isNumericString()->yes()) { + $preserved[] = new AccessoryNumericStringType(); + } elseif ($key->isNonFalsyString()->yes()) { + $preserved[] = new AccessoryNonFalsyStringType(); + } elseif ($key->isNonEmptyString()->yes()) { + $preserved[] = new AccessoryNonEmptyStringType(); + } + + if ($case === CASE_LOWER) { + return new IntersectionType([...$preserved, new AccessoryLowercaseStringType()]); + } + if ($case === CASE_UPPER) { + return new IntersectionType([...$preserved, new AccessoryUppercaseStringType()]); + } + + // `null` (PHP <8.4 / unspecified) yields lower- or upper-case + // keys; record both as a union. + return TypeCombinator::union( + new IntersectionType([...$preserved, new AccessoryLowercaseStringType()]), + new IntersectionType([...$preserved, new AccessoryUppercaseStringType()]), + ); + } + public function toPhpDocNode(): TypeNode { $items = []; diff --git a/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php b/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php index 6f93c1223a7..4fc46a4b180 100644 --- a/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php +++ b/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php @@ -23,3 +23,115 @@ public function filterUnsealed(array $arr): void } } + +class ChangeKeyCase +{ + + /** + * @param array{Foo: int, ...} $arr + */ + public function lowerCaseUnsealed(array $arr): void + { + // `array_change_key_case` folds explicit constant-string keys. + // The unsealed slot must be carried through — and the unsealed + // key picks up the matching `lowercase-string` accessory (every + // key after CASE_LOWER is lowercase). + assertType( + 'array{foo: int, ...}', + array_change_key_case($arr, CASE_LOWER), + ); + } + + /** + * @param array{Foo: int, ...} $arr + */ + public function upperCaseUnsealed(array $arr): void + { + assertType( + 'array{FOO: int, ...}', + array_change_key_case($arr, CASE_UPPER), + ); + } + + /** + * @param array{Foo: int, ...} $arr + */ + public function mixedKeyUnsealed(array $arr): void + { + // Int keys aren't affected by `array_change_key_case`; only the + // string portion of the unsealed key picks up the accessory. + assertType( + 'array{foo: int, ...}', + array_change_key_case($arr, CASE_LOWER), + ); + } + + /** + * @param array{a: int, ...} $arr + */ + public function lowercaseToUpper(array $arr): void + { + // CASE_UPPER on a `lowercase-string` unsealed key drops the + // lowercase property and replaces it with uppercase — + // `array_change_key_case` rewrites every key, so the prior case + // constraint no longer holds. + assertType( + 'array{A: int, ...}', + array_change_key_case($arr, CASE_UPPER), + ); + } + + /** + * @param array{a: int, ...} $arr + */ + public function preserveNonEmpty(array $arr): void + { + // Case-folding keeps the string length unchanged, so non-empty + // is preserved alongside the new case accessory on the unsealed + // key. + assertType( + 'array{a: int, ...}', + array_change_key_case($arr, CASE_LOWER), + ); + } + + /** + * @param array{Foo: int, BAR: string, ...} $arr + */ + public function multipleConstantKeys(array $arr): void + { + // Each `ConstantStringType` explicit key is independently folded. + assertType( + 'array{foo: int, bar: string, ...}', + array_change_key_case($arr, CASE_LOWER), + ); + } + + /** + * @param array{Foo: int, foo: string} $arr + */ + public function collidingConstantKeys(array $arr): void + { + // `Foo` and `foo` both fold to `foo`. PHP semantics: the later + // pair overwrites the earlier (the `foo: string` entry wins). + assertType( + 'array{foo: string}', + array_change_key_case($arr, CASE_LOWER), + ); + } + + /** + * @param array{Foo: int} $arr + */ + public function unknownCase(array $arr, int $case): void + { + // Non-constant `$case` — could be either CASE_LOWER or CASE_UPPER. + // `Foo` folds to `'foo'|'FOO'` and the builder splits the union + // into two optional keys, with at least one guaranteed present. + assertType( + 'non-empty-array{foo?: int, FOO?: int}', + array_change_key_case($arr, $case), + ); + } + +} From 7baa327c837e362759fce3c7de3d7612c8faec54 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Fri, 8 May 2026 09:04:28 +0200 Subject: [PATCH 28/66] Carry unsealed slot through `array_unshift` prepend branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Group 3 unsealed semantics for `array_unshift()`. The prepend branch in `FuncCallHandler` starts with a fresh `ConstantArrayTypeBuilder` and re-attaches the original constant array's keys/values, but silently dropped the unsealed slot — for `list{int, string, ...}` the result was `array{true, null, int, string}`, losing the unsealed tail. After re-attaching the explicit tail, copy the original CAT's unsealed types onto the builder via `makeUnsealed()`. The non-prepend branch (`array_push`) already preserves it through `createFromConstantArray()`. The new NSRT case (`prependPreservesUnsealed`) fails before the fix and passes after. --- src/Analyser/ExprHandler/FuncCallHandler.php | 5 +++++ .../Analyser/nsrt/unsealed-derivations.php | 18 ++++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/src/Analyser/ExprHandler/FuncCallHandler.php b/src/Analyser/ExprHandler/FuncCallHandler.php index 5df15e3fd91..102dc00a43a 100644 --- a/src/Analyser/ExprHandler/FuncCallHandler.php +++ b/src/Analyser/ExprHandler/FuncCallHandler.php @@ -699,6 +699,11 @@ static function (?Type $offsetType, Type $valueType, bool $optional) use (&$arra $constantArray->isOptionalKey($k), ); } + + $unsealedTypes = $constantArray->getUnsealedTypes(); + if ($unsealedTypes !== null) { + $arrayTypeBuilder->makeUnsealed($unsealedTypes[0], $unsealedTypes[1]); + } } $constantArray = $arrayTypeBuilder->getArray(); diff --git a/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php b/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php index 4fc46a4b180..3245be5155b 100644 --- a/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php +++ b/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php @@ -135,3 +135,21 @@ public function unknownCase(array $arr, int $case): void } } + +class ArrayUnshift +{ + + /** + * @param list{int, string, ...} $arr + */ + public function prependPreservesUnsealed(array $arr): void + { + array_unshift($arr, true, null); + // `array_unshift` prepends the new values and re-indexes; the + // original list's unsealed tail (`...`) must be carried + // through so the result still tracks "extra entries are + // `float`". + assertType('array{true, null, int, string, ...}', $arr); + } + +} From 52021ac5807f1b2fe11461d41e59e169884b6dbe Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Fri, 8 May 2026 09:24:26 +0200 Subject: [PATCH 29/66] Carry unsealed slot through `count()` narrowing (unbounded max) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Group 3 unsealed semantics for `TypeSpecifier::specifyTypesForCount`. The IntegerRange-with-unbounded-max branch (e.g. `count(\$a) >= 5`) needs three changes for unsealed inputs: 1. The `elseif (\$arrayType->isConstantArray()->yes())` probe runs `for (\$i = min;; \$i++)` and breaks on `hasOffset->no()`. For an unsealed CAT, `hasOffset` is `Maybe` for every in-range key and the probe runs until `ARRAY_COUNT_LIMIT` bails (slow + lossy; in practice the prior bug-`count >= 5` test exhausted memory). Detect unsealed input via `isUnsealed()->yes()` and stop probing once the explicit keys are exhausted. 2. After the builder is populated, attach the unsealed slot via `\$builder->makeUnsealed()` — only for the unbounded-max branch, since a bounded-max range caps the result size and the unsealed extras can't fit. 3. The truthy-shortcut at line 1541 returns just a `HasOffsetValueType(N-1, mixed)` constraint when the input has no optional keys. Intersecting an unsealed CAT with a single-slot constraint produces `NeverType` (the shape's slot semantics don't compose with a flat hasOffset). Extend the existing `\$hasOptionalKeys` gate to also catch real unsealed inputs so they fall through to the full builder-based narrowing. Use `isUnsealed()->yes()` everywhere instead of `getUnsealedTypes() !== null` — the latter would also match `[NeverType, NeverType]` (the explicit "sealed" sentinel under bleeding edge) and break sealed-CAT narrowing in `bug-4700`, `bug11480`, `list-count`. The new NSRT case (`geMinPreservesUnsealed`) hangs / OOMs before the fix and produces `array{int, string, float, float, float, ...}` after. --- src/Analyser/TypeSpecifier.php | 20 +++++++++---------- src/Type/Constant/ConstantArrayType.php | 16 +++++++++++++++ .../Analyser/nsrt/unsealed-derivations.php | 19 ++++++++++++++++++ 3 files changed, 45 insertions(+), 10 deletions(-) diff --git a/src/Analyser/TypeSpecifier.php b/src/Analyser/TypeSpecifier.php index a945627ef64..8127523a504 100644 --- a/src/Analyser/TypeSpecifier.php +++ b/src/Analyser/TypeSpecifier.php @@ -1429,27 +1429,27 @@ private function specifyTypesForCountFuncCall( continue; } - // `truncateListToSize` rebuilds the inner array as a list shape - // — that's only sound when the *outer* type is definitely a - // list. The inner array alone may have `isList()` answer `Maybe` - // (e.g. `ArrayType, T>` inside a - // `non-empty-list` intersection), so the gate has to live - // here, not on the per-array method. $resultTypes[] = $isList->yes() ? $arrayType->truncateListToSize($sizeType) : TypeCombinator::intersect($arrayType, new NonEmptyArrayType()); } if ($context->truthy() && $isConstantArray->yes() && $isList->yes()) { - $hasOptionalKeys = false; + $hasOptionalKeysOrUnsealed = false; foreach ($type->getConstantArrays() as $arrayType) { - if ($arrayType->getOptionalKeys() !== []) { - $hasOptionalKeys = true; + if ($arrayType->getOptionalKeys() !== [] || $arrayType->isUnsealed()->yes()) { + // Unsealed CATs can't be narrowed via the + // `HasOffsetValueType`-only shortcut below — the + // intersection of an unsealed shape with a single-slot + // constraint produces `NeverType`. Fall through to + // the full builder-based narrowing, which carries the + // unsealed slot via the loop above. + $hasOptionalKeysOrUnsealed = true; break; } } - if (!$hasOptionalKeys) { + if (!$hasOptionalKeysOrUnsealed) { $argExpr = $countFuncCall->getArgs()[0]->value; $argExprString = $this->exprPrinter->printExpr($argExpr); diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index 2877743fa51..29542e34914 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -1794,12 +1794,21 @@ public function truncateListToSize(Type $sizeType): Type // Unbounded max: probe explicit keys from `$min` onward until // `hasOffsetValueType` answers `no`. Each probe contributes one // optional (or required, when `hasOffsetValueType` is `yes`) slot. + $isUnsealed = $this->isUnsealed()->yes(); for ($i = $min;; $i++) { $offsetType = new ConstantIntegerType($i); $hasOffset = $this->hasOffsetValueType($offsetType); if ($hasOffset->no()) { break; } + // Real unsealed extras make `hasOffsetValueType` answer + // `Maybe` for *any* in-range key, so the probe would + // otherwise run until `ARRAY_COUNT_LIMIT` bails (slow + + // lossy). Stop once the explicit keys are exhausted; the + // unsealed slot attached below covers further entries. + if ($isUnsealed && !$hasOffset->yes()) { + break; + } $builderData[] = [$offsetType, $this->getOffsetValueType($offsetType), !$hasOffset->yes()]; } } @@ -1813,6 +1822,13 @@ public function truncateListToSize(Type $sizeType): Type $builder->setOffsetValueType($offsetType, $valueType, $optional); } + // Carry the unsealed slot through only for the unbounded-max + // branch — a bounded-max range caps the result size and the + // unsealed extras can't fit. + if ($max === null && $this->isUnsealed()->yes() && $this->unsealed !== null) { + $builder->makeUnsealed($this->unsealed[0], $this->unsealed[1]); + } + $builtArray = $builder->getArray(); // `setOffsetValueType` on a brand-new builder produces a list when // the resulting offsets are sequential ints — but it may not preserve diff --git a/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php b/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php index 3245be5155b..b3094b5fbf1 100644 --- a/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php +++ b/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php @@ -153,3 +153,22 @@ public function prependPreservesUnsealed(array $arr): void } } + +class CountNarrowing +{ + + /** + * @param list{int, string, ...} $arr + */ + public function geMinPreservesUnsealed(array $arr): void + { + if (count($arr) >= 5) { + // `count >= 5` guarantees the first 5 entries exist (the + // explicit prefix `[int, string]` plus three values from the + // unsealed `` range). Beyond five, the unsealed slot + // is preserved so further entries can still appear. + assertType('array{int, string, float, float, float, ...}', $arr); + } + } + +} From 3f419282771183a329d5c42bab10e14bee62d728 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Fri, 8 May 2026 09:26:20 +0200 Subject: [PATCH 30/66] Carry unsealed slots through `array_merge` all-constant fast path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Group 3 unsealed semantics for `ArrayMergeFunctionDynamicReturnTypeExtension`. The all-constant fast path (lines 80-103) walks each argument's explicit keys into a fresh builder but silently drops the unsealed slots — for `array_merge(array{a: int, ...}, ['b' => true])` the result was `array{a: int, b: true}`, losing the `` extras. Collect every input CAT's unsealed `[keyType, valueType]` tuple during the walk; after writing the explicit keys, union all the collected tuples and attach them via `\$builder->makeUnsealed()`. The unsealed slot of the result represents "any other key not in the explicit set could come from any of the merged inputs". Use `isUnsealed()->yes()` to detect real unsealed extras (not the `[NeverType, NeverType]` sealed sentinel used under bleeding edge). The new NSRT case (`mergePreservesUnsealed`) fails before the fix and passes after. The companion `array_replace` case is still failing — to be addressed in the next commit. --- ...ergeFunctionDynamicReturnTypeExtension.php | 18 ++++++++++ .../Analyser/nsrt/unsealed-derivations.php | 36 +++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/src/Type/Php/ArrayMergeFunctionDynamicReturnTypeExtension.php b/src/Type/Php/ArrayMergeFunctionDynamicReturnTypeExtension.php index 5ae8cbc48b9..9148eb6dd28 100644 --- a/src/Type/Php/ArrayMergeFunctionDynamicReturnTypeExtension.php +++ b/src/Type/Php/ArrayMergeFunctionDynamicReturnTypeExtension.php @@ -79,6 +79,8 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection, if ($allConstant->yes()) { $newArrayBuilder = ConstantArrayTypeBuilder::createEmpty(); + $unsealedKeys = []; + $unsealedValues = []; foreach ($argTypes as $argIndex => $argType) { $isOptionalArg = in_array($argIndex, $optionalArgTypes, true); @@ -88,6 +90,13 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection, foreach ($constantArray->getKeyTypes() as $keyType) { $keyTypes[$keyType->getValue()] = $keyType; } + if ($constantArray->isUnsealed()->yes()) { + $unsealedTypes = $constantArray->getUnsealedTypes(); + if ($unsealedTypes !== null) { + $unsealedKeys[] = $unsealedTypes[0]; + $unsealedValues[] = $unsealedTypes[1]; + } + } } foreach ($keyTypes as $keyType) { @@ -99,6 +108,15 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection, } } + if (count($unsealedKeys) > 0) { + // Union all input unsealed slots — extras can come from + // any of the merged arrays at otherwise-unmentioned keys. + $newArrayBuilder->makeUnsealed( + TypeCombinator::union(...$unsealedKeys), + TypeCombinator::union(...$unsealedValues), + ); + } + return $newArrayBuilder->getArray(); } diff --git a/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php b/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php index b3094b5fbf1..233658ae2b9 100644 --- a/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php +++ b/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php @@ -154,6 +154,42 @@ public function prependPreservesUnsealed(array $arr): void } +class ArrayMerge +{ + + /** + * @param array{a: int, ...} $arr + */ + public function mergePreservesUnsealed(array $arr): void + { + // `array_merge` with a sealed second arg appends `b` and keeps + // the unsealed extras from the first array. + assertType( + 'array{a: int, b: true, ...}', + array_merge($arr, ['b' => true]), + ); + } + +} + +class ArrayReplace +{ + + /** + * @param array{a: int, ...} $arr + */ + public function replacePreservesUnsealed(array $arr): void + { + // `array_replace` overwrites by key, but the unsealed extras + // from `$arr` survive at any unmentioned keys. + assertType( + 'array{a: int, b: true, ...}', + array_replace($arr, ['b' => true]), + ); + } + +} + class CountNarrowing { From bf996e0d86def8bcb923a8fd4a8e8cfe0326f15a Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Fri, 8 May 2026 09:27:33 +0200 Subject: [PATCH 31/66] Carry unsealed slots through `array_replace` all-constant fast path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same shape as the previous `array_merge` fix — `array_replace`'s all-constant fast path also walks each argument's explicit keys into a fresh builder and dropped the unsealed slots. Apply the same union-and-attach treatment. The `replacePreservesUnsealed` NSRT case from the previous commit (which was failing on `array_replace` only) now passes. --- ...ArrayReplaceFunctionReturnTypeExtension.php | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/Type/Php/ArrayReplaceFunctionReturnTypeExtension.php b/src/Type/Php/ArrayReplaceFunctionReturnTypeExtension.php index c53f0929d5d..690c1bd2a8c 100644 --- a/src/Type/Php/ArrayReplaceFunctionReturnTypeExtension.php +++ b/src/Type/Php/ArrayReplaceFunctionReturnTypeExtension.php @@ -79,6 +79,8 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection, if ($allConstant->yes()) { $newArrayBuilder = ConstantArrayTypeBuilder::createEmpty(); + $unsealedKeys = []; + $unsealedValues = []; foreach ($argTypes as $argIndex => $argType) { $isOptionalArg = in_array($argIndex, $optionalArgTypes, true); @@ -89,6 +91,13 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection, foreach ($constantArray->getKeyTypes() as $keyType) { $keyTypes[$keyType->getValue()] = $keyType; } + if ($constantArray->isUnsealed()->yes()) { + $unsealedTypes = $constantArray->getUnsealedTypes(); + if ($unsealedTypes !== null) { + $unsealedKeys[] = $unsealedTypes[0]; + $unsealedValues[] = $unsealedTypes[1]; + } + } } foreach ($keyTypes as $keyType) { @@ -100,6 +109,15 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection, } } + if (count($unsealedKeys) > 0) { + // Union all input unsealed slots — extras can come from + // any of the input arrays at otherwise-unmentioned keys. + $newArrayBuilder->makeUnsealed( + TypeCombinator::union(...$unsealedKeys), + TypeCombinator::union(...$unsealedValues), + ); + } + return $newArrayBuilder->getArray(); } From 98e869916a58bf31079191a93074ac2b1b059f52 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Fri, 8 May 2026 09:29:28 +0200 Subject: [PATCH 32/66] Carry unsealed slot through `array_filter` callback path Group 3 unsealed semantics for the callback variant of `array_filter` in `ArrayFilterFunctionReturnTypeHelper::filterByTruthyValue`. The explicit-key loop already runs each pair through `processKeyAndItemType()` to apply the predicate's truthy projection; the unsealed slot was simply skipped, so for `array_filter(array{a: int, ...}, fn (\$v) => \$v !== null)` the result was `array{a: int}`, losing the `` extras. After the explicit loop, run the same projection over the unsealed `[keyType, valueType]` tuple. Drop the unsealed slot if either the key or the value narrows to `NeverType` (the predicate rejects every possible extra). Use `isUnsealed()->yes()` to detect real unsealed extras (not the `[NeverType, NeverType]` sealed sentinel under bleeding edge). The new NSRT case (`preserveUnsealed`) fails before the fix and passes after. --- .../ArrayFilterFunctionReturnTypeHelper.php | 13 +++++++++++++ .../Analyser/nsrt/unsealed-derivations.php | 19 +++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/src/Type/Php/ArrayFilterFunctionReturnTypeHelper.php b/src/Type/Php/ArrayFilterFunctionReturnTypeHelper.php index 16e03fd68aa..83de6b7f622 100644 --- a/src/Type/Php/ArrayFilterFunctionReturnTypeHelper.php +++ b/src/Type/Php/ArrayFilterFunctionReturnTypeHelper.php @@ -181,6 +181,19 @@ private function filterByTruthyValue(Scope $scope, Error|Variable|null $itemVar, $builder->setOffsetValueType($newKeyType, $newItemType, true); } + if ($constantArray->isUnsealed()->yes()) { + $unsealedTypes = $constantArray->getUnsealedTypes(); + if ($unsealedTypes !== null) { + [$newKey, $newValue] = $this->processKeyAndItemType($scope, $unsealedTypes[0], $unsealedTypes[1], $itemVar, $keyVar, $expr); + // Drop the unsealed slot when the predicate + // rejects every possible extra (key or value + // narrows to `Never`). + if (!$newKey instanceof NeverType && !$newValue instanceof NeverType) { + $builder->makeUnsealed($newKey, $newValue); + } + } + } + $results[] = $builder->getArray(); } diff --git a/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php b/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php index 233658ae2b9..b53f3890646 100644 --- a/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php +++ b/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php @@ -154,6 +154,25 @@ public function prependPreservesUnsealed(array $arr): void } +class ArrayFilterCallback +{ + + /** + * @param array{a: int, ...} $arr + */ + public function preserveUnsealed(array $arr): void + { + // `array_filter` with a callback narrows each entry by the + // predicate's truthy projection. The unsealed slot must follow + // the same narrowing — `int|null` minus `null` is `int`. + assertType( + 'array{a: int, ...}', + array_filter($arr, fn ($v) => $v !== null), + ); + } + +} + class ArrayMerge { From 1a8af3ea81cf0a07bc3883d4a95767ef0afffdfa Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Fri, 8 May 2026 09:31:45 +0200 Subject: [PATCH 33/66] Carry unsealed slot through `array_column` constant-array path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Group 3 unsealed semantics for `ArrayColumnHelper::handleConstantArray`. The explicit-key loop already plucks each row's column via `getOffsetOrProperty()` and rebuilds via the builder; the unsealed slot was simply skipped, so for `array_column(list{Row, Row, ...}, 'name')` the result was `array{string, string}`, losing the unsealed tail. After the explicit loop, run the same column lookup on `unsealedTypes[1]` (the unsealed value, i.e. the row type at the unsealed keys). Drop the unsealed slot if the column extraction narrows to `Never` or returns `Maybe` certainty (matches the existing per-row early-`return null`). When `\$indexType` is non-null, additionally look up the index key on the unsealed row type — same logic as the explicit-row path. With a null `\$indexType` the unsealed keys retain their original range (typically `int`). The new NSRT case (`preserveUnsealed`) fails before the fix (`array{string, string}`) and passes after. --- src/Type/Php/ArrayColumnHelper.php | 29 +++++++++++++++++++ .../Analyser/nsrt/unsealed-derivations.php | 20 +++++++++++++ 2 files changed, 49 insertions(+) diff --git a/src/Type/Php/ArrayColumnHelper.php b/src/Type/Php/ArrayColumnHelper.php index 824530751c7..aeb414875f0 100644 --- a/src/Type/Php/ArrayColumnHelper.php +++ b/src/Type/Php/ArrayColumnHelper.php @@ -118,6 +118,35 @@ public function handleConstantArray(ConstantArrayType $arrayType, Type $columnTy $builder->setOffsetValueType($keyType, $valueType, $arrayType->isOptionalKey($i)); } + if ($arrayType->isUnsealed()->yes()) { + $unsealedTypes = $arrayType->getUnsealedTypes(); + if ($unsealedTypes !== null) { + [$unsealedValueType, $unsealedCertainty] = $this->getOffsetOrProperty($unsealedTypes[1], $columnType, $scope); + if (!$unsealedCertainty->yes()) { + return null; + } + if (!$unsealedValueType instanceof NeverType) { + if (!$indexType->isNull()->yes()) { + [$unsealedKeyFromIndex, $unsealedKeyCertainty] = $this->getOffsetOrProperty($unsealedTypes[1], $indexType, $scope); + if ($unsealedKeyFromIndex instanceof NeverType) { + $unsealedKey = $unsealedTypes[0]; + } elseif ($unsealedKeyCertainty->yes()) { + $unsealedKey = $this->castToArrayKeyType($unsealedKeyFromIndex); + } else { + $unsealedKey = $this->castToArrayKeyType(TypeCombinator::union($unsealedKeyFromIndex, new IntegerType())); + } + } else { + // `null` indexType keeps integer-keyed list semantics — + // the unsealed range remains keyed by the source's + // unsealed keys (typically `int`). + $unsealedKey = $unsealedTypes[0]; + } + + $builder->makeUnsealed($unsealedKey, $unsealedValueType); + } + } + } + return $builder->getArray(); } diff --git a/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php b/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php index b53f3890646..8937d60a248 100644 --- a/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php +++ b/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php @@ -173,6 +173,26 @@ public function preserveUnsealed(array $arr): void } +class ArrayColumn +{ + + /** + * @param list{array{name: string, age: int}, array{name: string, age: int}, ...} $rows + */ + public function preserveUnsealed(array $rows): void + { + // `array_column` plucks the named field from every row, + // including rows from the unsealed tail. Each row's `name` + // is `string`, so the unsealed slot of the result is `string` + // at the original integer keys. + assertType( + 'array{string, string, ...}', + array_column($rows, 'name'), + ); + } + +} + class ArrayMerge { From e215c058f7fa35829936b1f62cb99908a8e9f2c5 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Fri, 8 May 2026 09:35:13 +0200 Subject: [PATCH 34/66] Carry unsealed slot through `filter_var_array` constant-input path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Group 3 unsealed semantics for `FilterVarArrayDynamicReturnTypeExtension`. The constant-input path (`\$filterArgType instanceof ConstantIntegerType` or constant-array filter) walks the input's explicit keys through `filterFunctionReturnTypeHelper->getType()` and attaches each filtered value to a fresh builder. The unsealed slot was silently dropped — for `filter_var_array(array{a: int, ...}, FILTER_VALIDATE_INT)` the result was `array{a: int}`, losing the `` extras. After the explicit-key loop, run the same filter resolution over the input's unsealed value type and attach the result via `\$builder->makeUnsealed()`. The unsealed key range comes through unchanged (filtering changes values, not key shape). Apply the `addEmpty` `null`-injection on the unsealed value too, matching the per-key path. For the `ConstantIntegerType` filter case the same scalar filter applies to every value (including unsealed); for the rest, fall back to a `MixedType` filter spec via `fetchFilter()` — the constant-array filter case can't have a per-unsealed-key spec. The new NSRT case (`preserveUnsealed`) fails before the fix and passes after. --- ...lterVarArrayDynamicReturnTypeExtension.php | 21 +++++++++++++++++++ .../Analyser/nsrt/unsealed-derivations.php | 20 ++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/src/Type/Php/FilterVarArrayDynamicReturnTypeExtension.php b/src/Type/Php/FilterVarArrayDynamicReturnTypeExtension.php index a0442605d71..3be499415ca 100644 --- a/src/Type/Php/FilterVarArrayDynamicReturnTypeExtension.php +++ b/src/Type/Php/FilterVarArrayDynamicReturnTypeExtension.php @@ -173,6 +173,27 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection, $valueTypesBuilder->setOffsetValueType($keyType, $valueType, $optional); } + // Carry the unsealed slot through from the input. The filter + // applies to every key, including those covered by the unsealed + // range — run the same filter resolution over the input's + // unsealed value type and attach the result. + if ($inputConstantArrayType !== null && $inputConstantArrayType->isUnsealed()->yes()) { + $unsealedTypes = $inputConstantArrayType->getUnsealedTypes(); + if ($unsealedTypes !== null) { + if ($filterArgType instanceof ConstantIntegerType) { + $unsealedFilter = $filterArgType; + $unsealedFlags = null; + } else { + [$unsealedFilter, $unsealedFlags] = $this->fetchFilter(new MixedType()); + } + $unsealedValueType = $this->filterFunctionReturnTypeHelper->getType($unsealedTypes[1], $unsealedFilter, $unsealedFlags); + if ($addEmpty) { + $unsealedValueType = TypeCombinator::addNull($unsealedValueType); + } + $valueTypesBuilder->makeUnsealed($unsealedTypes[0], $unsealedValueType); + } + } + return $valueTypesBuilder->getArray(); } diff --git a/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php b/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php index 8937d60a248..96eb068f2b1 100644 --- a/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php +++ b/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php @@ -193,6 +193,26 @@ public function preserveUnsealed(array $rows): void } +class FilterVarArray +{ + + /** + * @param array{a: int, ...} $arr + */ + public function preserveUnsealed(array $arr): void + { + // `filter_var_array` applies the filter to every value, + // including the unsealed extras. The unsealed value type + // becomes the filter's projected output (`int|false` for + // `FILTER_VALIDATE_INT` over `mixed`). + assertType( + 'array{a: int, ...}', + filter_var_array($arr, FILTER_VALIDATE_INT), + ); + } + +} + class ArrayMerge { From e7e2c3bcf7613cff063be0b047b24865b545a74c Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Fri, 8 May 2026 11:51:27 +0200 Subject: [PATCH 35/66] Make `array_unshift` produce unsealed CAT for assoc + non-constant unpack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When `array_unshift(\$arr, ...\$items)` is called with `\$items` of an unknown size (e.g. a generic `list`), the prior degradation discarded both the input's explicit shape and the "unknown count of prepended values" — for `array_unshift(array{a: int, b: string}, ...\$listOfFloat)` the result was `non-empty-array`, losing the `a: int, b: string` precision. Split the degradation by `isList()`: - **List input**: still degrade to `non-empty-list` — every original index is shifted by the unknown count, so the precise indices can't be recovered. - **Associative input**: keep the string keys explicit and add an unsealed `int` slot whose value is the union of the int-keyed values that the unpack-arg loop wrote into the builder. The result becomes `array{a: int, b: string, ...}` — the unknown count is faithfully expressed by the unsealed slot. When the input CAT was itself unsealed, union the existing unsealed key/value with the new unsealed-int slot so both sources of "more entries possible" are preserved. Two new NSRT cases (`unshiftListWithUnpack` locking in the unchanged list behavior, and `unshiftAssocWithUnpack` failing before / passing after for the associative case). --- phpstan-baseline.neon | 2 +- src/Analyser/ExprHandler/FuncCallHandler.php | 52 ++++++++++++++++--- ...ergeFunctionDynamicReturnTypeExtension.php | 16 +++--- ...rrayReplaceFunctionReturnTypeExtension.php | 16 +++--- .../Analyser/nsrt/unsealed-derivations.php | 32 ++++++++++++ 5 files changed, 97 insertions(+), 21 deletions(-) diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 2b3748ff2c6..cc53f0f2e31 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -960,7 +960,7 @@ parameters: - rawMessage: 'Doing instanceof PHPStan\Type\Constant\ConstantStringType is error-prone and deprecated. Use Type::getConstantStrings() instead.' identifier: phpstanApi.instanceofType - count: 3 + count: 5 path: src/Type/Constant/ConstantArrayType.php - diff --git a/src/Analyser/ExprHandler/FuncCallHandler.php b/src/Analyser/ExprHandler/FuncCallHandler.php index 102dc00a43a..c784dda48c0 100644 --- a/src/Analyser/ExprHandler/FuncCallHandler.php +++ b/src/Analyser/ExprHandler/FuncCallHandler.php @@ -709,14 +709,50 @@ static function (?Type $offsetType, Type $valueType, bool $optional) use (&$arra $constantArray = $arrayTypeBuilder->getArray(); if ($constantArray->isConstantArray()->yes() && $nonConstantArrayWasUnpacked) { - $array = new ArrayType($constantArray->generalize(GeneralizePrecision::lessSpecific())->getIterableKeyType(), $constantArray->getIterableValueType()); - $isList = $constantArray->isList()->yes(); - $constantArray = $constantArray->isIterableAtLeastOnce()->yes() - ? new IntersectionType([$array, new NonEmptyArrayType()]) - : $array; - $constantArray = $isList - ? TypeCombinator::intersect($constantArray, new AccessoryArrayListType()) - : $constantArray; + $constantArrays = $constantArray->getConstantArrays(); + if ($constantArray->isList()->yes()) { + // A list can't preserve precise indices when an + // unknown number of values is prepended/appended — + // every index would be shifted by an unknown + // amount. Degrade to a `non-empty-list<...>` of + // the value union. + $array = new ArrayType($constantArray->generalize(GeneralizePrecision::lessSpecific())->getIterableKeyType(), $constantArray->getIterableValueType()); + $constantArray = $constantArray->isIterableAtLeastOnce()->yes() + ? new IntersectionType([$array, new NonEmptyArrayType()]) + : $array; + $constantArray = TypeCombinator::intersect($constantArray, new AccessoryArrayListType()); + } elseif (count($constantArrays) === 1) { + // Associative input — string keys keep their + // precise values and the unknown count of + // unpacked items lives in an unsealed `int` slot + // of the result. Drops the auto-indexed + // representatives that the unpacked-arg loop + // inserted (they stand in for "0..N-1 of the + // unpack value type" and are now subsumed by the + // unsealed slot). + $builder = ConstantArrayTypeBuilder::createEmpty(); + $intValues = []; + foreach ($constantArrays[0]->getKeyTypes() as $i => $keyType) { + $valueType = $constantArrays[0]->getValueTypes()[$i]; + if ($keyType->isString()->yes()) { + $builder->setOffsetValueType($keyType, $valueType, $constantArrays[0]->isOptionalKey($i)); + continue; + } + $intValues[] = $valueType; + } + + $unsealedKey = new IntegerType(); + $unsealedValue = count($intValues) > 0 ? TypeCombinator::union(...$intValues) : new MixedType(); + if ($constantArrays[0]->isUnsealed()->yes()) { + $existing = $constantArrays[0]->getUnsealedTypes(); + if ($existing !== null) { + $unsealedKey = TypeCombinator::union($unsealedKey, $existing[0]); + $unsealedValue = TypeCombinator::union($unsealedValue, $existing[1]); + } + } + $builder->makeUnsealed($unsealedKey, $unsealedValue); + $constantArray = $builder->getArray(); + } } $newArrayTypes[] = $constantArray; diff --git a/src/Type/Php/ArrayMergeFunctionDynamicReturnTypeExtension.php b/src/Type/Php/ArrayMergeFunctionDynamicReturnTypeExtension.php index 9148eb6dd28..20c29b40d63 100644 --- a/src/Type/Php/ArrayMergeFunctionDynamicReturnTypeExtension.php +++ b/src/Type/Php/ArrayMergeFunctionDynamicReturnTypeExtension.php @@ -90,13 +90,17 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection, foreach ($constantArray->getKeyTypes() as $keyType) { $keyTypes[$keyType->getValue()] = $keyType; } - if ($constantArray->isUnsealed()->yes()) { - $unsealedTypes = $constantArray->getUnsealedTypes(); - if ($unsealedTypes !== null) { - $unsealedKeys[] = $unsealedTypes[0]; - $unsealedValues[] = $unsealedTypes[1]; - } + if (!$constantArray->isUnsealed()->yes()) { + continue; + } + + $unsealedTypes = $constantArray->getUnsealedTypes(); + if ($unsealedTypes === null) { + continue; } + + $unsealedKeys[] = $unsealedTypes[0]; + $unsealedValues[] = $unsealedTypes[1]; } foreach ($keyTypes as $keyType) { diff --git a/src/Type/Php/ArrayReplaceFunctionReturnTypeExtension.php b/src/Type/Php/ArrayReplaceFunctionReturnTypeExtension.php index 690c1bd2a8c..48c26330ce1 100644 --- a/src/Type/Php/ArrayReplaceFunctionReturnTypeExtension.php +++ b/src/Type/Php/ArrayReplaceFunctionReturnTypeExtension.php @@ -91,13 +91,17 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection, foreach ($constantArray->getKeyTypes() as $keyType) { $keyTypes[$keyType->getValue()] = $keyType; } - if ($constantArray->isUnsealed()->yes()) { - $unsealedTypes = $constantArray->getUnsealedTypes(); - if ($unsealedTypes !== null) { - $unsealedKeys[] = $unsealedTypes[0]; - $unsealedValues[] = $unsealedTypes[1]; - } + if (!$constantArray->isUnsealed()->yes()) { + continue; + } + + $unsealedTypes = $constantArray->getUnsealedTypes(); + if ($unsealedTypes === null) { + continue; } + + $unsealedKeys[] = $unsealedTypes[0]; + $unsealedValues[] = $unsealedTypes[1]; } foreach ($keyTypes as $keyType) { diff --git a/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php b/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php index 96eb068f2b1..efdecc0030e 100644 --- a/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php +++ b/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php @@ -249,6 +249,38 @@ public function replacePreservesUnsealed(array $arr): void } +class UnpackingMakesUnsealed +{ + + /** + * @param list{int, string} $sealed + * @param list $unknownItems + */ + public function unshiftListWithUnpack(array $sealed, array $unknownItems): void + { + array_unshift($sealed, ...$unknownItems); + // A list can't keep precise indices when an unknown number of + // values are prepended — every original index is shifted by an + // unknown amount. The shape collapses to "non-empty list of the + // value union" (current behavior, kept). + assertType('non-empty-list', $sealed); + } + + /** + * @param array{a: int, b: string} $sealed + * @param list $unknownItems + */ + public function unshiftAssocWithUnpack(array $sealed, array $unknownItems): void + { + array_unshift($sealed, ...$unknownItems); + // Associative input — string keys are preserved exactly. The + // unknown number of prepended values is reflected as an unsealed + // `int` slot on the resulting shape. + assertType('array{a: int, b: string, ...}', $sealed); + } + +} + class CountNarrowing { From 095b8ff14917f8e05dd05c15ea69057b5d69ae7f Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 12 May 2026 15:14:39 +0200 Subject: [PATCH 36/66] Make `array_merge` slow path produce unsealed CAT for non-constant unpack When at least one input is non-constant and all CAT inputs have explicit sealedness (not `isUnsealed()->maybe()`), build the result as `array{known-keys, ...}` instead of the equivalent `ArrayType & HasOffsetValueType(...) & NonEmptyArrayType` intersection. The two are semantically equivalent but the unsealed CAT form is far more readable in error messages and composes naturally with other unsealed-CAT operations. Gated on bleeding-edge representation: when any input CAT has `isUnsealed()->maybe()` (the pre-bleeding-edge legacy form where sealedness was implicit), keep the original intersection output to preserve behaviour for non-bleeding-edge users. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...ergeFunctionDynamicReturnTypeExtension.php | 62 +++++++++++++++++++ .../PHPStan/Analyser/nsrt/array-functions.php | 4 +- .../nsrt/array-merge-const-non-const.php | 38 ++++++------ tests/PHPStan/Analyser/nsrt/bug-2911.php | 18 +++--- .../nsrt/generalize-scope-recursive.php | 2 +- 5 files changed, 93 insertions(+), 31 deletions(-) diff --git a/src/Type/Php/ArrayMergeFunctionDynamicReturnTypeExtension.php b/src/Type/Php/ArrayMergeFunctionDynamicReturnTypeExtension.php index 20c29b40d63..637c4908d7a 100644 --- a/src/Type/Php/ArrayMergeFunctionDynamicReturnTypeExtension.php +++ b/src/Type/Php/ArrayMergeFunctionDynamicReturnTypeExtension.php @@ -125,6 +125,24 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection, } $offsetTypes = []; + $nonConstantArrayWasUnpacked = false; + $unsealedKeyTypes = []; + $unsealedValueTypes = []; + // Only switch to the unsealed-CAT result format when every CAT + // input has explicit sealedness (`isUnsealed` is `Yes` or `No`, + // i.e. bleeding-edge representation). Legacy CATs report + // `Maybe` and must keep the original `HasOffsetType`-style + // output to avoid changing the shape for non-bleeding-edge + // users. + $canRebuildAsUnsealedCat = true; + foreach ($argTypes as $argType) { + foreach ($argType->getConstantArrays() as $constantArray) { + if ($constantArray->isUnsealed()->maybe()) { + $canRebuildAsUnsealedCat = false; + break 2; + } + } + } foreach ($argTypes as $argIndex => $argType) { if (in_array($argIndex, $optionalArgTypes, true)) { continue; @@ -141,6 +159,21 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection, ]; } } + } elseif ($canRebuildAsUnsealedCat) { + $nonConstantArrayWasUnpacked = true; + $iterableValue = $argType->getIterableValueType(); + $unsealedKeyTypes[] = $argType->getIterableKeyType(); + $unsealedValueTypes[] = $iterableValue; + foreach ($offsetTypes as $key => [$hasOffsetValue, $offsetValueType]) { + // Existing offsets stay required (the sealed input + // contributed them) but their value broadens to + // include the unknown shape's iterable value — the + // unknown shape might overwrite the offset. + $offsetTypes[$key] = [ + $hasOffsetValue, + TypeCombinator::union($offsetValueType, $iterableValue), + ]; + } } else { foreach ($offsetTypes as $key => [$hasOffsetValue, $offsetValueType]) { // more precise values-types will be calculated elsewhere. @@ -193,6 +226,35 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection, return new ConstantArrayType([], []); } + // Non-constant unpack contributes an unknown shape: rebuild as + // an unsealed CAT — explicit keys (from sealed inputs) on the + // CAT side, the unknown shape's iterable key/value as the + // unsealed slot. More idiomatic than the + // `ArrayType ∩ HasOffsetValueType ∩ ...` form for the same + // result. + if ($nonConstantArrayWasUnpacked) { + $builder = ConstantArrayTypeBuilder::createEmpty(); + foreach ($offsetTypes as $key => [$hasOffsetValue, $offsetType]) { + if (is_int($key) || $hasOffsetValue->no()) { + continue; + } + $builder->setOffsetValueType(new ConstantStringType((string) $key), $offsetType, !$hasOffsetValue->yes()); + } + $builder->makeUnsealed( + TypeCombinator::union(...$unsealedKeyTypes), + TypeCombinator::union(...$unsealedValueTypes), + ); + $arrayType = $builder->getArray(); + if ($nonEmpty) { + $arrayType = TypeCombinator::intersect($arrayType, new NonEmptyArrayType()); + } + if ($isList) { + $arrayType = TypeCombinator::intersect($arrayType, new AccessoryArrayListType()); + } + + return $arrayType; + } + $arrayType = new ArrayType( $keyType, TypeCombinator::union(...$valueTypes), diff --git a/tests/PHPStan/Analyser/nsrt/array-functions.php b/tests/PHPStan/Analyser/nsrt/array-functions.php index ab06eb4256a..6255e09caf7 100644 --- a/tests/PHPStan/Analyser/nsrt/array-functions.php +++ b/tests/PHPStan/Analyser/nsrt/array-functions.php @@ -246,8 +246,8 @@ assertType('list', array_values($generalStringKeys)); assertType('array{foo: stdClass, 0: stdClass}', array_merge($stringOrIntegerKeys)); assertType('array', array_merge($generalStringKeys, $generalDateTimeValues)); -assertType('non-empty-array<1|string, int|stdClass>&hasOffsetValue(\'foo\', stdClass)', array_merge($generalStringKeys, $stringOrIntegerKeys)); -assertType('non-empty-array<1|string, int|stdClass>&hasOffset(\'foo\')', array_merge($stringOrIntegerKeys, $generalStringKeys)); +assertType('array{foo: stdClass, ...}', array_merge($generalStringKeys, $stringOrIntegerKeys)); +assertType('array{foo: int|stdClass, ...}', array_merge($stringOrIntegerKeys, $generalStringKeys)); assertType('array{foo: stdClass, bar: stdClass, 0: stdClass}', array_merge($stringKeys, $stringOrIntegerKeys)); assertType('array{foo: \'foo\', 0: stdClass, bar: stdClass}', array_merge($stringOrIntegerKeys, $stringKeys)); assertType('array{foo: 1, bar: 2, 0: 2, 1: 3}', array_merge(['foo' => 4, 'bar' => 5], ...[['foo' => 1, 'bar' => 2], [2, 3]])); diff --git a/tests/PHPStan/Analyser/nsrt/array-merge-const-non-const.php b/tests/PHPStan/Analyser/nsrt/array-merge-const-non-const.php index 03286b936a2..1d4bf1bab41 100644 --- a/tests/PHPStan/Analyser/nsrt/array-merge-const-non-const.php +++ b/tests/PHPStan/Analyser/nsrt/array-merge-const-non-const.php @@ -6,21 +6,21 @@ function doFoo(array $post): void { assertType( - "non-empty-array&hasOffset('a')&hasOffset('b')", + 'array{a: mixed, b: mixed, ...}', array_merge(['a' => 1, 'b' => false, 10 => 99], $post) ); } function doBar(array $array): void { assertType( - "non-empty-array&hasOffsetValue('a', 1)&hasOffsetValue('b', false)", + 'array{a: 1, b: false, ...}', array_merge($array, ['a' => 1, 'b' => false, 10 => 99]) ); } function doFooBar(array $array): void { assertType( - "non-empty-array&hasOffset('x')&hasOffsetValue('a', 1)&hasOffsetValue('b', false)&hasOffsetValue('c', 'e')", + "array{c: 'e', x: mixed, a: 1, b: false, ...}", array_merge(['c' => 'd', 'x' => 'y'], $array, ['a' => 1, 'b' => false, 'c' => 'e']) ); } @@ -28,7 +28,7 @@ function doFooBar(array $array): void { function doFooInts(array $array): void { // int keys will be renumbered therefore we can't reason about them in case we don't know all arrays involved assertType( - "non-empty-array&hasOffsetValue('a', 1)&hasOffsetValue('c', 'e')", + "array{a: 1, c: 'e', ...}", array_merge([1 => 'd'], $array, ['a' => 1, 3 => false, 'c' => 'e']) ); } @@ -38,7 +38,7 @@ function doFooInts(array $array): void { */ function floatKey(array $array): void { assertType( - "non-empty-array&hasOffsetValue('a', '1')&hasOffsetValue('c', 'e')", + "array{a: '1', c: 'e', ...}", array_merge([4.23 => 'd'], $array, ['a' => '1', 3 => 'false', 'c' => 'e']) ); } @@ -55,14 +55,14 @@ function doOptKeys(array $array, array $arr2): void { * @param array{a?: 1, b: 2} $array */ function doOptShapeKeys(array $array, array $arr2): void { - assertType("non-empty-array&hasOffsetValue('b', 2)", array_merge($arr2, $array)); - assertType("non-empty-array&hasOffset('b')", array_merge($array, $arr2)); + assertType('array{b: 2, ...}', array_merge($arr2, $array)); + assertType('array{b: mixed, ...}', array_merge($array, $arr2)); } function hasOffsetKeys(array $array, array $arr2): void { if (array_key_exists('b', $array)) { - assertType("non-empty-array&hasOffsetValue('b', mixed)", array_merge($arr2, $array)); - assertType("non-empty-array&hasOffset('b')", array_merge($array, $arr2)); + assertType('array{b: mixed, ...}', array_merge($arr2, $array)); + assertType('array{b: mixed, ...}', array_merge($array, $arr2)); } } @@ -80,24 +80,24 @@ function hasOffsetValueKeys(array $hasB, array $mixedArray, array $hasC): void { $hasB['b'] = 123; $hasC['c'] = 'def'; - assertType("non-empty-array&hasOffsetValue('b', 123)", array_merge($mixedArray, $hasB)); - assertType("non-empty-array&hasOffset('b')", array_merge($hasB, $mixedArray)); + assertType('array{b: 123, ...}', array_merge($mixedArray, $hasB)); + assertType('array{b: mixed, ...}', array_merge($hasB, $mixedArray)); assertType( - "non-empty-array&hasOffset('b')&hasOffsetValue('c', 'def')", + "array{b: mixed, c: 'def', ...}", array_merge($mixedArray, $hasB, $hasC) ); assertType( - "non-empty-array&hasOffset('b')&hasOffsetValue('c', 'def')", + "array{b: mixed, c: 'def', ...}", array_merge($hasB, $mixedArray, $hasC) ); assertType( - "non-empty-array&hasOffset('c')&hasOffsetValue('b', 123)", + 'array{c: mixed, b: 123, ...}', array_merge($hasC, $mixedArray, $hasB) ); assertType( - "non-empty-array&hasOffset('b')&hasOffset('c')", + 'array{c: mixed, b: mixed, ...}', array_merge($hasC, $hasB, $mixedArray) ); @@ -116,12 +116,12 @@ function hasOffsetValueKeys(array $hasB, array $mixedArray, array $hasC): void { $differentCs = ['c' => 20]; } assertType('array{c: 10}|array{c: 20}', $differentCs); - assertType("non-empty-array&hasOffsetValue('c', 10|20)", array_merge($mixedArray, $differentCs)); - assertType("non-empty-array&hasOffset('c')", array_merge($differentCs, $mixedArray)); + assertType('array{c: 10|20, ...}', array_merge($mixedArray, $differentCs)); + assertType('array{c: mixed, ...}', array_merge($differentCs, $mixedArray)); - assertType("non-empty-array&hasOffsetValue('c', 10|20)", array_merge($mixedArray, $hasBorC, $differentCs)); + assertType('array{c: 10|20, ...}', array_merge($mixedArray, $hasBorC, $differentCs)); assertType("non-empty-array", array_merge($differentCs, $mixedArray, $hasBorC)); // could be non-empty-array&hasOffset('c') - assertType("non-empty-array&hasOffsetValue('c', 10|20)", array_merge($hasBorC, $mixedArray, $differentCs)); + assertType('array{c: 10|20, ...}', array_merge($hasBorC, $mixedArray, $differentCs)); assertType("non-empty-array", array_merge($differentCs, $hasBorC, $mixedArray)); // could be non-empty-array&hasOffset('c') } diff --git a/tests/PHPStan/Analyser/nsrt/bug-2911.php b/tests/PHPStan/Analyser/nsrt/bug-2911.php index b845c0189b5..226abe9d9b4 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-2911.php +++ b/tests/PHPStan/Analyser/nsrt/bug-2911.php @@ -49,32 +49,32 @@ public function __construct(MutatorConfig $config) private function getResultSettings(array $settings): array { $settings = array_merge(self::DEFAULT_SETTINGS, $settings); - assertType("non-empty-array&hasOffset('limit')&hasOffset('remove')", $settings); + assertType('array{remove: mixed, limit: mixed, ...}', $settings); if (!is_string($settings['remove'])) { throw $this->configException($settings, 'remove'); } - assertType("non-empty-array&hasOffset('limit')&hasOffsetValue('remove', string)", $settings); + assertType('array{remove: string, limit: mixed, ...}', $settings); $settings['remove'] = strtolower($settings['remove']); - assertType("non-empty-array&hasOffset('limit')&hasOffsetValue('remove', lowercase-string)", $settings); + assertType('array{remove: lowercase-string, limit: mixed, ...}', $settings); if (!in_array($settings['remove'], ['first', 'last', 'all'], true)) { throw $this->configException($settings, 'remove'); } - assertType("non-empty-array&hasOffset('limit')&hasOffsetValue('remove', 'all'|'first'|'last')", $settings); + assertType("array{remove: 'all'|'first'|'last', limit: mixed, ...}", $settings); if (!is_numeric($settings['limit']) || $settings['limit'] < 1) { throw $this->configException($settings, 'limit'); } - assertType("non-empty-array&hasOffsetValue('limit', float|int<1, max>|numeric-string)&hasOffsetValue('remove', 'all'|'first'|'last')", $settings); + assertType("array{remove: 'all'|'first'|'last', limit: float|int<1, max>|numeric-string, ...}", $settings); $settings['limit'] = (int) $settings['limit']; - assertType("non-empty-array&hasOffsetValue('limit', int)&hasOffsetValue('remove', 'all'|'first'|'last')", $settings); + assertType("array{remove: 'all'|'first'|'last', limit: int, ...}", $settings); return $settings; } @@ -110,19 +110,19 @@ private function getResultSettings(array $settings): array { $settings = array_merge(self::DEFAULT_SETTINGS, $settings); - assertType("non-empty-array&hasOffset('limit')&hasOffset('remove')", $settings); + assertType('array{remove: mixed, limit: mixed, ...}', $settings); if (!is_string($settings['remove'])) { throw new Exception(); } - assertType("non-empty-array&hasOffset('limit')&hasOffsetValue('remove', string)", $settings); + assertType('array{remove: string, limit: mixed, ...}', $settings); if (!is_int($settings['limit'])) { throw new Exception(); } - assertType("non-empty-array&hasOffsetValue('limit', int)&hasOffsetValue('remove', string)", $settings); + assertType('array{remove: string, limit: int, ...}', $settings); return $settings; } diff --git a/tests/PHPStan/Analyser/nsrt/generalize-scope-recursive.php b/tests/PHPStan/Analyser/nsrt/generalize-scope-recursive.php index 8d13c5526fe..d83076eba58 100644 --- a/tests/PHPStan/Analyser/nsrt/generalize-scope-recursive.php +++ b/tests/PHPStan/Analyser/nsrt/generalize-scope-recursive.php @@ -16,7 +16,7 @@ public function doFoo(array $array, array $values) } } - assertType('array{foo?: array}', $data); + assertType('array{}|array{foo: array>}', $data); } /** From 4fc494b71ce8e8594860d5bd5704a43fb8b5ccb3 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 12 May 2026 15:14:49 +0200 Subject: [PATCH 37/66] Make `array_replace` slow path produce unsealed CAT for non-constant unpack Mirrors the `array_merge` change: when at least one input is non-constant and all CAT inputs have explicit sealedness (not `isUnsealed()->maybe()`), build the result as `array{known-keys, ...}` instead of the equivalent intersection of `ArrayType`, `HasOffsetValueType`/`HasOffsetType`, and `NonEmptyArrayType`. Same bleeding-edge gate so non-bleeding-edge users keep the original output. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...rrayReplaceFunctionReturnTypeExtension.php | 51 +++++++++++++++++++ .../nsrt/array-replace-const-non-const.php | 40 +++++++-------- tests/PHPStan/Analyser/nsrt/array-replace.php | 8 +-- 3 files changed, 75 insertions(+), 24 deletions(-) diff --git a/src/Type/Php/ArrayReplaceFunctionReturnTypeExtension.php b/src/Type/Php/ArrayReplaceFunctionReturnTypeExtension.php index 48c26330ce1..834175222a2 100644 --- a/src/Type/Php/ArrayReplaceFunctionReturnTypeExtension.php +++ b/src/Type/Php/ArrayReplaceFunctionReturnTypeExtension.php @@ -126,6 +126,22 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection, } $offsetTypes = []; + $nonConstantArrayWasUnpacked = false; + $unsealedKeyTypes = []; + $unsealedValueTypes = []; + // Only switch to the unsealed-CAT result format when every CAT + // input has explicit sealedness — see the matching gate in + // `ArrayMergeFunctionDynamicReturnTypeExtension` for the + // rationale. + $canRebuildAsUnsealedCat = true; + foreach ($argTypes as $argType) { + foreach ($argType->getConstantArrays() as $constantArray) { + if ($constantArray->isUnsealed()->maybe()) { + $canRebuildAsUnsealedCat = false; + break 2; + } + } + } foreach ($argTypes as $argIndex => $argType) { if (in_array($argIndex, $optionalArgTypes, true)) { continue; @@ -142,6 +158,17 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection, ]; } } + } elseif ($canRebuildAsUnsealedCat) { + $nonConstantArrayWasUnpacked = true; + $iterableValue = $argType->getIterableValueType(); + $unsealedKeyTypes[] = $argType->getIterableKeyType(); + $unsealedValueTypes[] = $iterableValue; + foreach ($offsetTypes as $key => [$hasOffsetValue, $offsetValueType]) { + $offsetTypes[$key] = [ + $hasOffsetValue, + TypeCombinator::union($offsetValueType, $iterableValue), + ]; + } } else { foreach ($offsetTypes as $key => [$hasOffsetValue, $offsetValueType]) { // more precise values-types will be calculated elsewhere. @@ -194,6 +221,30 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection, return new ConstantArrayType([], []); } + if ($nonConstantArrayWasUnpacked) { + $builder = ConstantArrayTypeBuilder::createEmpty(); + foreach ($offsetTypes as $key => [$hasOffsetValue, $offsetType]) { + if ($hasOffsetValue->no()) { + continue; + } + $constKey = is_string($key) ? new ConstantStringType($key) : new ConstantIntegerType($key); + $builder->setOffsetValueType($constKey, $offsetType, !$hasOffsetValue->yes()); + } + $builder->makeUnsealed( + TypeCombinator::union(...$unsealedKeyTypes), + TypeCombinator::union(...$unsealedValueTypes), + ); + $arrayType = $builder->getArray(); + if ($nonEmpty) { + $arrayType = TypeCombinator::intersect($arrayType, new NonEmptyArrayType()); + } + if ($isList) { + $arrayType = TypeCombinator::intersect($arrayType, new AccessoryArrayListType()); + } + + return $arrayType; + } + $arrayType = new ArrayType( $keyType, TypeCombinator::union(...$valueTypes), diff --git a/tests/PHPStan/Analyser/nsrt/array-replace-const-non-const.php b/tests/PHPStan/Analyser/nsrt/array-replace-const-non-const.php index 9420d6f13ba..657db686771 100644 --- a/tests/PHPStan/Analyser/nsrt/array-replace-const-non-const.php +++ b/tests/PHPStan/Analyser/nsrt/array-replace-const-non-const.php @@ -7,21 +7,21 @@ function doFoo(array $post): void { assertType( - "non-empty-array&hasOffset('a')&hasOffset('b')&hasOffset(10)", + 'array{a: mixed, b: mixed, 10: mixed, ...}', array_replace(['a' => 1, 'b' => false, 10 => 99], $post) ); } function doBar(array $array): void { assertType( - "non-empty-array&hasOffsetValue('a', 1)&hasOffsetValue('b', false)&hasOffsetValue(10, 99)", + 'array{a: 1, b: false, 10: 99, ...}', array_replace($array, ['a' => 1, 'b' => false, 10 => 99]) ); } function doFooBar(array $array): void { assertType( - "non-empty-array&hasOffset('x')&hasOffsetValue('a', 1)&hasOffsetValue('b', false)&hasOffsetValue('c', 'e')", + "array{c: 'e', x: mixed, a: 1, b: false, ...}", array_replace(['c' => 'd', 'x' => 'y'], $array, ['a' => 1, 'b' => false, 'c' => 'e']) ); } @@ -30,14 +30,14 @@ function doFooBar(array $array): void { * @param array{a?: 1, b: 2} $array */ function doOptShapeKeys(array $array, array $arr2): void { - assertType("non-empty-array&hasOffsetValue('b', 2)", array_replace($arr2, $array)); - assertType("non-empty-array&hasOffset('b')", array_replace($array, $arr2)); + assertType('array{b: 2, ...}', array_replace($arr2, $array)); + assertType('array{b: mixed, ...}', array_replace($array, $arr2)); } function hasOffsetKeys(array $array, array $arr2): void { if (array_key_exists('b', $array)) { - assertType("non-empty-array&hasOffsetValue('b', mixed)", array_replace($arr2, $array)); - assertType("non-empty-array&hasOffset('b')", array_replace($array, $arr2)); + assertType('array{b: mixed, ...}', array_replace($arr2, $array)); + assertType('array{b: mixed, ...}', array_replace($array, $arr2)); } } @@ -55,24 +55,24 @@ function hasOffsetValueKeys(array $hasB, array $mixedArray, array $hasC): void { $hasB['b'] = 123; $hasC['c'] = 'def'; - assertType("non-empty-array&hasOffsetValue('b', 123)", array_replace($mixedArray, $hasB)); - assertType("non-empty-array&hasOffset('b')", array_replace($hasB, $mixedArray)); + assertType('array{b: 123, ...}', array_replace($mixedArray, $hasB)); + assertType('array{b: mixed, ...}', array_replace($hasB, $mixedArray)); assertType( - "non-empty-array&hasOffset('b')&hasOffsetValue('c', 'def')", + "array{b: mixed, c: 'def', ...}", array_replace($mixedArray, $hasB, $hasC) ); assertType( - "non-empty-array&hasOffset('b')&hasOffsetValue('c', 'def')", + "array{b: mixed, c: 'def', ...}", array_replace($hasB, $mixedArray, $hasC) ); assertType( - "non-empty-array&hasOffset('c')&hasOffsetValue('b', 123)", + 'array{c: mixed, b: 123, ...}', array_replace($hasC, $mixedArray, $hasB) ); assertType( - "non-empty-array&hasOffset('b')&hasOffset('c')", + 'array{c: mixed, b: mixed, ...}', array_replace($hasC, $hasB, $mixedArray) ); @@ -91,12 +91,12 @@ function hasOffsetValueKeys(array $hasB, array $mixedArray, array $hasC): void { $differentCs = ['c' => 20]; } assertType('array{c: 10}|array{c: 20}', $differentCs); - assertType("non-empty-array&hasOffsetValue('c', 10|20)", array_replace($mixedArray, $differentCs)); - assertType("non-empty-array&hasOffset('c')", array_replace($differentCs, $mixedArray)); + assertType('array{c: 10|20, ...}', array_replace($mixedArray, $differentCs)); + assertType('array{c: mixed, ...}', array_replace($differentCs, $mixedArray)); - assertType("non-empty-array&hasOffsetValue('c', 10|20)", array_replace($mixedArray, $hasBorC, $differentCs)); + assertType('array{c: 10|20, ...}', array_replace($mixedArray, $hasBorC, $differentCs)); assertType("non-empty-array", array_replace($differentCs, $mixedArray, $hasBorC)); // could be non-empty-array&hasOffset('c') - assertType("non-empty-array&hasOffsetValue('c', 10|20)", array_replace($hasBorC, $mixedArray, $differentCs)); + assertType('array{c: 10|20, ...}', array_replace($hasBorC, $mixedArray, $differentCs)); assertType("non-empty-array", array_replace($differentCs, $hasBorC, $mixedArray)); // could be non-empty-array&hasOffset('c') } @@ -113,13 +113,13 @@ function withArrayReplacement(array $base): void { $replacements2 = [ 'citrus' => [ 'kumquat', 'citron' ], 'pome' => [ 'loquat' ] ]; $basket = array_replace($base, $replacements, $replacements2); - assertType("non-empty-array&hasOffsetValue('citrus', array{'kumquat', 'citron'})&hasOffsetValue('pome', array{'loquat'})", $basket); + assertType("array{citrus: array{'kumquat', 'citron'}, pome: array{'loquat'}, ...}", $basket); } /** * @param array{foo: int, x: string}|array{foo: string, y: 1} $arr1 */ function doUnions(array $arr1, array $arr2): void { - assertType("non-empty-array&hasOffset('foo')", array_replace($arr1, $arr2)); - assertType("non-empty-array&hasOffsetValue('foo', int|string)", array_replace($arr2, $arr1)); + assertType('array{foo: mixed, ...}', array_replace($arr1, $arr2)); + assertType('array{foo: int|string, ...}', array_replace($arr2, $arr1)); } diff --git a/tests/PHPStan/Analyser/nsrt/array-replace.php b/tests/PHPStan/Analyser/nsrt/array-replace.php index 0b22a8f74a2..6bf0d2b0bac 100644 --- a/tests/PHPStan/Analyser/nsrt/array-replace.php +++ b/tests/PHPStan/Analyser/nsrt/array-replace.php @@ -76,11 +76,11 @@ public function arrayReplaceUnionTypeArrayShapes($array1, $array2): void */ public function arrayReplaceArrayShapeAndGeneralArray($array1, $array2, $array3): void { - assertType("non-empty-array&hasOffset('bar')&hasOffset('foo')", array_replace($array1, $array2)); - assertType("non-empty-array&hasOffsetValue('bar', '2')&hasOffsetValue('foo', '1')", array_replace($array2, $array1)); + assertType("array{foo: '1'|int, bar: '2'|int, ...}", array_replace($array1, $array2)); + assertType("array{foo: '1', bar: '2', ...}", array_replace($array2, $array1)); - assertType("non-empty-array<'bar'|'foo'|int, string>&hasOffset('bar')&hasOffset('foo')", array_replace($array1, $array3)); - assertType("non-empty-array<'bar'|'foo'|int, string>&hasOffsetValue('bar', '2')&hasOffsetValue('foo', '1')", array_replace($array3, $array1)); + assertType("array{foo: string, bar: string, ...}", array_replace($array1, $array3)); + assertType("array{foo: '1', bar: '2', ...}", array_replace($array3, $array1)); assertType("array", array_replace($array2, $array3)); } From 4585c2d647c4a9f8033980f036af869132be8124 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 12 May 2026 16:59:44 +0200 Subject: [PATCH 38/66] Preserve int-keyed values from CATs in `array_merge` slow path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit rebuilt the slow-path result as `array{string-keys, ...}` but dropped int-keyed values from sealed CAT inputs entirely. `array_merge` renumbers int keys rather than overwriting them, so a CAT's `1 => stdClass` survives in the output under a renumbered int key — but with the int key gone from the offsets and not redirected anywhere, the result type lost both the int key contribution and its value. Push CAT int-keyed values into the unsealed slot under an `int` key instead. Also skip the int-key broadening when a non-constant input is mixed in: `array_merge` does not overwrite int keys with a later arg's value, so broadening them with the non-const's iterableValue was overstating the value type. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...ergeFunctionDynamicReturnTypeExtension.php | 33 +++++++++++++++---- .../PHPStan/Analyser/nsrt/array-functions.php | 4 +-- 2 files changed, 29 insertions(+), 8 deletions(-) diff --git a/src/Type/Php/ArrayMergeFunctionDynamicReturnTypeExtension.php b/src/Type/Php/ArrayMergeFunctionDynamicReturnTypeExtension.php index 637c4908d7a..b82ce7bb971 100644 --- a/src/Type/Php/ArrayMergeFunctionDynamicReturnTypeExtension.php +++ b/src/Type/Php/ArrayMergeFunctionDynamicReturnTypeExtension.php @@ -165,10 +165,16 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection, $unsealedKeyTypes[] = $argType->getIterableKeyType(); $unsealedValueTypes[] = $iterableValue; foreach ($offsetTypes as $key => [$hasOffsetValue, $offsetValueType]) { - // Existing offsets stay required (the sealed input - // contributed them) but their value broadens to - // include the unknown shape's iterable value — the - // unknown shape might overwrite the offset. + if (is_int($key)) { + // array_merge renumbers int keys instead of + // overwriting them, so a later non-constant + // input doesn't broaden a CAT's int-key value. + continue; + } + // Existing string offsets stay required (the sealed + // input contributed them) but their value broadens + // to include the unknown shape's iterable value — + // the unknown shape might overwrite the offset. $offsetTypes[$key] = [ $hasOffsetValue, TypeCombinator::union($offsetValueType, $iterableValue), @@ -234,11 +240,26 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection, // result. if ($nonConstantArrayWasUnpacked) { $builder = ConstantArrayTypeBuilder::createEmpty(); + $intKeyValuesFromCats = []; foreach ($offsetTypes as $key => [$hasOffsetValue, $offsetType]) { - if (is_int($key) || $hasOffsetValue->no()) { + if (is_int($key)) { + // array_merge renumbers int keys. We can't track + // what they become, so push their values into the + // unsealed slot under an `int` key instead of + // dropping them. + if (!$hasOffsetValue->no()) { + $intKeyValuesFromCats[] = $offsetType; + } continue; } - $builder->setOffsetValueType(new ConstantStringType((string) $key), $offsetType, !$hasOffsetValue->yes()); + if ($hasOffsetValue->no()) { + continue; + } + $builder->setOffsetValueType(new ConstantStringType($key), $offsetType, !$hasOffsetValue->yes()); + } + if ($intKeyValuesFromCats !== []) { + $unsealedKeyTypes[] = new IntegerType(); + $unsealedValueTypes[] = TypeCombinator::union(...$intKeyValuesFromCats); } $builder->makeUnsealed( TypeCombinator::union(...$unsealedKeyTypes), diff --git a/tests/PHPStan/Analyser/nsrt/array-functions.php b/tests/PHPStan/Analyser/nsrt/array-functions.php index 6255e09caf7..dbbbe0cf76c 100644 --- a/tests/PHPStan/Analyser/nsrt/array-functions.php +++ b/tests/PHPStan/Analyser/nsrt/array-functions.php @@ -246,8 +246,8 @@ assertType('list', array_values($generalStringKeys)); assertType('array{foo: stdClass, 0: stdClass}', array_merge($stringOrIntegerKeys)); assertType('array', array_merge($generalStringKeys, $generalDateTimeValues)); -assertType('array{foo: stdClass, ...}', array_merge($generalStringKeys, $stringOrIntegerKeys)); -assertType('array{foo: int|stdClass, ...}', array_merge($stringOrIntegerKeys, $generalStringKeys)); +assertType('array{foo: stdClass, ...}', array_merge($generalStringKeys, $stringOrIntegerKeys)); +assertType('array{foo: int|stdClass, ...}', array_merge($stringOrIntegerKeys, $generalStringKeys)); assertType('array{foo: stdClass, bar: stdClass, 0: stdClass}', array_merge($stringKeys, $stringOrIntegerKeys)); assertType('array{foo: \'foo\', 0: stdClass, bar: stdClass}', array_merge($stringOrIntegerKeys, $stringKeys)); assertType('array{foo: 1, bar: 2, 0: 2, 1: 3}', array_merge(['foo' => 4, 'bar' => 5], ...[['foo' => 1, 'bar' => 2], [2, 3]])); From 1f6a697d18f4f3e48483ca74fd997373fb8d5ad4 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 13 May 2026 07:58:18 +0200 Subject: [PATCH 39/66] Honour unsealed shape in `ConstantArrayType::isCallable()` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `doFindTypeAndMethodNames()` previously rejected anything other than exactly two explicit keys, so two related cases came out wrong: - `array{0: object, 1: 'method', ...}` returned `Yes` even though the unsealed slot could add extra keys, voiding the `[classOrObject, method]` shape. Should be `Maybe`. - `array{0: object, ...}` returned `No` because only one explicit key was present, even though a `1 => 'method'` extra is exactly what the unsealed slot might supply. Should be `Maybe`. Drive the slot-filling from the unsealed types: if a slot is missing, check that the unsealed key range covers the missing index *and* that the unsealed value type can overlap with the type required for that slot (object|class-string for key 0, non-falsy-string for key 1). Otherwise no concrete value of the CAT could ever be callable — return `[]` so `isCallable()` answers `No`. When the array is unsealed, downgrade the per-method certainty to `Maybe` — extras at positions other than 0/1 would void callability, but the unsealed slot describes "zero or more" extras so it's genuinely uncertain. Added tests cover both wrong-value-type unsealed slots (int can't be object|class-string nor non-falsy-string) and the symmetric "only key 1 explicit" case, plus the >2-keys and stray-key sealed disqualifiers. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Type/Constant/ConstantArrayType.php | 61 ++++++- .../Type/Constant/ConstantArrayTypeTest.php | 170 ++++++++++++++++++ 2 files changed, 228 insertions(+), 3 deletions(-) diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index 29542e34914..bf33b77a98f 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -36,6 +36,7 @@ use PHPStan\Type\ArrayType; use PHPStan\Type\BenevolentUnionType; use PHPStan\Type\BooleanType; +use PHPStan\Type\ClassStringType; use PHPStan\Type\CompoundType; use PHPStan\Type\ConstantScalarType; use PHPStan\Type\ErrorType; @@ -52,6 +53,7 @@ use PHPStan\Type\MixedType; use PHPStan\Type\NeverType; use PHPStan\Type\NullType; +use PHPStan\Type\ObjectWithoutClassType; use PHPStan\Type\RecursionGuard; use PHPStan\Type\StaticTypeFactory; use PHPStan\Type\StrictMixedType; @@ -981,7 +983,17 @@ public function findTypeAndMethodNames(): array /** @return ConstantArrayTypeAndMethod[] */ private function doFindTypeAndMethodNames(bool &$hasNonExistentMethod = false): array { - if (count($this->keyTypes) !== 2) { + $isUnsealed = $this->isUnsealed()->yes(); + + // Sealed: must have exactly the two callable slots, no more, no less. + // Unsealed: explicit keys may cover 0, 1, both, or neither — but any + // explicit key outside {0, 1} immediately disqualifies, because the + // callable shape `[classOrObject, method]` has no room for other + // keys. + if (!$isUnsealed && count($this->keyTypes) !== 2) { + return []; + } + if (count($this->keyTypes) > 2) { return []; } @@ -993,11 +1005,47 @@ private function doFindTypeAndMethodNames(bool &$hasNonExistentMethod = false): continue; } - if (!$keyType->isSuperTypeOf(new ConstantIntegerType(1))->yes()) { + if ($keyType->isSuperTypeOf(new ConstantIntegerType(1))->yes()) { + $method = $this->valueTypes[$i]; continue; } - $method = $this->valueTypes[$i]; + // Explicit key is something other than 0 or 1 — not callable. + return []; + } + + // Try to fill missing callable slots from the unsealed extras: an + // unsealed array `array{0: object, ...}` *might* turn + // into a callable if the actual value carries a `1 => 'method'` + // extra. Require that the unsealed key range covers the missing + // slot and that the unsealed value type can overlap with the + // type required for that slot (object|class-string for key 0, + // non-falsy-string for key 1) — otherwise no concrete value of + // this CAT can ever be callable. + if ($isUnsealed && $this->unsealed !== null) { + [$unsealedKey, $unsealedValue] = $this->unsealed; + + if ($classOrObject === null) { + if ($unsealedKey->isSuperTypeOf(new ConstantIntegerType(0))->no()) { + return []; + } + $expected = TypeCombinator::union(new ObjectWithoutClassType(), new ClassStringType()); + if ($expected->isSuperTypeOf($unsealedValue)->no()) { + return []; + } + $classOrObject = $unsealedValue; + } + + if ($method === null) { + if ($unsealedKey->isSuperTypeOf(new ConstantIntegerType(1))->no()) { + return []; + } + $expected = TypeCombinator::intersect(new StringType(), new AccessoryNonFalsyStringType()); + if ($expected->isSuperTypeOf($unsealedValue)->no()) { + return []; + } + $method = $unsealedValue; + } } if ($classOrObject === null || $method === null) { @@ -1045,6 +1093,13 @@ private function doFindTypeAndMethodNames(bool &$hasNonExistentMethod = false): $has = $has->and(TrinaryLogic::createMaybe()); } + // Unsealed: the actual value may carry extras beyond keys 0/1, + // which would void the callable shape. The CAT itself describes + // "zero or more extras", so callable-ness is uncertain. + if ($isUnsealed) { + $has = $has->and(TrinaryLogic::createMaybe()); + } + $typeAndMethods[] = ConstantArrayTypeAndMethod::createConcrete($type, $methodName->getValue(), $has); } diff --git a/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php b/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php index 28167c294c9..2bd1a6452ad 100644 --- a/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php +++ b/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php @@ -11,6 +11,7 @@ use PHPStan\Type\Accessory\HasOffsetValueType; use PHPStan\Type\ArrayType; use PHPStan\Type\CallableType; +use PHPStan\Type\ClassStringType; use PHPStan\Type\Generic\GenericClassStringType; use PHPStan\Type\Generic\TemplateTypeFactory; use PHPStan\Type\Generic\TemplateTypeScope; @@ -23,6 +24,7 @@ use PHPStan\Type\NeverType; use PHPStan\Type\NullType; use PHPStan\Type\ObjectType; +use PHPStan\Type\ObjectWithoutClassType; use PHPStan\Type\StringType; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; @@ -1209,6 +1211,174 @@ public static function dataIsCallable(): iterable ]), TrinaryLogic::createYes(), ]; + + yield [ + new ConstantArrayType([ + new ConstantIntegerType(0), + new ConstantIntegerType(1), + ], [ + new ClassStringType(), + new StringType(), + ]), + TrinaryLogic::createMaybe(), + ]; + + yield [ + new ConstantArrayType([ + new ConstantIntegerType(0), + new ConstantIntegerType(1), + ], [ + new ObjectWithoutClassType(), + new StringType(), + ]), + TrinaryLogic::createMaybe(), + ]; + + $never = new NeverType(true); + $sealed = [$never, $never]; + + yield [ + new ConstantArrayType([ + new ConstantIntegerType(0), + new ConstantIntegerType(1), + ], [ + new ObjectWithoutClassType(), + new StringType(), + ], unsealed: $sealed), + TrinaryLogic::createMaybe(), + ]; + + yield [ + new ConstantArrayType([ + new ConstantIntegerType(0), + new ConstantIntegerType(1), + ], [ + new ObjectWithoutClassType(), + new StringType(), + ], unsealed: [new IntegerType(), new StringType()]), + TrinaryLogic::createMaybe(), + ]; + + yield [ + new ConstantArrayType([ + new ConstantIntegerType(0), + new ConstantIntegerType(1), + ], [ + new GenericClassStringType(new ObjectType(Closure::class)), + new ConstantStringType('bind'), + ], unsealed: [new IntegerType(), new StringType()]), + TrinaryLogic::createMaybe(), // extra keys would void the callable-ness + ]; + + yield [ + new ConstantArrayType([ + new ConstantIntegerType(0) + ], [ + new ObjectWithoutClassType(), + ], unsealed: [new IntegerType(), new StringType()]), + TrinaryLogic::createMaybe(), + ]; + + yield [ + new ConstantArrayType([ + new ConstantIntegerType(0) + ], [ + new ObjectWithoutClassType(), + ], unsealed: $sealed), + TrinaryLogic::createNo(), + ]; + + yield [ + new ConstantArrayType([ + new ConstantIntegerType(0) + ], [ + new ObjectWithoutClassType(), + ], unsealed: [IntegerRangeType::createAllGreaterThanOrEqualTo(2), new StringType()]), + TrinaryLogic::createNo(), + ]; + + yield [ + new ConstantArrayType([ + new ConstantIntegerType(0) + ], [ + new ObjectWithoutClassType(), + ], unsealed: [new StringType(), new StringType()]), + TrinaryLogic::createNo(), + ]; + + // Only key 0 explicit, value at key 1 from unsealed can never be + // a non-falsy-string (int → not a string at all). + yield [ + new ConstantArrayType([ + new ConstantIntegerType(0) + ], [ + new ObjectWithoutClassType(), + ], unsealed: [new IntegerType(), new IntegerType()]), + TrinaryLogic::createNo(), + ]; + + // Only key 1 explicit, value at key 0 from unsealed must be + // object|class-string; int can never be that. + yield [ + new ConstantArrayType([ + new ConstantIntegerType(1) + ], [ + new ConstantStringType('bind'), + ], unsealed: [new IntegerType(), new IntegerType()]), + TrinaryLogic::createNo(), + ]; + + // Only key 1 explicit, value at key 0 from unsealed is a plain + // string — `string ∩ (object|class-string) = class-string`, so + // it could line up. + yield [ + new ConstantArrayType([ + new ConstantIntegerType(1) + ], [ + new ConstantStringType('bind'), + ], unsealed: [new IntegerType(), new StringType()]), + TrinaryLogic::createMaybe(), + ]; + + // Sealed three-element array is never a callable (callable + // shape has exactly two slots). + yield [ + new ConstantArrayType([ + new ConstantIntegerType(0), + new ConstantIntegerType(1), + new ConstantIntegerType(2), + ], [ + new GenericClassStringType(new ObjectType(Closure::class)), + new ConstantStringType('bind'), + new ConstantStringType('extra'), + ]), + TrinaryLogic::createNo(), + ]; + + // Sealed two-element array with a stray non-callable key + // position is never a callable. + yield [ + new ConstantArrayType([ + new ConstantIntegerType(0), + new ConstantIntegerType(5), + ], [ + new GenericClassStringType(new ObjectType(Closure::class)), + new ConstantStringType('bind'), + ]), + TrinaryLogic::createNo(), + ]; + + // Fully open `array{...}`: callable iff actual + // extras happen to land on `[0 => object|class-string, + // 1 => non-falsy-string]` — uncertain by construction. + yield [ + new ConstantArrayType([], [], unsealed: [new MixedType(), new MixedType()]), + TrinaryLogic::createMaybe(), + ]; + + // Empty value, no explicit keys, sealed → empty array → No. + // (Already covered by the 'zero items' case above; included here + // as a foil for the open-shape variant.) } public static function dataValuesArray(): iterable From 71efd0d354613521d15cd7badc5913c35a481863 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Mon, 18 May 2026 18:44:48 +0200 Subject: [PATCH 40/66] isConstantValue --- src/Type/Constant/ConstantArrayType.php | 4 ++++ src/Type/VerbosityLevel.php | 6 +++++- .../Type/Constant/ConstantArrayTypeTest.php | 14 +++++++------- 3 files changed, 16 insertions(+), 8 deletions(-) diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index bf33b77a98f..c4e744e7df7 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -332,6 +332,10 @@ public function getItemType(): Type public function isConstantValue(): TrinaryLogic { + if ($this->isUnsealed()->yes()) { + return TrinaryLogic::createNo(); + } + return TrinaryLogic::createYes(); } diff --git a/src/Type/VerbosityLevel.php b/src/Type/VerbosityLevel.php index 73513641eef..bf488ee83ad 100644 --- a/src/Type/VerbosityLevel.php +++ b/src/Type/VerbosityLevel.php @@ -141,10 +141,14 @@ public static function getRecommendedLevelByType(Type $acceptingType, ?Type $acc // Keep checking if we need to be very verbose. return $traverse($type); } - if ($type->isConstantValue()->yes() && $type->isNull()->no()) { + if ($type->isConstantArray()->yes()) { $moreVerbose = true; // For ConstantArrayType we need to keep checking if we need to be very verbose. + return $traverse($type); + } + if ($type->isConstantValue()->yes() && $type->isNull()->no()) { + $moreVerbose = true; if (!$type->isArray()->no()) { return $traverse($type); } diff --git a/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php b/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php index 2bd1a6452ad..c23fde3f6bd 100644 --- a/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php +++ b/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php @@ -1272,7 +1272,7 @@ public static function dataIsCallable(): iterable yield [ new ConstantArrayType([ - new ConstantIntegerType(0) + new ConstantIntegerType(0), ], [ new ObjectWithoutClassType(), ], unsealed: [new IntegerType(), new StringType()]), @@ -1281,7 +1281,7 @@ public static function dataIsCallable(): iterable yield [ new ConstantArrayType([ - new ConstantIntegerType(0) + new ConstantIntegerType(0), ], [ new ObjectWithoutClassType(), ], unsealed: $sealed), @@ -1290,7 +1290,7 @@ public static function dataIsCallable(): iterable yield [ new ConstantArrayType([ - new ConstantIntegerType(0) + new ConstantIntegerType(0), ], [ new ObjectWithoutClassType(), ], unsealed: [IntegerRangeType::createAllGreaterThanOrEqualTo(2), new StringType()]), @@ -1299,7 +1299,7 @@ public static function dataIsCallable(): iterable yield [ new ConstantArrayType([ - new ConstantIntegerType(0) + new ConstantIntegerType(0), ], [ new ObjectWithoutClassType(), ], unsealed: [new StringType(), new StringType()]), @@ -1310,7 +1310,7 @@ public static function dataIsCallable(): iterable // a non-falsy-string (int → not a string at all). yield [ new ConstantArrayType([ - new ConstantIntegerType(0) + new ConstantIntegerType(0), ], [ new ObjectWithoutClassType(), ], unsealed: [new IntegerType(), new IntegerType()]), @@ -1321,7 +1321,7 @@ public static function dataIsCallable(): iterable // object|class-string; int can never be that. yield [ new ConstantArrayType([ - new ConstantIntegerType(1) + new ConstantIntegerType(1), ], [ new ConstantStringType('bind'), ], unsealed: [new IntegerType(), new IntegerType()]), @@ -1333,7 +1333,7 @@ public static function dataIsCallable(): iterable // it could line up. yield [ new ConstantArrayType([ - new ConstantIntegerType(1) + new ConstantIntegerType(1), ], [ new ConstantStringType('bind'), ], unsealed: [new IntegerType(), new StringType()]), From 65917b68bdde854a5dc09b569070403199630826 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 19 May 2026 10:13:45 +0200 Subject: [PATCH 41/66] More ConstantArrayType methods --- src/Type/Constant/ConstantArrayType.php | 28 +++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index c4e744e7df7..e601ee7dab2 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -1967,6 +1967,16 @@ public function getFirstIterableKeyType(): Type } } + if ($this->isUnsealed()->yes() && $this->unsealed !== null) { + $unsealedKeyType = $this->unsealed[0]; + if ($unsealedKeyType instanceof MixedType && !$unsealedKeyType instanceof TemplateMixedType) { + $unsealedKeyType = (new BenevolentUnionType([new IntegerType(), new StringType()]))->toArrayKey(); + } elseif ($unsealedKeyType instanceof StrictMixedType && !$unsealedKeyType instanceof TemplateStrictMixedType) { + $unsealedKeyType = (new BenevolentUnionType([new IntegerType(), new StringType()]))->toArrayKey(); + } + $keyTypes[] = $unsealedKeyType; + } + return TypeCombinator::union(...$keyTypes); } @@ -1980,6 +1990,16 @@ public function getLastIterableKeyType(): Type } } + if ($this->isUnsealed()->yes() && $this->unsealed !== null) { + $unsealedKeyType = $this->unsealed[0]; + if ($unsealedKeyType instanceof MixedType && !$unsealedKeyType instanceof TemplateMixedType) { + $unsealedKeyType = (new BenevolentUnionType([new IntegerType(), new StringType()]))->toArrayKey(); + } elseif ($unsealedKeyType instanceof StrictMixedType && !$unsealedKeyType instanceof TemplateStrictMixedType) { + $unsealedKeyType = (new BenevolentUnionType([new IntegerType(), new StringType()]))->toArrayKey(); + } + $keyTypes[] = $unsealedKeyType; + } + return TypeCombinator::union(...$keyTypes); } @@ -1993,6 +2013,10 @@ public function getFirstIterableValueType(): Type } } + if ($this->isUnsealed()->yes() && $this->unsealed !== null) { + $valueTypes[] = $this->unsealed[1]; + } + return TypeCombinator::union(...$valueTypes); } @@ -2006,6 +2030,10 @@ public function getLastIterableValueType(): Type } } + if ($this->isUnsealed()->yes() && $this->unsealed !== null) { + $valueTypes[] = $this->unsealed[1]; + } + return TypeCombinator::union(...$valueTypes); } From 8202c48cfad59c1eb358c75f39a63e4d0cb65ca3 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 19 May 2026 10:20:42 +0200 Subject: [PATCH 42/66] Cover `ConstantArrayType::getFiniteTypes()` with a data provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Twelve cases pinning the current behaviour: empty array, single and multi-key all-finite shapes, union-value cartesian-product expansion, `bool` → `true|false` fork, non-finite value short-circuiting the whole result to `[]`, mixed finite + non-finite, single optional key forking with/without, all-optional, optional + union combined, and the >128 cartesian-product bail-out via `CALCULATE_SCALARS_LIMIT`. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Type/Constant/ConstantArrayType.php | 4 + .../Type/Constant/ConstantArrayTypeTest.php | 216 ++++++++++++++++++ 2 files changed, 220 insertions(+) diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index e601ee7dab2..0e55c8069f6 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -3183,6 +3183,10 @@ public static function isValidIdentifier(string $value): bool public function getFiniteTypes(): array { + if ($this->isUnsealed()->yes()) { + return []; + } + $limit = InitializerExprTypeResolver::CALCULATE_SCALARS_LIMIT; // Build finite array types incrementally, processing one key at a time. diff --git a/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php b/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php index c23fde3f6bd..f83711027bf 100644 --- a/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php +++ b/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php @@ -10,6 +10,7 @@ use PHPStan\Type\Accessory\HasOffsetType; use PHPStan\Type\Accessory\HasOffsetValueType; use PHPStan\Type\ArrayType; +use PHPStan\Type\BooleanType; use PHPStan\Type\CallableType; use PHPStan\Type\ClassStringType; use PHPStan\Type\Generic\GenericClassStringType; @@ -1643,4 +1644,219 @@ public function testGetArraySize(Type $constantArray, Type $expectedSize): void $this->assertSame($expectedSize->describe(VerbosityLevel::precise()), $constantArray->getArraySize()->describe(VerbosityLevel::precise())); } + public static function dataGetFiniteTypes(): iterable + { + yield 'empty array' => [ + new ConstantArrayType([], []), + ['array{}'], + ]; + + yield 'single key with single finite value' => [ + new ConstantArrayType( + [new ConstantStringType('a')], + [new ConstantStringType('foo')], + ), + ["array{a: 'foo'}"], + ]; + + yield 'multiple finite-only values' => [ + new ConstantArrayType( + [ + new ConstantStringType('a'), + new ConstantStringType('b'), + ], + [ + new ConstantIntegerType(1), + new ConstantStringType('foo'), + ], + ), + ["array{a: 1, b: 'foo'}"], + ]; + + yield 'union value expands to cartesian product' => [ + new ConstantArrayType( + [new ConstantStringType('a')], + [new UnionType([ + new ConstantIntegerType(1), + new ConstantIntegerType(2), + ])], + ), + ['array{a: 1}', 'array{a: 2}'], + ]; + + yield 'two union values expand to full cartesian product' => [ + new ConstantArrayType( + [ + new ConstantStringType('a'), + new ConstantStringType('b'), + ], + [ + new UnionType([ + new ConstantIntegerType(1), + new ConstantIntegerType(2), + ]), + new UnionType([ + new ConstantStringType('x'), + new ConstantStringType('y'), + ]), + ], + ), + [ + "array{a: 1, b: 'x'}", + "array{a: 1, b: 'y'}", + "array{a: 2, b: 'x'}", + "array{a: 2, b: 'y'}", + ], + ]; + + yield 'bool value expands to true/false' => [ + new ConstantArrayType( + [new ConstantStringType('flag')], + [new BooleanType()], + ), + ['array{flag: true}', 'array{flag: false}'], + ]; + + yield 'non-finite value yields no finite types' => [ + new ConstantArrayType( + [new ConstantStringType('a')], + [new IntegerType()], + ), + [], + ]; + + yield 'mixed finite and non-finite values yield no finite types' => [ + new ConstantArrayType( + [ + new ConstantStringType('a'), + new ConstantStringType('b'), + ], + [ + new ConstantIntegerType(1), + new IntegerType(), + ], + ), + [], + ]; + + yield 'optional key forks with-without' => [ + new ConstantArrayType( + [ + new ConstantStringType('a'), + new ConstantStringType('b'), + ], + [ + new ConstantIntegerType(1), + new ConstantStringType('foo'), + ], + [0], + [0], + ), + [ + "array{b: 'foo'}", + "array{a: 1, b: 'foo'}", + ], + ]; + + yield 'all optional keys' => [ + new ConstantArrayType( + [ + new ConstantStringType('a'), + new ConstantStringType('b'), + ], + [ + new ConstantIntegerType(1), + new ConstantStringType('foo'), + ], + [0], + [0, 1], + ), + [ + 'array{}', + "array{b: 'foo'}", + 'array{a: 1}', + "array{a: 1, b: 'foo'}", + ], + ]; + + yield 'optional key combined with union value' => [ + new ConstantArrayType( + [ + new ConstantStringType('a'), + new ConstantStringType('b'), + ], + [ + new UnionType([ + new ConstantIntegerType(1), + new ConstantIntegerType(2), + ]), + new ConstantStringType('foo'), + ], + [2], + [0], + ), + [ + "array{b: 'foo'}", + "array{a: 1, b: 'foo'}", + "array{a: 2, b: 'foo'}", + ], + ]; + + yield 'exceeding CALCULATE_SCALARS_LIMIT bails out' => [ + (static function (): ConstantArrayType { + $keyTypes = []; + $valueTypes = []; + // 8 keys × 2 = 256 combinations, well above the 128 limit. + for ($i = 0; $i < 8; $i++) { + $keyTypes[] = new ConstantIntegerType($i); + $valueTypes[] = new UnionType([ + new ConstantStringType('a'), + new ConstantStringType('b'), + ]); + } + return new ConstantArrayType($keyTypes, $valueTypes); + })(), + [], + ]; + + $never = new NeverType(true); + $sealed = [$never, $never]; + + yield 'sealed is finite' => [ + new ConstantArrayType( + [new ConstantStringType('a')], + [new ConstantStringType('foo')], + unsealed: $sealed, + ), + ["array{a: 'foo'}"], + ]; + + yield 'unsealed is finite' => [ + new ConstantArrayType( + [new ConstantStringType('a')], + [new ConstantStringType('foo')], + unsealed: [new IntegerType(), new StringType()], + ), + [], + ]; + } + + /** + * @param list $expectedDescriptions + */ + #[DataProvider('dataGetFiniteTypes')] + public function testGetFiniteTypes(ConstantArrayType $type, array $expectedDescriptions): void + { + $actual = array_map( + static fn (Type $finite): string => $finite->describe(VerbosityLevel::precise()), + $type->getFiniteTypes(), + ); + + $this->assertSame( + $expectedDescriptions, + $actual, + sprintf('%s -> getFiniteTypes()', $type->describe(VerbosityLevel::precise())), + ); + } + } From ccb668b4e3fd350a5ef35057e858ff29609b8d39 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 19 May 2026 10:30:08 +0200 Subject: [PATCH 43/66] hasTemplateOrLateResolvableType --- src/Type/Constant/ConstantArrayType.php | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index 0e55c8069f6..812926d3391 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -3247,6 +3247,15 @@ public function hasTemplateOrLateResolvableType(): bool return true; } + if ($this->unsealed !== null) { + if ($this->unsealed[0]->hasTemplateOrLateResolvableType()) { + return true; + } + if ($this->unsealed[1]->hasTemplateOrLateResolvableType()) { + return true; + } + } + return false; } From 9ee0108f819485441997364afadb60c83bda3bf8 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 19 May 2026 13:35:18 +0200 Subject: [PATCH 44/66] Carry unsealed slot through `ConstantArrayType::flipArray()` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `array_flip` swaps keys and values pair-by-pair. For an unsealed source `array{a: 'foo', b: 'bar', ...}`, the explicit entries flip as before and the unsealed slot becomes `` — `unsealed[1]->toArrayKey()` lands in the new key slot, `unsealed[0]` in the new value slot. Sealed stays sealed. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Type/Constant/ConstantArrayType.php | 5 ++++ .../Analyser/nsrt/unsealed-derivations.php | 24 +++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index 812926d3391..fb44d7f054f 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -1496,6 +1496,11 @@ public function flipArray(): Type ); } + if ($this->isUnsealed()->yes() && $this->unsealed !== null) { + [$unsealedKey, $unsealedValue] = $this->unsealed; + $builder->makeUnsealed($unsealedValue->toArrayKey(), $unsealedKey); + } + return $builder->getArray(); } diff --git a/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php b/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php index efdecc0030e..87220395415 100644 --- a/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php +++ b/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php @@ -281,6 +281,30 @@ public function unshiftAssocWithUnpack(array $sealed, array $unknownItems): void } +class FlipArray +{ + + /** + * @param array{a: 'foo', b: 'bar', ...} $arr + */ + public function flipPreservesUnsealed(array $arr): void + { + // `array_flip` swaps keys and values pair-by-pair, so the + // unsealed `` becomes `` — the value + // type passes through `toArrayKey()` to land in the new key slot. + assertType("array{foo: 'a', bar: 'b', ...}", array_flip($arr)); + } + + /** + * @param array{a: 1, b: 2} $sealed + */ + public function flipSealedStaysSealed(array $sealed): void + { + assertType("array{1: 'a', 2: 'b'}", array_flip($sealed)); + } + +} + class CountNarrowing { From 805bdb8047643efcbb408400631dd5312ea96b21 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 19 May 2026 13:36:53 +0200 Subject: [PATCH 45/66] Carry unsealed slot through `ConstantArrayType::fillKeysArray()` `array_fill_keys` uses the source's *values* as result keys (after `toArrayKey()`), filling every value slot with `\$valueType`. On an unsealed source, the unsealed value type becomes the unsealed key type of the result, and `\$valueType` fills the new unsealed value slot. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Type/Constant/ConstantArrayType.php | 5 +++++ .../Analyser/nsrt/unsealed-derivations.php | 18 ++++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index fb44d7f054f..c97a1562b39 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -1480,6 +1480,11 @@ public function fillKeysArray(Type $valueType): Type } } + if ($this->isUnsealed()->yes() && $this->unsealed !== null) { + [, $unsealedValue] = $this->unsealed; + $builder->makeUnsealed($unsealedValue->toArrayKey(), $valueType); + } + return $builder->getArray(); } diff --git a/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php b/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php index 87220395415..8aca74d3c9a 100644 --- a/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php +++ b/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php @@ -305,6 +305,24 @@ public function flipSealedStaysSealed(array $sealed): void } +class FillKeysArray +{ + + /** + * @param array{a: 'foo', b: 'bar', ...} $arr + */ + public function fillKeysPreservesUnsealed(array $arr): void + { + // `array_fill_keys` uses the source's *values* as the result's + // keys (with `toArrayKey()` applied). Explicit `'foo'`, `'bar'` + // become keys; the unsealed `` contributes string + // values that become the result's unsealed key range — the new + // unsealed entry is `` with the fill value `42`. + assertType('array{foo: 42, bar: 42, ...}', array_fill_keys($arr, 42)); + } + +} + class CountNarrowing { From ffb3adfd4507c3f02b03054580b9ef75019a536b Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 19 May 2026 13:38:28 +0200 Subject: [PATCH 46/66] Carry unsealed slot through `ConstantArrayType::intersectKeyArray()` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `array_intersect_key(\$this, \$other)` keeps entries from `\$this` whose keys appear in `\$other`. The unsealed extras in `\$this` survive only at keys that `\$other` can also have — narrow the unsealed key type to the intersection of `\$this->unsealed[0]` and `\$other->getIterableKeyType()`. If the intersection is `Never`, drop the unsealed slot entirely. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Type/Constant/ConstantArrayType.php | 12 +++++++ .../Analyser/nsrt/unsealed-derivations.php | 35 +++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index c97a1562b39..3f70437425a 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -1522,6 +1522,18 @@ public function intersectKeyArray(Type $otherArraysType): Type $builder->setOffsetValueType($keyType, $valueType, $this->isOptionalKey($i) || !$has->yes()); } + if ($this->isUnsealed()->yes() && $this->unsealed !== null) { + [$unsealedKey, $unsealedValue] = $this->unsealed; + // An unsealed extra at key K survives only if `$other` can + // also have key K. Narrow the unsealed key to the intersection + // of our extras-range and `$other`'s key type. If they don't + // overlap, the unsealed slot is dropped. + $narrowedKey = TypeCombinator::intersect($unsealedKey, $otherArraysType->getIterableKeyType()); + if (!$narrowedKey instanceof NeverType) { + $builder->makeUnsealed($narrowedKey, $unsealedValue); + } + } + return $builder->getArray(); } diff --git a/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php b/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php index 8aca74d3c9a..d92bc459475 100644 --- a/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php +++ b/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php @@ -323,6 +323,41 @@ public function fillKeysPreservesUnsealed(array $arr): void } +class IntersectKeyArray +{ + + /** + * @param array{a: 1, b: 2, ...} $arr + * @param array $other + */ + public function intersectWithStringKeys(array $arr, array $other): void + { + // `array_intersect_key` keeps entries from `$arr` whose key is + // also a key of `$other`. The explicit `a`/`b` survive as + // optional (we don't know that `$other` has them). The unsealed + // `` range intersects with `` on + // the key side — `int ∩ string` is empty — so the unsealed slot + // disappears entirely. + assertType('array{a?: 1, b?: 2}', array_intersect_key($arr, $other)); + } + + /** + * @param array{a: 1, b: 2, ...} $arr + * @param array $other + */ + public function intersectWithIntKeys(array $arr, array $other): void + { + // `$other`'s int keys can match `$arr`'s unsealed int range, + // so the unsealed slot survives but its key narrows to the + // intersection (`int`). The explicit `a`/`b` are dropped — they + // are string keys, and `$other`'s key type is int. With no + // explicit keys left, the builder collapses the result to a + // plain `array`. + assertType('array', array_intersect_key($arr, $other)); + } + +} + class CountNarrowing { From 80eadc5db70272fbe73d7ff9a7f2e06d9ca7608b Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 19 May 2026 13:39:34 +0200 Subject: [PATCH 47/66] Carry unsealed slot through `ConstantArrayType::reverseArray()` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `array_reverse` only permutes element positions. The unsealed slot describes "zero or more extras at unspecified positions" — that property is unchanged by reversal, so pass `\$this->unsealed` through to the result. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Type/Constant/ConstantArrayType.php | 8 ++++++++ .../Analyser/nsrt/unsealed-derivations.php | 16 ++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index 3f70437425a..a3861078e3e 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -1553,6 +1553,14 @@ public function reverseArray(TrinaryLogic $preserveKeys): Type $builder->setOffsetValueType($offsetType, $this->valueTypes[$i], $this->isOptionalKey($i)); } + if ($this->isUnsealed()->yes() && $this->unsealed !== null) { + // `array_reverse` only permutes positions; the unsealed slot + // is "zero or more extras at unspecified positions" both + // before and after. + [$unsealedKey, $unsealedValue] = $this->unsealed; + $builder->makeUnsealed($unsealedKey, $unsealedValue); + } + return $builder->getArray(); } diff --git a/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php b/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php index d92bc459475..e86c79f44ee 100644 --- a/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php +++ b/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php @@ -358,6 +358,22 @@ public function intersectWithIntKeys(array $arr, array $other): void } +class ReverseArray +{ + + /** + * @param array{a: 1, b: 2, ...} $arr + */ + public function reversePreservesUnsealed(array $arr): void + { + // `array_reverse` only changes element order; the unsealed slot + // describes "zero or more extras at unspecified positions" — the + // reversed value has the same property. + assertType('array{b: 2, a: 1, ...}', array_reverse($arr)); + } + +} + class CountNarrowing { From b2fa4e3896d62b136024a0ece03fe2421f90a7ac Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 19 May 2026 13:41:31 +0200 Subject: [PATCH 48/66] Visit unsealed value in `ConstantArrayType::traverseSimultaneously()` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `traverse()` already invokes the callback on the unsealed value type (commit history c. \`traverse\` unsealed support). Its \`traverseSimultaneously\` sibling skipped that step — only the explicit \`valueTypes\` were paired with \`\$right->getOffsetValueType()\`, leaving the unsealed value untransformed. Pair the unsealed value with \`\$right->getIterableValueType()\` and thread the result back into the new \`unsealed\` tuple, mirroring how \`traverse()\` handles it. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Type/Constant/ConstantArrayType.php | 12 ++++++- .../Type/Constant/ConstantArrayTypeTest.php | 36 +++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index a3861078e3e..929ac7a53e5 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -2569,11 +2569,21 @@ public function traverseSimultaneously(Type $right, callable $cb): Type $valueTypes[] = $transformedValueType; } + $unsealed = $this->unsealed; + if ($unsealed !== null) { + [$unsealedKeyType, $unsealedValueType] = $unsealed; + $transformedUnsealedValueType = $cb($unsealedValueType, $right->getIterableValueType()); + if ($transformedUnsealedValueType !== $unsealedValueType) { + $stillOriginal = false; + $unsealed = [$unsealedKeyType, $transformedUnsealedValueType]; + } + } + if ($stillOriginal) { return $this; } - return $this->recreate($this->keyTypes, $valueTypes, $this->nextAutoIndexes, $this->optionalKeys, $this->isList, $this->unsealed); + return $this->recreate($this->keyTypes, $valueTypes, $this->nextAutoIndexes, $this->optionalKeys, $this->isList, $unsealed); } public function isKeysSupersetOf(self $otherArray): bool diff --git a/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php b/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php index f83711027bf..f3079eded36 100644 --- a/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php +++ b/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php @@ -1859,4 +1859,40 @@ public function testGetFiniteTypes(ConstantArrayType $type, array $expectedDescr ); } + public function testTraverseSimultaneouslyVisitsUnsealedValue(): void + { + $left = new ConstantArrayType( + [new ConstantStringType('a')], + [new IntegerType()], + unsealed: [new IntegerType(), new IntegerType()], + ); + $right = new ConstantArrayType( + [new ConstantStringType('a')], + [new StringType()], + unsealed: [new IntegerType(), new StringType()], + ); + + $visited = []; + $result = $left->traverseSimultaneously($right, static function (Type $l, Type $r) use (&$visited): Type { + $visited[] = [ + $l->describe(VerbosityLevel::precise()), + $r->describe(VerbosityLevel::precise()), + ]; + return new MixedType(); + }); + + $this->assertSame( + [ + ['int', 'string'], + ['int', 'string'], + ], + $visited, + ); + + $this->assertSame( + 'array{a: mixed, ...}', + $result->describe(VerbosityLevel::precise()), + ); + } + } From c919101da7b235e6d331e879c0a7e355b4bafdc9 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 19 May 2026 13:43:20 +0200 Subject: [PATCH 49/66] Make `popArray` / `removeLastElements` unsealed-aware MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit \`array_pop\` removes the last element. On a sealed array the last explicit key is the one that disappears, but with real unsealed extras on the source the popped element might come from the unsealed range instead — the actual value might have zero extras (so a trailing explicit key is popped) or one+ extras (so an extra is popped, leaving the explicit keys intact). Encode the union: when the source is unsealed, the trailing \`\$length\` explicit keys become optional and the unsealed slot is preserved. Sealed inputs keep the previous "hard-remove last keys" behaviour. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Type/Constant/ConstantArrayType.php | 28 +++++++++++++++++++ .../Analyser/nsrt/unsealed-derivations.php | 19 +++++++++++++ 2 files changed, 47 insertions(+) diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index 929ac7a53e5..d330a9312f9 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -80,6 +80,7 @@ use function in_array; use function is_int; use function is_string; +use function max; use function min; use function pow; use function range; @@ -2085,6 +2086,33 @@ private function removeLastElements(int $length): self return $this; } + // With real unsealed extras on the source, the elements being + // "removed" might come from the unsealed range rather than from + // the trailing explicit keys — the array might have zero extras + // (so the trailing explicit keys are popped) or one+ extras (so + // they're popped instead, leaving the explicit keys intact). + // Encode this by marking the trailing keys as optional and + // keeping the unsealed slot in place. + if ($this->isUnsealed()->yes()) { + $optionalKeys = $this->optionalKeys; + $newLength = $keyTypesCount - $length; + for ($i = $keyTypesCount - 1; $i >= max($newLength, 0); $i--) { + if (in_array($i, $optionalKeys, true)) { + continue; + } + $optionalKeys[] = $i; + } + + return $this->recreate( + $this->keyTypes, + $this->valueTypes, + $this->nextAutoIndexes, + array_values($optionalKeys), + $this->isList, + $this->unsealed, + ); + } + $keyTypes = $this->keyTypes; $valueTypes = $this->valueTypes; $optionalKeys = $this->optionalKeys; diff --git a/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php b/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php index e86c79f44ee..277eb41fcf9 100644 --- a/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php +++ b/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php @@ -374,6 +374,25 @@ public function reversePreservesUnsealed(array $arr): void } +class PopArray +{ + + /** + * @param array{a: 1, b: 2, ...} $arr + */ + public function popMakesLastKeyOptional(array $arr): void + { + array_pop($arr); + // `array_pop` removes the last element. With unsealed extras, the + // last element might be one of those extras (the unsealed slot + // silently shrinks by one) or — if no extras existed — the last + // explicit key. So the last explicit key becomes optional and + // the unsealed slot is preserved (still "zero or more"). + assertType('array{a: 1, b?: 2, ...}', $arr); + } + +} + class CountNarrowing { From 8c8917693cf6814b7eb32a2770034b3f6f53be87 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 19 May 2026 13:44:36 +0200 Subject: [PATCH 50/66] Carry unsealed slot through `shiftArray` / `removeFirstElements` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit \`array_shift\` removes the first element. With explicit keys present on the source, the shifted element is always one of them — the unsealed extras live "after" the explicit keys in insertion order and are untouched by the operation. Pass \`\$this->unsealed\` into the builder so the result keeps its zero-or-more extras. (Re-indexing of int keys doesn't change the unsealed range — a \`\` slot stays \`\`.) Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Type/Constant/ConstantArrayType.php | 10 ++++++++++ .../Analyser/nsrt/unsealed-derivations.php | 18 ++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index d330a9312f9..df838abc139 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -2188,6 +2188,16 @@ private function removeFirstElements(int $length, bool $reindex = true): Type $builder->setOffsetValueType($keyType, $valueType, $isOptional); } + if ($this->isUnsealed()->yes() && $this->unsealed !== null) { + // `array_shift` removes the *first* element. The explicit + // keys precede the unsealed extras in insertion order, so + // the shift always lands on an explicit key (when there is + // one); the unsealed slot is unaffected. Re-indexing of int + // keys doesn't change the unsealed range — it stays ``. + [$unsealedKey, $unsealedValue] = $this->unsealed; + $builder->makeUnsealed($unsealedKey, $unsealedValue); + } + return $builder->getArray(); } diff --git a/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php b/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php index 277eb41fcf9..e35313bfbba 100644 --- a/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php +++ b/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php @@ -393,6 +393,24 @@ public function popMakesLastKeyOptional(array $arr): void } +class ShiftArray +{ + + /** + * @param array{a: 1, b: 2, ...} $arr + */ + public function shiftPreservesUnsealed(array $arr): void + { + array_shift($arr); + // `array_shift` removes the first element. With explicit keys in + // place, that's always the leading explicit key (`a`). The + // unsealed extras live "after" the explicit ones and are + // preserved. + assertType('array{b: 2, ...}', $arr); + } + +} + class CountNarrowing { From fd44d98b2cf5104068c96acf0a61b7d31fd9437c Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 19 May 2026 13:56:17 +0200 Subject: [PATCH 51/66] Carry unsealed slot through `getKeysArray` / `getValuesArray` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `array_keys($source)` / `array_values($source)` produce a list whose explicit values come from `$source`'s keys/values. With real unsealed extras on `$source`, the source's unsealed *key* type becomes the new unsealed value for `array_keys`, and the source's unsealed *value* type becomes the new unsealed value for `array_values`. The new unsealed key range is `int<0, max>` — the conventional shape for list-style extras, which also collapses the describe output to the `` short form. Previously the shared helper passed `$this->unsealed` through verbatim (the two `// todo unsealed` markers); for an unsealed source that produced a result with the *source's* unsealed key/value in the *result's* unsealed slot — semantically nonsense. Pass an explicit `$unsealedSourceType` so each caller injects the right projection. `getKeysArrayFiltered` flows through the same helper and benefits implicitly: its `getIterableValueType()` now includes the right unsealed value contribution. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Type/Constant/ConstantArrayType.php | 24 +++++++++--- .../Analyser/nsrt/unsealed-derivations.php | 39 +++++++++++++++++++ 2 files changed, 57 insertions(+), 6 deletions(-) diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index df838abc139..30b39766725 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -2278,7 +2278,7 @@ private function degradeToGeneralArray(): Type public function getKeysArrayFiltered(Type $filterValueType, TrinaryLogic $strict): Type { - $keysArray = $this->getKeysOrValuesArray($this->keyTypes); + $keysArray = $this->getKeysOrValuesArray($this->keyTypes, $this->unsealed[0] ?? null); return new IntersectionType([ new ArrayType( @@ -2291,29 +2291,41 @@ public function getKeysArrayFiltered(Type $filterValueType, TrinaryLogic $strict public function getKeysArray(): self { - return $this->getKeysOrValuesArray($this->keyTypes); + return $this->getKeysOrValuesArray($this->keyTypes, $this->unsealed[0] ?? null); } public function getValuesArray(): self { - return $this->getKeysOrValuesArray($this->valueTypes); + return $this->getKeysOrValuesArray($this->valueTypes, $this->unsealed[1] ?? null); } /** * @param array $types */ - private function getKeysOrValuesArray(array $types): self + private function getKeysOrValuesArray(array $types, ?Type $unsealedSourceType): self { $count = count($types); $autoIndexes = range($count - count($this->optionalKeys), $count); + // The result is always a list — the source's keys/values are + // numbered sequentially. The new unsealed slot (if the source + // has real extras) describes "zero or more extras at int + // positions >= 0 whose values are the source's unsealed + // key/value type". `int<0, max>` is the conventional unsealed + // key for list-shaped extras; it also enables the short-form + // `` describe. + $resultUnsealed = null; + if ($this->isUnsealed()->yes() && $unsealedSourceType !== null) { + $resultUnsealed = [IntegerRangeType::createAllGreaterThanOrEqualTo(0), $unsealedSourceType]; + } + if ($this->isList->yes()) { // Optimized version for lists: Assume that if a later key exists, then earlier keys also exist. $keyTypes = array_map( static fn (int $i): ConstantIntegerType => new ConstantIntegerType($i), array_keys($types), ); - return $this->recreate($keyTypes, $types, $autoIndexes, $this->optionalKeys, TrinaryLogic::createYes(), $this->unsealed); // todo unsealed + return $this->recreate($keyTypes, $types, $autoIndexes, $this->optionalKeys, TrinaryLogic::createYes(), $resultUnsealed); } $keyTypes = []; @@ -2342,7 +2354,7 @@ private function getKeysOrValuesArray(array $types): self $maxIndex++; } - return $this->recreate($keyTypes, $valueTypes, $autoIndexes, $optionalKeys, TrinaryLogic::createYes(), $this->unsealed); // todo unsealed + return $this->recreate($keyTypes, $valueTypes, $autoIndexes, $optionalKeys, TrinaryLogic::createYes(), $resultUnsealed); } public function describe(VerbosityLevel $level): string diff --git a/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php b/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php index e35313bfbba..2c16126be77 100644 --- a/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php +++ b/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php @@ -411,6 +411,45 @@ public function shiftPreservesUnsealed(array $arr): void } +class ArrayKeysValues +{ + + /** + * @param array{a: 1, b: 2, ...} $arr + */ + public function keysFromUnsealed(array $arr): void + { + // `array_keys` returns a list of the source's keys. Explicit + // keys land in the result's value slots; the source's unsealed + // *key* type fills the new unsealed value slot. The result is + // list-shaped, so its unsealed key range is `int<0, max>` (the + // describe collapses that to the `` short form). + assertType("array{'a', 'b', ...}", array_keys($arr)); + } + + /** + * @param array{a: 1, b: 2, ...} $arr + */ + public function valuesFromUnsealed(array $arr): void + { + // `array_values` returns a list of the source's values. The + // source's unsealed *value* type fills the new unsealed value + // slot. + assertType('array{1, 2, ...}', array_values($arr)); + } + + /** + * @param array{a: 1, b: 2, ...} $arr + */ + public function keysFromUnsealedWithStringKeys(array $arr): void + { + // Source's unsealed key type is `string`, so the result's + // unsealed values are strings. + assertType("array{'a', 'b', ...}", array_keys($arr)); + } + +} + class CountNarrowing { From 3e49a3ee8691e5a4f84fae26db79742bc1d57381 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 19 May 2026 13:58:14 +0200 Subject: [PATCH 52/66] Carry unsealed slot through `ConstantArrayType::sliceArray()` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `array_slice($arr, $offset, $length)` returns up to `$length` elements starting at `$offset`. When the slice fits within the source's explicit keys, the result is sealed as before. When the requested length runs past the explicit keys, the trailing slots could be filled by the source's unsealed extras (or be absent) — carry the unsealed slot through to the result so those potential extras are preserved. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Type/Constant/ConstantArrayType.php | 13 ++++++++++ .../Analyser/nsrt/unsealed-derivations.php | 26 +++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index 30b39766725..ae5f3bbe8b6 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -1731,6 +1731,19 @@ public function sliceArray(Type $offsetType, Type $lengthType, TrinaryLogic $pre $builder->setOffsetValueType($offsetType, $this->valueTypes[$i], $isOptional); } + // When the requested length runs past the explicit keys, the + // missing trailing slots could be filled by the source's + // unsealed extras (or be absent). Carry the unsealed slot + // through so the result still describes those potential extras. + if ( + $this->isUnsealed()->yes() + && $this->unsealed !== null + && $nonOptionalElementsCount < $length + ) { + [$unsealedKey, $unsealedValue] = $this->unsealed; + $builder->makeUnsealed($unsealedKey, $unsealedValue); + } + return $builder->getArray(); } diff --git a/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php b/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php index 2c16126be77..9882d6bbbf4 100644 --- a/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php +++ b/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php @@ -450,6 +450,32 @@ public function keysFromUnsealedWithStringKeys(array $arr): void } +class SliceArray +{ + + /** + * @param array{a: 1, b: 2, ...} $arr + */ + public function sliceWithinExplicit(array $arr): void + { + // Slice fits entirely within the explicit keys — the unsealed + // slot doesn't come into play and the result is sealed. + assertType("array{a: 1}", array_slice($arr, 0, 1)); + } + + /** + * @param array{a: 1, b: 2, ...} $arr + */ + public function sliceBeyondExplicit(array $arr): void + { + // Slice extends past the explicit keys: the trailing positions + // could be filled by unsealed extras (or be absent), so the + // result is unsealed and carries the source's extras slot. + assertType('array{a: 1, b: 2, ...}', array_slice($arr, 0, 5)); + } + +} + class CountNarrowing { From 6a43069dc48816650976523a0bb06c3e0a7e49e4 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 19 May 2026 14:00:23 +0200 Subject: [PATCH 53/66] Degrade `chunkArray` on unsealed sources to the general trait path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `array_chunk` with a constant length precisely enumerates the chunks for sealed CATs: each chunk is a known slice of the source. With real unsealed extras the source has an unknown number of trailing entries that could form additional partial or full chunks — the precise enumeration would lie about the chunk count. Fall back to `traitChunkArray`, which yields the general `non-empty-list` form. Less precise but correct. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Type/Constant/ConstantArrayType.php | 9 +++++++++ .../Analyser/nsrt/unsealed-derivations.php | 20 +++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index ae5f3bbe8b6..15928f3b8b1 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -1436,6 +1436,15 @@ private static function isListAfterUnset(array $newKeyTypes, array $newOptionalK public function chunkArray(Type $lengthType, TrinaryLogic $preserveKeys): Type { + // With real unsealed extras, we can't precisely enumerate the + // chunks — the source has an unknown number of extras that + // could form additional partial or full chunks. Fall back to + // the general `list>` shape produced by + // the trait, which is correct (just less precise). + if ($this->isUnsealed()->yes()) { + return $this->traitChunkArray($lengthType, $preserveKeys); + } + $biggerOne = IntegerRangeType::fromInterval(1, null); $finiteTypes = $lengthType->getFiniteTypes(); if ($biggerOne->isSuperTypeOf($lengthType)->yes() && count($finiteTypes) < self::CHUNK_FINITE_TYPES_LIMIT) { diff --git a/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php b/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php index 9882d6bbbf4..2e81c32d8b1 100644 --- a/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php +++ b/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php @@ -476,6 +476,26 @@ public function sliceBeyondExplicit(array $arr): void } +class ChunkArray +{ + + /** + * @param array{a: 1, b: 2, c: 3, d: 4, ...} $arr + */ + public function chunkPreservingKeys(array $arr): void + { + // With real unsealed extras the precise chunk count is unknown, + // so the type falls back to a general "non-empty list of chunks + // shaped like the source". Each chunk is described by the + // source's own shape (preserveKeys=true). + assertType( + 'non-empty-list}>', + array_chunk($arr, 2, true), + ); + } + +} + class CountNarrowing { From c726c2a8d0e048a4a68f7352aff501424ed3b192 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 19 May 2026 14:02:29 +0200 Subject: [PATCH 54/66] Include unsealed extras in `ConstantArrayTypeBuilder` degraded-array form MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ConstantArrayTypeBuilder::getArray()` in the degrade-to-general-array branch builds `new ArrayType(union($keyTypes), union($valueTypes))` from the explicit slots only — the unsealed extras' key/value contribution was silently dropped. Union the unsealed key/value into the degraded `ArrayType`'s key and value unions when the builder carries real extras. Fixes shuffleArray (and any other call site that round-trips through `degradeToGeneralArray()`) on unsealed sources, where the unsealed value type now reaches the final list/array form. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Type/Constant/ConstantArrayTypeBuilder.php | 15 ++++++++++++++- .../Analyser/nsrt/unsealed-derivations.php | 18 ++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/src/Type/Constant/ConstantArrayTypeBuilder.php b/src/Type/Constant/ConstantArrayTypeBuilder.php index c6f18b8dee8..e81a4d694eb 100644 --- a/src/Type/Constant/ConstantArrayTypeBuilder.php +++ b/src/Type/Constant/ConstantArrayTypeBuilder.php @@ -473,8 +473,21 @@ public function getArray(): Type $itemTypes = $this->valueTypes; } + $keyTypesForArray = $this->keyTypes; + // Real unsealed extras describe additional key/value pairs that + // belong in the degraded `ArrayType`'s key/value unions too — + // otherwise the degraded type silently drops them. + if ($this->unsealed !== null) { + [$unsealedKey, $unsealedValue] = $this->unsealed; + $isExplicitNever = $unsealedKey instanceof NeverType && $unsealedKey->isExplicit(); + if (!$isExplicitNever) { + $keyTypesForArray[] = $unsealedKey; + $itemTypes[] = $unsealedValue; + } + } + $array = new ArrayType( - TypeCombinator::union(...$this->keyTypes), + TypeCombinator::union(...$keyTypesForArray), TypeCombinator::union(...$itemTypes), ); diff --git a/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php b/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php index 2e81c32d8b1..950c81d3607 100644 --- a/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php +++ b/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php @@ -496,6 +496,24 @@ public function chunkPreservingKeys(array $arr): void } +class ShuffleArray +{ + + /** + * @param array{a: 1, b: 2, ...} $arr + */ + public function shufflePreservesUnsealedValues(array $arr): void + { + // `shuffle` reorders + reindexes. Through `getValuesArray()` + // the source's unsealed value type contributes to the result's + // value union — the final degraded list type includes `string` + // alongside the explicit values `1` and `2`. + shuffle($arr); + assertType('non-empty-list<1|2|string>', $arr); + } + +} + class CountNarrowing { From ea71a355d5afd11e44cd7fbb86d08ebe69dcf0db Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 19 May 2026 14:04:51 +0200 Subject: [PATCH 55/66] Carry unsealed slot through `ConstantArrayType::spliceArray()` `array_splice` removes a slice at an explicit offset and inserts a replacement at that position. Real unsealed extras live at positions past the explicit keys, so they're not affected by either operation; the re-indexing of int keys also leaves a `` unsealed range unchanged. Pass the source's unsealed tuple into the builder before producing the result for each replacement variant. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Type/Constant/ConstantArrayType.php | 10 ++++++++++ .../Analyser/nsrt/unsealed-derivations.php | 17 +++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index 15928f3b8b1..78b47442f9a 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -1857,6 +1857,16 @@ public function spliceArray(Type $offsetType, Type $lengthType, Type $replacemen ); } + // `array_splice` removes a slice at an explicit offset and + // inserts a replacement there. Real unsealed extras live at + // positions past the explicit keys, so they're unaffected + // by the operation (re-indexing of int keys keeps the + // `` range intact). Carry the slot through. + if ($this->isUnsealed()->yes() && $this->unsealed !== null) { + [$unsealedKey, $unsealedValue] = $this->unsealed; + $builder->makeUnsealed($unsealedKey, $unsealedValue); + } + $builtType = $builder->getArray(); if ($allKeysInteger && !$builtType->isList()->yes()) { $builtType = TypeCombinator::intersect($builtType, new AccessoryArrayListType()); diff --git a/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php b/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php index 950c81d3607..6b85cae6bcf 100644 --- a/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php +++ b/tests/PHPStan/Analyser/nsrt/unsealed-derivations.php @@ -514,6 +514,23 @@ public function shufflePreservesUnsealedValues(array $arr): void } +class SpliceArray +{ + + /** + * @param list{int, int, int, ...} $arr + */ + public function splicePreservesUnsealed(array $arr): void + { + array_splice($arr, 1, 1); + // `array_splice` removes a slice from an explicit position; + // the unsealed extras at the tail are unaffected and survive + // on the result. + assertType('array{int, int, ...}', $arr); + } + +} + class CountNarrowing { From 51b7794aed0c487912e03e26bab24928e15886c6 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 19 May 2026 14:26:38 +0200 Subject: [PATCH 56/66] Cover `ConstantArrayType::generalize()` with a data provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Twelve cases pinning the current behaviour: empty arrays (legacy null vs. bleeding-edge NeverType marker), sealed inputs at each precision (lessSpecific, moreSpecific, templateArgument), single and multi-key shapes, list shape, all-optional keys, and unsealed inputs — including the "no explicit keys + real extras" case where the current implementation returns `$this` unchanged instead of generalizing. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Type/Constant/ConstantArrayTypeTest.php | 130 ++++++++++++++++++ 1 file changed, 130 insertions(+) diff --git a/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php b/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php index f3079eded36..4dc1daf33d0 100644 --- a/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php +++ b/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php @@ -13,6 +13,7 @@ use PHPStan\Type\BooleanType; use PHPStan\Type\CallableType; use PHPStan\Type\ClassStringType; +use PHPStan\Type\GeneralizePrecision; use PHPStan\Type\Generic\GenericClassStringType; use PHPStan\Type\Generic\TemplateTypeFactory; use PHPStan\Type\Generic\TemplateTypeScope; @@ -1859,6 +1860,135 @@ public function testGetFiniteTypes(ConstantArrayType $type, array $expectedDescr ); } + public static function dataGeneralize(): iterable + { + $never = new NeverType(true); + $sealedMarker = [$never, $never]; + + yield 'sealed empty (legacy null unsealed)' => [ + new ConstantArrayType([], []), + GeneralizePrecision::lessSpecific(), + 'array{}', + ]; + + yield 'sealed empty (bleeding-edge NeverType marker)' => [ + new ConstantArrayType([], [], unsealed: $sealedMarker), + GeneralizePrecision::lessSpecific(), + 'array{}', + ]; + + yield 'sealed single explicit key' => [ + new ConstantArrayType( + [new ConstantStringType('a')], + [new ConstantIntegerType(1)], + unsealed: $sealedMarker, + ), + GeneralizePrecision::lessSpecific(), + 'non-empty-array', + ]; + + yield 'sealed two explicit keys, lessSpecific' => [ + new ConstantArrayType( + [new ConstantStringType('a'), new ConstantStringType('b')], + [new ConstantIntegerType(1), new ConstantStringType('x')], + unsealed: $sealedMarker, + ), + GeneralizePrecision::lessSpecific(), + 'non-empty-array', + ]; + + yield 'sealed two explicit keys, moreSpecific' => [ + new ConstantArrayType( + [new ConstantStringType('a'), new ConstantStringType('b')], + [new ConstantIntegerType(1), new ConstantStringType('x')], + unsealed: $sealedMarker, + ), + GeneralizePrecision::moreSpecific(), + "non-empty-array&hasOffsetValue('a', int)&hasOffsetValue('b', literal-string&lowercase-string&non-falsy-string)", + ]; + + yield 'sealed list, lessSpecific' => [ + new ConstantArrayType( + [new ConstantIntegerType(0), new ConstantIntegerType(1)], + [new ConstantIntegerType(1), new ConstantIntegerType(2)], + unsealed: $sealedMarker, + isList: TrinaryLogic::createYes(), + ), + GeneralizePrecision::lessSpecific(), + 'non-empty-list', + ]; + + yield 'sealed only-optional keys' => [ + new ConstantArrayType( + [new ConstantStringType('a')], + [new ConstantIntegerType(1)], + optionalKeys: [0], + unsealed: $sealedMarker, + ), + GeneralizePrecision::lessSpecific(), + 'array', + ]; + + yield 'unsealed only, lessSpecific' => [ + new ConstantArrayType([], [], unsealed: [new IntegerType(), new ConstantStringType('foo')]), + GeneralizePrecision::lessSpecific(), + "array{...}", + ]; + + yield 'unsealed with explicit key, lessSpecific' => [ + new ConstantArrayType( + [new ConstantStringType('a')], + [new ConstantIntegerType(1)], + unsealed: [new IntegerType(), new StringType()], + ), + GeneralizePrecision::lessSpecific(), + 'non-empty-array', + ]; + + yield 'unsealed with explicit key, moreSpecific' => [ + new ConstantArrayType( + [new ConstantStringType('a')], + [new ConstantIntegerType(1)], + unsealed: [new IntegerType(), new StringType()], + ), + GeneralizePrecision::moreSpecific(), + "non-empty-array&hasOffsetValue('a', int)", + ]; + + yield 'unsealed with optional explicit key' => [ + new ConstantArrayType( + [new ConstantStringType('a')], + [new ConstantIntegerType(1)], + optionalKeys: [0], + unsealed: [new IntegerType(), new StringType()], + ), + GeneralizePrecision::lessSpecific(), + 'array', + ]; + + yield 'templateArgument routes through traverse' => [ + new ConstantArrayType( + [new ConstantStringType('a')], + [new ConstantIntegerType(1)], + unsealed: [new IntegerType(), new ConstantStringType('foo')], + ), + GeneralizePrecision::templateArgument(), + // `traverse` recurses into both explicit and unsealed values + // (see commit history c. unsealed-aware traverse): `1` → + // `int`, `'foo'` → `string`. + 'array{a: int, ...}', + ]; + } + + #[DataProvider('dataGeneralize')] + public function testGeneralize(ConstantArrayType $type, GeneralizePrecision $precision, string $expectedDescription): void + { + $this->assertSame( + $expectedDescription, + $type->generalize($precision)->describe(VerbosityLevel::precise()), + ); + } + public function testTraverseSimultaneouslyVisitsUnsealedValue(): void { $left = new ConstantArrayType( From f9c295a1710f2e1244b59b8e51b5ee1ab77ac969 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 19 May 2026 14:28:55 +0200 Subject: [PATCH 57/66] Generalize unsealed `ConstantArrayType` instead of returning it unchanged MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `generalize()` early-returned `$this` when `count($keyTypes) === 0`, which is correct for actually empty sealed arrays but wrong for a CAT with no explicit keys but real unsealed extras (`array{...}` — type-checked construction; the builder normally collapses this shape to `ArrayType`, but it can still appear through `recreate()` paths). The constant values inside the unsealed slot survived `generalize()` untouched. Two fixes, mirroring each other: 1. Only early-return for "no explicit keys *and* not unsealed". When the CAT carries real extras, proceed through the normal path so the unsealed key/value are generalized through `getIterableKeyType()` / `getIterableValueType()`. 2. Switch the `NonEmptyArrayType` gate from `keyTypesCount > optionalKeysCount` to `isIterableAtLeastOnce()->yes()`. The old gate was a proxy for "the array is definitely non-empty" that only looked at explicit keys; for "no explicit keys + real extras" it would incorrectly require the accessory. The `isIterableAtLeastOnce()` answer is what we actually want — and for sealed shapes it matches the previous gate exactly. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Type/Constant/ConstantArrayType.php | 12 ++++++++++-- .../PHPStan/Type/Constant/ConstantArrayTypeTest.php | 12 +++++++++++- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index 78b47442f9a..e795f682734 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -2250,7 +2250,8 @@ public function toFloat(): Type public function generalize(GeneralizePrecision $precision): Type { - if (count($this->keyTypes) === 0) { + // No explicit keys and no real extras — actually empty, return as-is. + if (count($this->keyTypes) === 0 && !$this->isUnsealed()->yes()) { return $this; } @@ -2275,7 +2276,14 @@ public function generalize(GeneralizePrecision $precision): Type $accessoryTypes[] = new HasOffsetValueType($keyType, $this->valueTypes[$i]->generalize($precision)); } - } elseif ($keyTypesCount > $optionalKeysCount) { + } elseif ($this->isIterableAtLeastOnce()->yes()) { + // Previously gated on `keyTypesCount > optionalKeysCount`, + // which mishandles "no explicit keys + real unsealed + // extras" (`isIterableAtLeastOnce()` answers `Maybe` — + // extras might be empty — and correctly skips + // `NonEmptyArrayType`). The new gate also covers the + // usual sealed-with-required-keys case, so behaviour for + // existing CAT shapes is unchanged. $accessoryTypes[] = new NonEmptyArrayType(); } diff --git a/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php b/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php index 4dc1daf33d0..6b4bdd4d2ad 100644 --- a/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php +++ b/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php @@ -1932,7 +1932,17 @@ public static function dataGeneralize(): iterable yield 'unsealed only, lessSpecific' => [ new ConstantArrayType([], [], unsealed: [new IntegerType(), new ConstantStringType('foo')]), GeneralizePrecision::lessSpecific(), - "array{...}", + // No explicit keys but real unsealed extras — generalize + // has to broaden the unsealed value (`'foo'` → `string`) + // and degrade to a plain `ArrayType`. The size is uncertain + // (zero-or-more extras), so no `NonEmptyArrayType`. + 'array', + ]; + + yield 'unsealed only with non-falsy-string key, moreSpecific' => [ + new ConstantArrayType([], [], unsealed: [new IntegerType(), new ConstantStringType('foo')]), + GeneralizePrecision::moreSpecific(), + 'array', ]; yield 'unsealed with explicit key, lessSpecific' => [ From 38b3496fa955e6086bf7ab22c8385840cd1a5824 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 19 May 2026 14:35:43 +0200 Subject: [PATCH 58/66] Broaden unsealed value in `ConstantArrayType::generalizeValues()` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `generalizeValues()` walked the explicit `valueTypes` only — for an unsealed source the unsealed value type (e.g. `'foo'`) survived unchanged, leaving the result inconsistent: explicit values generalized to `int`/`string`/etc. but the unsealed slot still carried a constant. Generalize the unsealed value too, mirroring the explicit loop. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Type/Constant/ConstantArrayType.php | 8 +++++++- .../Type/Constant/ConstantArrayTypeTest.php | 14 ++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index e795f682734..fb414a36345 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -2305,7 +2305,13 @@ public function generalizeValues(): self $valueTypes[] = $valueType->generalize(GeneralizePrecision::lessSpecific()); } - return $this->recreate($this->keyTypes, $valueTypes, $this->nextAutoIndexes, $this->optionalKeys, $this->isList, $this->unsealed); + $unsealed = $this->unsealed; + if ($unsealed !== null) { + [$unsealedKey, $unsealedValue] = $unsealed; + $unsealed = [$unsealedKey, $unsealedValue->generalize(GeneralizePrecision::lessSpecific())]; + } + + return $this->recreate($this->keyTypes, $valueTypes, $this->nextAutoIndexes, $this->optionalKeys, $this->isList, $unsealed); } private function degradeToGeneralArray(): Type diff --git a/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php b/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php index 6b4bdd4d2ad..3f97412e561 100644 --- a/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php +++ b/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php @@ -1999,6 +1999,20 @@ public function testGeneralize(ConstantArrayType $type, GeneralizePrecision $pre ); } + public function testGeneralizeValuesAlsoBroadensUnsealedValue(): void + { + $type = new ConstantArrayType( + [new ConstantStringType('a')], + [new ConstantIntegerType(1)], + unsealed: [new IntegerType(), new ConstantStringType('foo')], + ); + + $this->assertSame( + 'array{a: int, ...}', + $type->generalizeValues()->describe(VerbosityLevel::precise()), + ); + } + public function testTraverseSimultaneouslyVisitsUnsealedValue(): void { $left = new ConstantArrayType( From 82d775431ba8deb8711a3ab41bf6dd9f3407b983 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 19 May 2026 18:04:17 +0200 Subject: [PATCH 59/66] Treat the `isList`-from-shape inference in `ConstantArrayType` symmetrically MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The empty-keys block unconditionally overrode `$isList` from the unsealed key type, while the non-empty path only filled it in when null — an asymmetry. No caller actually passes a non-null `$isList` for an empty CAT, so the override and the only-when-null path were equivalent today, but the rule was inconsistent on its face. Wrap both branches in `if ($isList === null)` so the constructor consistently says "trust the caller when given, infer from shape otherwise". Also drop the stale `makeList()` TODO — the comment was speculating about validation that would require `isList=Maybe` co-occurring with a non-list-compatible shape, a state that no source-level flow actually constructs. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Type/Constant/ConstantArrayType.php | 34 +++++++++++++------------ 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index fb414a36345..55ca5c638b8 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -144,25 +144,29 @@ public function __construct( { assert(count($keyTypes) === count($valueTypes)); - $keyTypesCount = count($this->keyTypes); - if ($keyTypesCount === 0) { - if ($unsealed === null) { - $isList = TrinaryLogic::createYes(); - } else { - [$unsealedKeyType] = $unsealed; - if ($unsealedKeyType instanceof NeverType && $unsealedKeyType->isExplicit()) { + // Fill in `$isList` from the shape when the caller didn't pass one. + // For empty CATs the answer derives from the unsealed key type + // (no explicit keys to inspect); for non-empty ones the default + // is `No` and the caller is expected to assert list-ness via + // `makeList()` if appropriate. + if ($isList === null) { + if (count($this->keyTypes) === 0) { + if ($unsealed === null) { $isList = TrinaryLogic::createYes(); - } elseif ($unsealedKeyType->isInteger()->yes()) { - $isList = TrinaryLogic::createMaybe(); } else { - $isList = TrinaryLogic::createNo(); + [$unsealedKeyType] = $unsealed; + if ($unsealedKeyType instanceof NeverType && $unsealedKeyType->isExplicit()) { + $isList = TrinaryLogic::createYes(); + } elseif ($unsealedKeyType->isInteger()->yes()) { + $isList = TrinaryLogic::createMaybe(); + } else { + $isList = TrinaryLogic::createNo(); + } } + } else { + $isList = TrinaryLogic::createNo(); } } - - if ($isList === null) { - $isList = TrinaryLogic::createNo(); - } $this->isList = $isList; if ($unsealed !== null) { @@ -3053,8 +3057,6 @@ public function makeList(): Type return new NeverType(); } - // todo can't be a list if keyTypes are not subsequent integers, or if unsealed type is not int keys - return $this->recreate($this->keyTypes, $this->valueTypes, $this->nextAutoIndexes, $this->optionalKeys, TrinaryLogic::createYes(), $this->unsealed); } From 741527c653b9dd02fe9765de34b7806f193ae85c Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 20 May 2026 10:09:54 +0200 Subject: [PATCH 60/66] Fold unsealed extras into `array_sum()` over a constant array MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `array_sum` summed only the explicit `getValueTypes()` of a constant array, silently dropping the unsealed extras — `array_sum(array{1, 2, ...})` inferred `3` even though the real sum is `3` plus zero-or-more further ints. That's unsound, not just imprecise. Add two result variants for unsealed inputs: the zero-extras case (the exact explicit sum, so a float unsealed value can't erase the precise int sum) and the one-or-more-extras case (explicit sum plus the unsealed value type multiplied by an unbounded count). Sealed constant arrays are unaffected. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...ySumFunctionDynamicReturnTypeExtension.php | 12 +++++++++++ tests/PHPStan/Analyser/nsrt/array-sum.php | 20 +++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/src/Type/Php/ArraySumFunctionDynamicReturnTypeExtension.php b/src/Type/Php/ArraySumFunctionDynamicReturnTypeExtension.php index 2107666303c..3f5a1465a79 100644 --- a/src/Type/Php/ArraySumFunctionDynamicReturnTypeExtension.php +++ b/src/Type/Php/ArraySumFunctionDynamicReturnTypeExtension.php @@ -48,6 +48,18 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection, } } + $unsealedTypes = $constantArray->getUnsealedTypes(); + if ($unsealedTypes !== null && $constantArray->isUnsealed()->yes()) { + // The unsealed slot holds zero-or-more further values. + // Add the zero-extras result (just the explicit sum) as + // its own variant so e.g. a float unsealed value doesn't + // erase the exact int sum, then extend with the + // one-or-more-extras case: explicit sum + value × count. + $resultTypes[] = $scope->getType($node); + $extrasNode = new Mul(new TypeExpr($unsealedTypes[1]), new TypeExpr(IntegerRangeType::fromInterval(1, null))); + $node = new Plus($node, $extrasNode); + } + $resultTypes[] = $scope->getType($node); } } else { diff --git a/tests/PHPStan/Analyser/nsrt/array-sum.php b/tests/PHPStan/Analyser/nsrt/array-sum.php index 3d53b450e33..7f9334f5bfd 100644 --- a/tests/PHPStan/Analyser/nsrt/array-sum.php +++ b/tests/PHPStan/Analyser/nsrt/array-sum.php @@ -264,3 +264,23 @@ function foo32($list) { assertType('(float|int)', array_sum($list)); } + +/** + * @param array{1, 2, ...} $list + */ +function foo33($list) +{ + // The explicit `1, 2` sum to 3, but the unsealed `` extras + // can add any number of further ints — the result must include them. + assertType('int', array_sum($list)); +} + +/** + * @param array{1, 2, ...} $list + */ +function foo34($list) +{ + // Zero extras keeps the exact int sum `3`; one-or-more float extras + // make it float. + assertType('3|float', array_sum($list)); +} From d67b3a8909d4015745c5a279d94d70a2be67625f Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 20 May 2026 10:12:47 +0200 Subject: [PATCH 61/66] Skip `implode()` constant-fold for unsealed constant arrays MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `inferConstantType` built the exact result string from the explicit `getValueTypes()` only — `implode(',', array{'a', 'b', ...})` inferred `'a,b'` even though the unsealed extras can append further segments. That's an unsound constant fold. Bail out of the constant fold when the input `isUnsealed()->yes()` and fall through to the accessory-based result, which only keeps what's provable (e.g. `non-falsy-string` when the separator is non-falsy and the explicit prefix guarantees at least one separator). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Php/ImplodeFunctionReturnTypeExtension.php | 7 +++++++ tests/PHPStan/Analyser/nsrt/implode.php | 17 +++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/src/Type/Php/ImplodeFunctionReturnTypeExtension.php b/src/Type/Php/ImplodeFunctionReturnTypeExtension.php index a23d8443e15..34f4080a9f5 100644 --- a/src/Type/Php/ImplodeFunctionReturnTypeExtension.php +++ b/src/Type/Php/ImplodeFunctionReturnTypeExtension.php @@ -113,6 +113,13 @@ private function implode(Type $arrayType, Type $separatorType): Type private function inferConstantType(ConstantArrayType $arrayType, ConstantStringType $separatorType, bool $isNonEmpty): ?Type { + // Unsealed extras can append further segments the constant fold + // can't see, so the exact string result would be unsound. Fall + // back to the accessory-based result. + if ($arrayType->isUnsealed()->yes()) { + return null; + } + $sep = $separatorType->getValue(); $valueTypes = $arrayType->getValueTypes(); $limit = InitializerExprTypeResolver::CALCULATE_SCALARS_LIMIT; diff --git a/tests/PHPStan/Analyser/nsrt/implode.php b/tests/PHPStan/Analyser/nsrt/implode.php index 8e97e19f729..ecd36a0570f 100644 --- a/tests/PHPStan/Analyser/nsrt/implode.php +++ b/tests/PHPStan/Analyser/nsrt/implode.php @@ -68,4 +68,21 @@ public function constArrays6($constArr) { public function constArrays7($constArr) { assertType("'1a'|'1b'|'1c'|'2a'|'2b'|'2c'|'a'|'b'|'c'", implode('', $constArr)); } + + /** @param array{'a', 'b', ...} $unsealed */ + public function unsealedConstArr($unsealed) { + // The unsealed `` extras can append more segments, so + // the exact constant fold `'a,b'` is unsound. The result keeps only + // what's guaranteed: with a non-falsy separator and at least two + // explicit elements, the output always contains a comma. + assertType('non-falsy-string', implode(',', $unsealed)); + } + + /** @param array{'a', 'b', ...} $unsealed */ + public function unsealedConstArrEmptySeparator($unsealed) { + // Empty separator + a possibly-empty unsealed value type leaves no + // accessory PHPStan can prove, so the result widens to `string` + // (still sound — no bogus constant fold). + assertType('string', implode('', $unsealed)); + } } From 11b6609cfa7497650d61fa50a2994b27222dc1c5 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 20 May 2026 10:16:59 +0200 Subject: [PATCH 62/66] Range `min()` / `max()` over unsealed extras of a constant array MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `processArrayType` fed only the explicit `getValueTypes()` into the min/max comparison, so `min(array{1, 2, ...})` inferred `1` and `max(...)` inferred `2` — unsound, since the unsealed extras can be any int above or below the explicit entries. Append the unsealed value type to the comparison arguments when the constant array `isUnsealed()->yes()`, so the result widens to cover the extras (`int` here). Sealed constant arrays keep their exact min/max. `vsprintf` was on the same verify list but is already sound: it reads only the positions the format references (extras beyond them are ignored) and bails to a general string when a referenced position falls outside the explicit keys. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Type/Php/MinMaxFunctionReturnTypeExtension.php | 8 ++++++++ tests/PHPStan/Analyser/nsrt/minmax.php | 12 ++++++++++++ 2 files changed, 20 insertions(+) diff --git a/src/Type/Php/MinMaxFunctionReturnTypeExtension.php b/src/Type/Php/MinMaxFunctionReturnTypeExtension.php index b425174cd78..a142784318d 100644 --- a/src/Type/Php/MinMaxFunctionReturnTypeExtension.php +++ b/src/Type/Php/MinMaxFunctionReturnTypeExtension.php @@ -128,6 +128,14 @@ private function processArrayType(string $functionName, Type $argType): Type $argumentTypes[] = $innerType; } + $unsealedTypes = $constArrayType->getUnsealedTypes(); + if ($unsealedTypes !== null && $constArrayType->isUnsealed()->yes()) { + // Unsealed extras can hold further values, so the min/max + // must also range over the unsealed value type — otherwise + // the explicit entries would be reported as the answer. + $argumentTypes[] = $unsealedTypes[1]; + } + $resultTypes[] = $this->processType($functionName, $argumentTypes); } diff --git a/tests/PHPStan/Analyser/nsrt/minmax.php b/tests/PHPStan/Analyser/nsrt/minmax.php index d4cbb77c446..0295aa58177 100644 --- a/tests/PHPStan/Analyser/nsrt/minmax.php +++ b/tests/PHPStan/Analyser/nsrt/minmax.php @@ -18,6 +18,18 @@ function dummy5(int $i, int $j): void assertType('array{1: true}', array_filter([false, true])); } +/** + * @param array{1, 2, ...} $unsealed + */ +function unsealedMinMax(array $unsealed): void +{ + // The unsealed `` extras can be any int, so min/max of + // `{1, 2} ∪ extras` is unbounded — the explicit `1`/`2` must not be + // reported as the result. + assertType('int', min($unsealed)); + assertType('int', max($unsealed)); +} + function dummy6(string $s, string $t): void { assertType('array{0?: non-falsy-string, 1?: non-falsy-string}', array_filter([$s, $t])); } From 6988c8191035f09780582dd01be212dfe5f8b0e5 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 20 May 2026 11:02:35 +0200 Subject: [PATCH 63/66] Carry unsealed slots through `array_combine()` all-constant fast path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ArrayCombineHelper` paired only the explicit `getValueTypes()` of the keys and values arrays, dropping unsealed extras — `array_combine(array{'a', 'b', ...}, array{1, 2, ...})` inferred a sealed `array{a: 1, b: 2}` even though the matching extras pair up into further entries. When both inputs are unsealed (their extra counts are both unbounded and must match for the call to succeed), attach the result's unsealed slot: the keys' unsealed value (as an array key) mapped to the values' unsealed value. If only one side is unsealed the sealed side caps the size, so no extras survive and the result stays sealed. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Type/Php/ArrayCombineHelper.php | 16 +++++++++++++ .../Analyser/nsrt/array-combine-php8.php | 24 +++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/src/Type/Php/ArrayCombineHelper.php b/src/Type/Php/ArrayCombineHelper.php index d0dc7f66d6b..b8bb3f445af 100644 --- a/src/Type/Php/ArrayCombineHelper.php +++ b/src/Type/Php/ArrayCombineHelper.php @@ -66,6 +66,22 @@ public function getReturnAndThrowType(Expr $firstArg, Expr $secondArg, Scope $sc $builder->setOffsetValueType($keyType, $valueType); } + // When both inputs carry unsealed extras (of matching, + // unbounded count) the extra positions pair up: the keys' + // unsealed value becomes a key, the values' unsealed value + // becomes its value. If only one side is unsealed, the + // sealed side caps the size, so no extras can survive. + $keysUnsealed = $constantKeysArray->getUnsealedTypes(); + $valuesUnsealed = $constantValueArrays->getUnsealedTypes(); + if ( + $constantKeysArray->isUnsealed()->yes() + && $constantValueArrays->isUnsealed()->yes() + && $keysUnsealed !== null + && $valuesUnsealed !== null + ) { + $builder->makeUnsealed($keysUnsealed[1]->toArrayKey(), $valuesUnsealed[1]); + } + $results[] = $builder->getArray(); } diff --git a/tests/PHPStan/Analyser/nsrt/array-combine-php8.php b/tests/PHPStan/Analyser/nsrt/array-combine-php8.php index 9eb9827154b..1a1a78c411b 100644 --- a/tests/PHPStan/Analyser/nsrt/array-combine-php8.php +++ b/tests/PHPStan/Analyser/nsrt/array-combine-php8.php @@ -162,3 +162,27 @@ function withUnionAsKey(int|bool $oneOrBool) assertType("array{1: 'bar'}", array_combine($keys, ['bar'])); } + +/** + * @param array{'a', 'b', ...} $keys + * @param array{1, 2, ...} $values + */ +function bothUnsealed(array $keys, array $values) +{ + // Both arrays carry unsealed extras of matching (unbounded) count; + // the extra key/value pairs become the result's unsealed slot: + // `` (the keys' unsealed value, as a key, mapped to the + // values' unsealed value). + assertType('array{a: 1, b: 2, ...}', array_combine($keys, $values)); +} + +/** + * @param array{'a', 'b', ...} $keys + */ +function onlyKeysUnsealed(array $keys) +{ + // The values array is sealed (exactly 2), so array_combine only + // succeeds when the keys array also has exactly 2 — the extras can't + // exist. Result stays sealed. + assertType('array{a: 1, b: 2}', array_combine($keys, [1, 2])); +} From 3699e9544602dd2c653272611dc56eb7cd1f31e8 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 20 May 2026 11:04:43 +0200 Subject: [PATCH 64/66] Bail `compact()` enumeration for unsealed name arrays MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `findConstantStrings` walked a constant array's explicit `getValueTypes()` to collect the variable names, so an unsealed names array — `compact(array{'foo', 'bar', ...})` — produced a sealed `array{foo: ..., bar: ...}`, ignoring that the unsealed extras are further unknown variable names. Return `null` (give up the precise shape) when the names array is `isUnsealed()->yes()`, so the caller falls back to the general `array` signature. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Type/Php/CompactFunctionReturnTypeExtension.php | 7 +++++++ tests/PHPStan/Analyser/nsrt/compact.php | 12 ++++++++++++ 2 files changed, 19 insertions(+) diff --git a/src/Type/Php/CompactFunctionReturnTypeExtension.php b/src/Type/Php/CompactFunctionReturnTypeExtension.php index 0d7f40c9f75..0636d048919 100644 --- a/src/Type/Php/CompactFunctionReturnTypeExtension.php +++ b/src/Type/Php/CompactFunctionReturnTypeExtension.php @@ -75,6 +75,13 @@ private function findConstantStrings(Type $type): ?array } if ($type instanceof ConstantArrayType) { + // Unsealed extras are unknown further variable names that can't + // be enumerated — bail so the caller falls back to the general + // `compact()` signature. + if ($type->isUnsealed()->yes()) { + return null; + } + $result = []; foreach ($type->getValueTypes() as $valueType) { $constantStrings = $this->findConstantStrings($valueType); diff --git a/tests/PHPStan/Analyser/nsrt/compact.php b/tests/PHPStan/Analyser/nsrt/compact.php index b15f2f5eb43..d89cb5ceba7 100644 --- a/tests/PHPStan/Analyser/nsrt/compact.php +++ b/tests/PHPStan/Analyser/nsrt/compact.php @@ -20,3 +20,15 @@ function (string $dolor): void { assertType('array{}', compact([])); }; + +/** + * @param array{'foo', 'bar', ...} $names + */ +function unsealedNames(array $names): void { + $foo = 'x'; + $bar = 'y'; + // The unsealed `` extras are unknown further variable + // names, so the result can't be enumerated as a sealed shape — it + // must widen to the general `compact()` signature. + assertType('array', compact($names)); +}; From e40cef49549aaefed20b5b10a1994ba84cd3bd52 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 20 May 2026 11:07:11 +0200 Subject: [PATCH 65/66] Treat unsealed from-encoding arrays as multi-candidate in `mb_convert_encoding()` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The PHP 8 "can this return false?" check counted only the explicit `getValueTypes()` of the from-encoding array, so a one-explicit-key unsealed array — `mb_convert_encoding($s, 'UTF-8', array{'FOO', ...})` — was treated as a single guaranteed encoding and the `false` branch was dropped. The unsealed extras can supply further candidates, making it an auto-detect list that may fail. Extend the `count(...) > 1` gate with `|| isUnsealed()->yes()` so the result keeps `false` as a possible outcome. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Php/MbConvertEncodingFunctionReturnTypeExtension.php | 6 +++++- tests/PHPStan/Analyser/nsrt/mb-convert-encoding-php8.php | 7 +++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/Type/Php/MbConvertEncodingFunctionReturnTypeExtension.php b/src/Type/Php/MbConvertEncodingFunctionReturnTypeExtension.php index 310045be6e7..11c7e86fbe6 100644 --- a/src/Type/Php/MbConvertEncodingFunctionReturnTypeExtension.php +++ b/src/Type/Php/MbConvertEncodingFunctionReturnTypeExtension.php @@ -71,7 +71,11 @@ public function getTypeFromFunctionCall( $constantArrays = $fromEncodingArgType->getConstantArrays(); if (count($constantArrays) > 0) { foreach ($constantArrays as $constantArray) { - if (count($constantArray->getValueTypes()) > 1) { + // Unsealed extras can add further encoding candidates + // on top of the explicit ones, so the list may hold + // 2+ entries (auto-detect → may return false) even when + // only one explicit value is present. + if (count($constantArray->getValueTypes()) > 1 || $constantArray->isUnsealed()->yes()) { $returnFalseIfCannotDetectEncoding = true; break; } diff --git a/tests/PHPStan/Analyser/nsrt/mb-convert-encoding-php8.php b/tests/PHPStan/Analyser/nsrt/mb-convert-encoding-php8.php index e96f8f0e1a0..b1366edd00a 100644 --- a/tests/PHPStan/Analyser/nsrt/mb-convert-encoding-php8.php +++ b/tests/PHPStan/Analyser/nsrt/mb-convert-encoding-php8.php @@ -8,6 +8,7 @@ * @param list $stringList * @param list $intList * @param 'foo'|'bar'|array{foo: string, bar: int, baz: 'foo'}|bool $union + * @param array{'FOO', ...} $unsealedEncodings */ function test_mb_convert_encoding( mixed $mixed, @@ -19,6 +20,7 @@ function test_mb_convert_encoding( array $intList, string|array|bool $union, int $int, + array $unsealedEncodings, ): void { \PHPStan\Testing\assertType('array|string', mb_convert_encoding($mixed, 'UTF-8')); \PHPStan\Testing\assertType('string', mb_convert_encoding($constantString, 'UTF-8')); @@ -45,4 +47,9 @@ function test_mb_convert_encoding( \PHPStan\Testing\assertType('string|false', mb_convert_encoding($string, 'UTF-8', 'auto')); \PHPStan\Testing\assertType('string|false', mb_convert_encoding($string, 'UTF-8', ' AUTO ')); + + // One explicit encoding, but the unsealed extras can add more, so the + // from-encoding list may hold 2+ candidates → auto-detect → false is + // possible. + \PHPStan\Testing\assertType('string|false', mb_convert_encoding($string, 'UTF-8', $unsealedEncodings)); }; From e2d4e60270163acc0b2ed0a40680ce20e6ed5cd9 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 20 May 2026 11:14:46 +0200 Subject: [PATCH 66/66] Treat legacy-null and sealed-marker as equal in `ConstantArrayType::equals()` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `equals()` already documented that a null unsealed slot (legacy / pre-bleeding-edge, `isUnsealed()` = `Maybe`) and the `[NeverType, NeverType]` sealed marker (bleeding edge, `isUnsealed()` = `No`) both mean "no real extras" and should compare equal. But the implementation used `isUnsealed()->no()`, which is `false` for the null case and `true` for the marker — so a legacy-null shape and a marker-sealed shape of otherwise-identical structure compared *unequal*. This surfaced as `TypeToPhpDocNodeTest::testToPhpDocNode` failing only under old PHPUnit (PHP < 8.2): there the data provider runs before the test container enables bleeding edge, so the directly-constructed expected type gets a null slot while the round-trip-parsed type (built after bleeding edge is on) gets the marker. New PHPUnit runs data providers after container init, so both sides got the marker and the bug stayed hidden. Compare on `isUnsealed()->yes()` ("has real extras") instead, so null and marker are both treated as sealed and only genuine extras are compared. Added a timing-independent unit test that constructs both forms directly via `BleedingEdgeToggle`. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Type/Constant/ConstantArrayType.php | 20 ++++++++------ .../Type/Constant/ConstantArrayTypeTest.php | 26 +++++++++++++++++++ 2 files changed, 38 insertions(+), 8 deletions(-) diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index 55ca5c638b8..5a22f5b189a 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -903,17 +903,21 @@ public function equals(Type $type): bool return false; } - // Both `unsealed === null` and `unsealed === [explicitNever, explicitNever]` - // mean "sealed", just from different code paths (pre-bleeding-edge vs. - // fresh bleeding-edge builder). Treat them as equivalent here, only - // comparing the actual extras when both sides have real ones. - $thisIsSealed = $this->isUnsealed()->no(); - $otherIsSealed = $type->isUnsealed()->no(); - if ($thisIsSealed !== $otherIsSealed) { + // Both `unsealed === null` (legacy / pre-bleeding-edge, where + // `isUnsealed()` answers `Maybe`) and `unsealed === [explicitNever, + // explicitNever]` (the fresh bleeding-edge sealed marker, where + // `isUnsealed()` answers `No`) mean "no real extras". Treat them as + // equivalent here — use `!isUnsealed()->yes()` rather than + // `isUnsealed()->no()`, otherwise a legacy-null shape and a + // marker-sealed shape compare unequal. Only compare the actual + // extras when both sides genuinely have them. + $thisHasExtras = $this->isUnsealed()->yes(); + $otherHasExtras = $type->isUnsealed()->yes(); + if ($thisHasExtras !== $otherHasExtras) { return false; } - if (!$thisIsSealed && $this->unsealed !== null && $type->unsealed !== null) { + if ($thisHasExtras && $this->unsealed !== null && $type->unsealed !== null) { if (!$this->unsealed[0]->equals($type->unsealed[0])) { return false; } diff --git a/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php b/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php index 3f97412e561..ced494973f0 100644 --- a/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php +++ b/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php @@ -1549,6 +1549,32 @@ public function testHasOffsetValueType( ); } + public function testEqualsTreatsLegacyNullAndSealedMarkerAsEqual(): void + { + $bleedingEdgeBackup = BleedingEdgeToggle::isBleedingEdge(); + + try { + // Pre-bleeding-edge construction leaves the unsealed slot null + // (`isUnsealed()` answers `Maybe`). + BleedingEdgeToggle::setBleedingEdge(false); + $legacyNull = new ConstantArrayType([new ConstantStringType('a')], [new IntegerType()]); + + // Bleeding-edge construction seeds the `[NeverType, NeverType]` + // sealed marker (`isUnsealed()` answers `No`). + BleedingEdgeToggle::setBleedingEdge(true); + $sealedMarker = new ConstantArrayType([new ConstantStringType('a')], [new IntegerType()]); + + // Both represent the same sealed shape, so they must compare + // equal in both directions — this mismatch is what made the + // `TypeToPhpDocNode` round-trip fail under old PHPUnit (data + // providers run before the container enables bleeding edge). + $this->assertTrue($legacyNull->equals($sealedMarker), 'legacy-null should equal sealed-marker'); + $this->assertTrue($sealedMarker->equals($legacyNull), 'sealed-marker should equal legacy-null'); + } finally { + BleedingEdgeToggle::setBleedingEdge($bleedingEdgeBackup); + } + } + public function testSealedness(): void { $bleedingEdgeBackup = BleedingEdgeToggle::isBleedingEdge();