From 8ab6e10d4c9127348fd589f45c9845ad6de760d7 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Fri, 17 Apr 2026 18:11:13 +0200 Subject: [PATCH 01/34] 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 5283e5bae82..39b019e6f9d 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,21 +29,27 @@ 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; use PHPStan\Type\NeverType; use PHPStan\Type\NullType; use PHPStan\Type\RecursionGuard; +use PHPStan\Type\StrictMixedType; +use PHPStan\Type\StringType; use PHPStan\Type\Traits\ArrayTypeTrait; use PHPStan\Type\Traits\NonObjectTypeTrait; use PHPStan\Type\Traits\UndecidedComparisonTypeTrait; @@ -87,6 +95,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; @@ -103,6 +114,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, @@ -110,6 +122,7 @@ public function __construct( private array $nextAutoIndexes = [0], private array $optionalKeys = [], ?TrinaryLogic $isList = null, + ?array $unsealed = null, ) { assert(count($keyTypes) === count($valueTypes)); @@ -123,6 +136,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; } /** @@ -130,16 +181,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 @@ -180,6 +233,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; } @@ -189,7 +252,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 @@ -330,10 +398,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]; @@ -380,13 +611,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; } @@ -723,7 +947,7 @@ public function getOffsetValueType(Type $offsetType): Type $matchingValueTypes[] = $this->valueTypes[$i]; } - if ($all) { + if ($all && !$this->isUnsealed()->yes()) { return $this->getIterableValueType(); } @@ -811,7 +1035,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; @@ -858,7 +1082,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); } $optionalKeys = $this->optionalKeys; @@ -888,7 +1112,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); } /** @@ -1112,7 +1336,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) { @@ -1308,11 +1532,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 @@ -1428,6 +1657,7 @@ private function removeLastElements(int $length): self $nextAutoindexes, array_values($optionalKeys), $this->isList, + $this->unsealed, ); } @@ -1526,7 +1756,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 @@ -1574,7 +1804,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 = []; @@ -1603,7 +1833,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 @@ -1648,6 +1878,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, @@ -1764,11 +2011,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 @@ -1794,7 +2051,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 @@ -1851,6 +2108,8 @@ public function isKeysSupersetOf(self $otherArray): bool } } + // todo unsealed + return true; } @@ -1877,7 +2136,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 } /** @@ -1933,7 +2192,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; @@ -1952,7 +2211,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 toPhpDocNode(): TypeNode @@ -1995,6 +2256,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 894fbfc4f18..c8329b39ce5 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) { @@ -390,13 +405,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 1b62fe7e8b8..b87e2ee904e 100644 --- a/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php +++ b/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php @@ -2956,4 +2956,15 @@ public function testBug3842(): void $this->analyse([__DIR__ . '/../../Analyser/nsrt/bug-3842.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 0c23f0e6b0a..b0dffa21adf 100644 --- a/tests/PHPStan/Rules/Functions/ReturnTypeRuleTest.php +++ b/tests/PHPStan/Rules/Functions/ReturnTypeRuleTest.php @@ -431,4 +431,17 @@ public function testBug13000(): void $this->analyse([__DIR__ . '/data/bug-13000.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 643693f336a9e442d562744fa237573e0dc03ef1 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 22 Apr 2026 10:03:54 +0200 Subject: [PATCH 02/34] 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 2d1a0c7f48c..b35c50c4ff2 100644 --- a/tests/PHPStan/Type/TypeCombinatorTest.php +++ b/tests/PHPStan/Type/TypeCombinatorTest.php @@ -5083,6 +5083,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 a0b1930c396d94494ea52d23c68bacae8a955d2a Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 22 Apr 2026 10:11:36 +0200 Subject: [PATCH 03/34] 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 f4338b9b492..6bcb5154cf9 100644 --- a/src/Type/TypeCombinator.php +++ b/src/Type/TypeCombinator.php @@ -1637,42 +1637,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 b744726c9c2eb443edfdf4a311d7b1ebe486fb17 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 22 Apr 2026 10:42:23 +0200 Subject: [PATCH 04/34] 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 39b019e6f9d..4d41512f9ce 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -392,6 +392,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 6bcb5154cf9..90fbe76bc84 100644 --- a/src/Type/TypeCombinator.php +++ b/src/Type/TypeCombinator.php @@ -1643,25 +1643,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--; @@ -1728,6 +1741,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 b35c50c4ff2..d70eb63b8dd 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; @@ -5104,10 +5107,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')] @@ -5117,35 +5293,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); } @@ -5160,35 +5325,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 66dd40b392b67fb3eb04074e3dada164311412a9 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 22 Apr 2026 12:20:40 +0200 Subject: [PATCH 05/34] isSuperTypeOf --- src/Type/Constant/ConstantArrayType.php | 77 +++++++++++++++++- .../Type/Constant/ConstantArrayTypeTest.php | 79 ++++++++++++++++++- 2 files changed, 153 insertions(+), 3 deletions(-) diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index 4d41512f9ce..3e9d571494a 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -57,6 +57,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; @@ -647,13 +648,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(); @@ -665,13 +682,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/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php b/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php index f061882c1e7..414c7559389 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; @@ -909,11 +910,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(), From f0175e24342f4db70f3a912f08bb9405e8b2cb54 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 22 Apr 2026 12:57:45 +0200 Subject: [PATCH 06/34] Fix CS --- src/Type/Constant/ConstantArrayType.php | 1 + src/Type/TypeCombinator.php | 6 ++++-- .../CallToFunctionParametersRuleTest.php | 2 +- .../Type/Constant/ConstantArrayTypeTest.php | 7 +++++-- tests/PHPStan/Type/TypeCombinatorTest.php | 17 +++++++++++------ 5 files changed, 22 insertions(+), 11 deletions(-) diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index 3e9d571494a..832459b6973 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -78,6 +78,7 @@ use function sort; use function sprintf; use function str_contains; +use function usort; /** * @api diff --git a/src/Type/TypeCombinator.php b/src/Type/TypeCombinator.php index 90fbe76bc84..10ca7970b21 100644 --- a/src/Type/TypeCombinator.php +++ b/src/Type/TypeCombinator.php @@ -1808,9 +1808,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 b87e2ee904e..0c40e691d00 100644 --- a/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php +++ b/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php @@ -2962,7 +2962,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 414c7559389..dab3e8bf4bb 100644 --- a/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php +++ b/tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php @@ -31,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 @@ -616,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 diff --git a/tests/PHPStan/Type/TypeCombinatorTest.php b/tests/PHPStan/Type/TypeCombinatorTest.php index d70eb63b8dd..675f8ac5089 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; @@ -5094,7 +5095,7 @@ public static function dataIntersect(): iterable [new IntegerType(), new UnionType([ new ConstantStringType('0'), new ConstantStringType('foo'), - ])] + ])], ), new ConstantArrayType( [new ConstantIntegerType(0), new ConstantIntegerType(1)], @@ -5297,9 +5298,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); @@ -5315,7 +5318,7 @@ public function testIntersect( } /** - * @param list $types + * @param list|list $types * @param class-string $expectedTypeClass */ #[DataProvider('dataIntersect')] @@ -5329,9 +5332,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 466fe0368cd0fa1f9fe0883e59b25d491fded315 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 22 Apr 2026 18:12:53 +0200 Subject: [PATCH 07/34] union improvement --- src/Type/Constant/ConstantArrayType.php | 205 +++++++++- .../Constant/ConstantArrayTypeBuilder.php | 7 + src/Type/TypeCombinator.php | 61 ++- .../Constant/ConstantArrayTypeBuilderTest.php | 43 ++ .../Type/Constant/ConstantArrayTypeTest.php | 11 +- tests/PHPStan/Type/TypeCombinatorTest.php | 387 ++++++++++++++---- 6 files changed, 626 insertions(+), 88 deletions(-) diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index 832459b6973..9a5ea95bf54 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -131,7 +131,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) { @@ -1621,7 +1632,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); @@ -2159,6 +2177,78 @@ 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); + } + + $keyIndexMap = $this->getKeyIndexMap(); + $otherKeyIndexMap = $otherArray->getKeyIndexMap(); + + // Disjoint values at a common key prevent a lossless merge + $hasCommon = false; + foreach ($otherKeyIndexMap as $keyValue => $j) { + if (!array_key_exists($keyValue, $keyIndexMap)) { + continue; + } + $i = $keyIndexMap[$keyValue]; + $valueType = $this->valueTypes[$i]; + $otherValueType = $otherArray->valueTypes[$j]; + if ($valueType->isSuperTypeOf($otherValueType)->no() && $otherValueType->isSuperTypeOf($valueType)->no()) { + return false; + } + $hasCommon = true; + } + + [$thisUnsealedKey, $thisUnsealedValue] = $this->unsealed; + [$otherUnsealedKey, $otherUnsealedValue] = $otherArray->unsealed; + $thisHasExtras = !($thisUnsealedKey instanceof NeverType && $thisUnsealedKey->isExplicit()); + $otherHasExtras = !($otherUnsealedKey instanceof NeverType && $otherUnsealedKey->isExplicit()); + + if ($hasCommon) { + return true; + } + + if ($thisHasExtras && $otherHasExtras) { + return true; + } + + // Mixed or both sealed, no common keys — only merge if one side's extras can + // absorb the other side's required keys (preserves tagged-union otherwise). + if ($thisHasExtras) { + 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) { + 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; + } + + return false; + } + + private function legacyIsKeysSupersetOf(self $otherArray): bool { $keyTypesCount = count($this->keyTypes); $otherKeyTypesCount = count($otherArray->keyTypes); @@ -2212,14 +2302,119 @@ 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); + + $resultUnsealed = [$mergedUnsealedKey, $mergedUnsealedValue]; + $resultHasExtras = !($mergedUnsealedKey instanceof NeverType && $mergedUnsealedKey->isExplicit()); + + $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) { @@ -2240,7 +2435,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 c8329b39ce5..2952284956f 100644 --- a/src/Type/Constant/ConstantArrayTypeBuilder.php +++ b/src/Type/Constant/ConstantArrayTypeBuilder.php @@ -405,6 +405,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 10ca7970b21..46189c5df81 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; @@ -918,7 +919,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(); @@ -1288,6 +1289,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 @@ -1334,6 +1390,7 @@ private static function reduceArrays(array $constantArrays, bool $preserveTagged } } + return array_merge($newArrays, $arraysToProcess); } @@ -1565,6 +1622,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); @@ -1577,6 +1635,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); 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 675f8ac5089..a9504476f63 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}', ], [ [ @@ -2622,7 +2621,7 @@ public static function dataUnion(): iterable new NonAcceptingNeverType(), ], NeverType::class, - 'never', + 'never=explicit', ]; yield [ [ @@ -2896,10 +2895,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, ...}', + ], + UnionType::class, + 'array{a: int, ...}|array{a: 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')] @@ -2909,29 +3158,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, @@ -2966,28 +3208,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, @@ -5208,8 +5445,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 @@ -5238,8 +5484,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 @@ -5249,7 +5495,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 @@ -5308,11 +5554,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); } @@ -5342,20 +5590,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'; @@ -5376,25 +5630,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 022de2d999dc476797b883f26a05a257193564ac Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Thu, 23 Apr 2026 10:35:45 +0200 Subject: [PATCH 08/34] SA fixes --- src/Type/Constant/ConstantArrayType.php | 14 +++++++------- src/Type/TypeCombinator.php | 2 +- tests/PHPStan/Type/TypeCombinatorTest.php | 2 +- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index 9a5ea95bf54..55d32eb7d92 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -560,7 +560,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; @@ -662,7 +662,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) { @@ -678,7 +678,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(); } @@ -696,7 +696,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); @@ -725,7 +725,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)) { @@ -749,8 +749,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); } diff --git a/src/Type/TypeCombinator.php b/src/Type/TypeCombinator.php index 46189c5df81..36e0d8c1a4a 100644 --- a/src/Type/TypeCombinator.php +++ b/src/Type/TypeCombinator.php @@ -1804,7 +1804,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/Type/TypeCombinatorTest.php b/tests/PHPStan/Type/TypeCombinatorTest.php index a9504476f63..d5f71c7b8df 100644 --- a/tests/PHPStan/Type/TypeCombinatorTest.php +++ b/tests/PHPStan/Type/TypeCombinatorTest.php @@ -3197,7 +3197,7 @@ public function testUnion( } /** - * @param list $types + * @param list|list $types * @param class-string $expectedTypeClass */ #[DataProvider('dataUnion')] From 677f46b3f68907931c65e04d58e3f99478a4a72c Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Thu, 23 Apr 2026 11:42:10 +0200 Subject: [PATCH 09/34] More SA fixes --- src/Type/Constant/ConstantArrayType.php | 53 +++++++++++-------- src/Type/TypeCombinator.php | 9 +++- .../Analyser/nsrt/array-append-count.php | 32 +++++++++++ .../nsrt/conditional-array-key-exists.php | 25 +++++++++ tests/PHPStan/Type/TypeCombinatorTest.php | 4 +- 5 files changed, 97 insertions(+), 26 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 55d32eb7d92..73698ca8ff5 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -2182,40 +2182,43 @@ public function isKeysSupersetOf(self $otherArray): bool return $this->legacyIsKeysSupersetOf($otherArray); } - $keyIndexMap = $this->getKeyIndexMap(); - $otherKeyIndexMap = $otherArray->getKeyIndexMap(); - - // Disjoint values at a common key prevent a lossless merge - $hasCommon = false; - foreach ($otherKeyIndexMap as $keyValue => $j) { - if (!array_key_exists($keyValue, $keyIndexMap)) { - continue; - } - $i = $keyIndexMap[$keyValue]; - $valueType = $this->valueTypes[$i]; - $otherValueType = $otherArray->valueTypes[$j]; - if ($valueType->isSuperTypeOf($otherValueType)->no() && $otherValueType->isSuperTypeOf($valueType)->no()) { - return false; - } - $hasCommon = true; - } - [$thisUnsealedKey, $thisUnsealedValue] = $this->unsealed; [$otherUnsealedKey, $otherUnsealedValue] = $otherArray->unsealed; $thisHasExtras = !($thisUnsealedKey instanceof NeverType && $thisUnsealedKey->isExplicit()); $otherHasExtras = !($otherUnsealedKey instanceof NeverType && $otherUnsealedKey->isExplicit()); - if ($hasCommon) { + $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; } - // Mixed or both sealed, no common keys — only merge if one side's extras can - // absorb the other side's required keys (preserves tagged-union otherwise). + // 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; @@ -2231,6 +2234,9 @@ public function isKeysSupersetOf(self $otherArray): bool } if ($otherHasExtras) { + if ($this->legacyIsKeysSupersetOf($otherArray)) { + return true; + } foreach ($this->keyTypes as $i => $keyType) { if ($this->isOptionalKey($i)) { continue; @@ -2245,7 +2251,8 @@ public function isKeysSupersetOf(self $otherArray): bool return true; } - return false; + // Both sealed: fall back to the legacy key/value shape check. + return $this->legacyIsKeysSupersetOf($otherArray); } private function legacyIsKeysSupersetOf(self $otherArray): bool @@ -2356,7 +2363,7 @@ public function mergeWith(self $otherArray): self $j = $otherKeyIndexMap[$keyValue]; $otherValueType = $otherArray->valueTypes[$j]; $mergedValue = TypeCombinator::union($valueType, $otherValueType); - $optional = $this->isOptionalKey($i) && $otherArray->isOptionalKey($j); + $optional = $this->isOptionalKey($i) || $otherArray->isOptionalKey($j); $keyTypes[] = $keyType; $valueTypes[] = $mergedValue; diff --git a/src/Type/TypeCombinator.php b/src/Type/TypeCombinator.php index 36e0d8c1a4a..50a08630bac 100644 --- a/src/Type/TypeCombinator.php +++ b/src/Type/TypeCombinator.php @@ -1204,7 +1204,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 = []; 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/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/Type/TypeCombinatorTest.php b/tests/PHPStan/Type/TypeCombinatorTest.php index d5f71c7b8df..741bc032bea 100644 --- a/tests/PHPStan/Type/TypeCombinatorTest.php +++ b/tests/PHPStan/Type/TypeCombinatorTest.php @@ -3102,8 +3102,8 @@ public static function dataUnion(): iterable 'array{a: int, ...}', 'array{a: string, ...}', ], - UnionType::class, - 'array{a: int, ...}|array{a: string, ...}', + ConstantArrayType::class, + 'array{a: int|string, ...}', ]; yield [ From 980a5b401d515ebd48b075a42512fdf1e6f59faf Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Thu, 23 Apr 2026 12:30:57 +0200 Subject: [PATCH 10/34] Test updates --- src/Type/Constant/ConstantArrayType.php | 3 --- tests/PHPStan/Analyser/nsrt/bug-14314.php | 2 +- tests/PHPStan/Analyser/nsrt/bug-5584.php | 2 +- tests/PHPStan/Analyser/nsrt/bug-9985.php | 2 +- .../PHPStan/Analyser/nsrt/generalize-scope-recursive.php | 2 +- tests/PHPStan/Analyser/nsrt/has-offset-type-bug.php | 6 +++--- tests/PHPStan/Analyser/nsrt/list-count.php | 8 ++++---- tests/PHPStan/Analyser/nsrt/narrow-tagged-union.php | 2 +- tests/PHPStan/Analyser/nsrt/unsealed-array-shapes.php | 2 +- tests/PHPStan/Rules/Comparison/data/bug-7898.php | 2 +- 10 files changed, 14 insertions(+), 17 deletions(-) diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index 73698ca8ff5..b36a8e7f8e1 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -2325,9 +2325,6 @@ public function mergeWith(self $otherArray): self $mergedUnsealedKey = TypeCombinator::union($thisUnsealedKey, $otherUnsealedKey); $mergedUnsealedValue = TypeCombinator::union($thisUnsealedValue, $otherUnsealedValue); - $resultUnsealed = [$mergedUnsealedKey, $mergedUnsealedValue]; - $resultHasExtras = !($mergedUnsealedKey instanceof NeverType && $mergedUnsealedKey->isExplicit()); - $absorbIntoExtras = static function (Type $keyType, Type $valueType) use (&$mergedUnsealedKey, &$mergedUnsealedValue): void { $mergedUnsealedKey = TypeCombinator::union($mergedUnsealedKey, $keyType); $mergedUnsealedValue = TypeCombinator::union($mergedUnsealedValue, $valueType); 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/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()]); } From 77eb78812ff27ee76ced0a2dd8b3716b12aa4142 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Thu, 23 Apr 2026 12:54:48 +0200 Subject: [PATCH 11/34] 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 71503bcab7f5ba00d29c7ae983f5cdf13a5acd3d Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Thu, 23 Apr 2026 14:38:29 +0200 Subject: [PATCH 12/34] Optimization --- src/Type/TypeCombinator.php | 83 +++++++++++++++++++++++++++---------- 1 file changed, 61 insertions(+), 22 deletions(-) diff --git a/src/Type/TypeCombinator.php b/src/Type/TypeCombinator.php index 50a08630bac..1ac9cbfff88 100644 --- a/src/Type/TypeCombinator.php +++ b/src/Type/TypeCombinator.php @@ -1296,38 +1296,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]); + } } } From 3e5e5c6d2ad6e774d663fd5a4d733076577b37c3 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Fri, 24 Apr 2026 18:54:46 +0200 Subject: [PATCH 13/34] Fix --- src/Type/Constant/ConstantArrayType.php | 1 + src/Type/TypeCombinator.php | 1 + 2 files changed, 2 insertions(+) diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index b36a8e7f8e1..32434452d1b 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -182,6 +182,7 @@ public function isUnsealed(): TrinaryLogic } /** + * @phpstan-pure * @return array{Type, Type}|null */ public function getUnsealedTypes(): ?array diff --git a/src/Type/TypeCombinator.php b/src/Type/TypeCombinator.php index 1ac9cbfff88..0f58889cf7c 100644 --- a/src/Type/TypeCombinator.php +++ b/src/Type/TypeCombinator.php @@ -1938,6 +1938,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); From 721c0d0e1b26931e918ae767db2fbac4421e4aa0 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sun, 26 Apr 2026 09:21:58 +0200 Subject: [PATCH 14/34] SA fixes --- phpstan-baseline.neon | 2 +- src/Type/FileTypeMapper.php | 4 ++-- tests/PHPStan/Analyser/Ignore/IgnoreLexerTest.php | 2 +- .../Command/ErrorFormatter/BaselineNeonErrorFormatterTest.php | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 6e056711993..ab2e4fe990b 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -1710,7 +1710,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/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/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 33c84ac9627bbd0ceae4a0b4050fd9b26e241bf1 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sun, 26 Apr 2026 10:12:58 +0200 Subject: [PATCH 15/34] Fix SA --- src/Node/AnonymousClassNode.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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; } From e11272c1a89a3b19ab9b268186dac73882e1b5c0 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Mon, 27 Apr 2026 10:02:05 +0200 Subject: [PATCH 16/34] Fix SA --- phpstan-baseline.neon | 6 -- src/Analyser/Ignore/IgnoredError.php | 5 +- src/Analyser/Ignore/IgnoredErrorHelper.php | 18 +++- .../Ignore/IgnoredErrorHelperResult.php | 91 ++++++++++++++----- 4 files changed, 86 insertions(+), 34 deletions(-) diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index ab2e4fe990b..991a0836460 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 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)) { From 338170a03712cfe07f680dfa6f3b8808966d5c96 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Mon, 27 Apr 2026 10:54:48 +0200 Subject: [PATCH 17/34] Remove unrelated tip --- src/Type/Constant/ConstantArrayType.php | 4 ++++ tests/PHPStan/Type/Constant/ConstantArrayTypeTest.php | 10 ++++++++++ 2 files changed, 14 insertions(+) diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index 32434452d1b..51bce0b7f87 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -460,6 +460,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/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 7d2a7ce883b1147d769fa47521b16995bb2c5429 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Mon, 27 Apr 2026 11:08:33 +0200 Subject: [PATCH 18/34] Fix tests --- 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 | 2 +- .../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 ++++++++++++++ 17 files changed, 100 insertions(+), 15 deletions(-) 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 0c40e691d00..f356228e66a 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, ], ]); @@ -2962,7 +2962,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..604031c2c2e 100644 --- a/tests/PHPStan/Rules/Functions/data/bug-3931.php +++ b/tests/PHPStan/Rules/Functions/data/bug-3931.php @@ -7,7 +7,7 @@ /** * @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; 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 e2c9673583c..402803aa3af 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 9b0e8ca96da..57ac94cc8ea 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 39efdf4aeb7..9d2d184ab15 100644 --- a/tests/PHPStan/Rules/Methods/MethodSignatureRuleTest.php +++ b/tests/PHPStan/Rules/Methods/MethodSignatureRuleTest.php @@ -81,6 +81,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, + ], ], ); } @@ -184,6 +188,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' => '', + ]; + } +} From 0fa1aa2c876a92e94e6cb5180152b054b1cb0659 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Mon, 27 Apr 2026 11:19:55 +0200 Subject: [PATCH 19/34] Fix --- tests/PHPStan/Rules/Functions/data/bug-3931.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/PHPStan/Rules/Functions/data/bug-3931.php b/tests/PHPStan/Rules/Functions/data/bug-3931.php index 604031c2c2e..ec98f36e845 100644 --- a/tests/PHPStan/Rules/Functions/data/bug-3931.php +++ b/tests/PHPStan/Rules/Functions/data/bug-3931.php @@ -11,7 +11,7 @@ */ 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); } From 479e3bf6bb8a0996a9092750eef6f013042fdbb9 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Mon, 27 Apr 2026 12:53:11 +0200 Subject: [PATCH 20/34] 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/Rules/Functions/data/bug-3931.php | 2 +- tests/PHPStan/Type/TypeCombinatorTest.php | 4 ++-- 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/src/Type/TypeCombinator.php b/src/Type/TypeCombinator.php index 0f58889cf7c..53608f26360 100644 --- a/src/Type/TypeCombinator.php +++ b/src/Type/TypeCombinator.php @@ -1760,6 +1760,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/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 741bc032bea..649e5838ff2 100644 --- a/tests/PHPStan/Type/TypeCombinatorTest.php +++ b/tests/PHPStan/Type/TypeCombinatorTest.php @@ -5485,7 +5485,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 @@ -5495,7 +5495,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 1bd33cf673ab0b6bfcf54804962062747d2d3a46 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Mon, 27 Apr 2026 13:17:31 +0200 Subject: [PATCH 21/34] Fix tests: bug-7963, bug-13978 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bug-7963: loosen `@phpstan-return` to `array>` to match the actual heterogeneous list-of-lists return shape — the prior shape required positional `string`/`array` types that no union member satisfies. bug-13978: PHPStan checks `@param-out` at every mutation, so the intermediate state with both `key1` and `key2` (between `$item['key2'] =` and `unset($item['key1'])`) needs to be in the union too. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/PHPStan/Analyser/data/bug-13978.php | 3 +++ tests/PHPStan/Analyser/data/bug-7963.php | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) 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 { From 22199893b450023867673bcd9851fc74e8eb48f2 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Mon, 27 Apr 2026 16:44:44 +0200 Subject: [PATCH 22/34] 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 fd0823112ce145aa0d751b13e5b2d98090c882d5 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Mon, 27 Apr 2026 18:48:22 +0200 Subject: [PATCH 23/34] 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) --- tests/PHPStan/Analyser/data/bug-7963.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 771a2e8b70a0ff94e26d48040f999621b55a326d Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Mon, 27 Apr 2026 18:48:40 +0200 Subject: [PATCH 24/34] Fix CS --- src/Type/TypeCombinator.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Type/TypeCombinator.php b/src/Type/TypeCombinator.php index 53608f26360..ed64ae911f9 100644 --- a/src/Type/TypeCombinator.php +++ b/src/Type/TypeCombinator.php @@ -1436,7 +1436,6 @@ private static function reduceArrays(array $constantArrays, bool $preserveTagged } } - return array_merge($newArrays, $arraysToProcess); } From 3556b3a2fdea60137dae0a39cec02a0a671eea4d Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Mon, 27 Apr 2026 18:50:15 +0200 Subject: [PATCH 25/34] Fix baseline --- phpstan-baseline.neon | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 991a0836460..45ba7dcbab2 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -1704,7 +1704,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 - From 04a737b328f20f47e52c81cdf247ef3a7e9cd369 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 28 Apr 2026 14:44:17 +0200 Subject: [PATCH 26/34] Fix --- .../Functions/CallToFunctionParametersRuleTest.php | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php b/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php index f356228e66a..446154dc39c 100644 --- a/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php +++ b/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php @@ -2953,7 +2953,18 @@ public function testBug13643(): void public function testBug3842(): void { - $this->analyse([__DIR__ . '/../../Analyser/nsrt/bug-3842.php'], []); + $this->analyse([__DIR__ . '/../../Analyser/nsrt/bug-3842.php'], [ + [ + 'Parameter #1 $values of function Bug3842\check expects array{object|string, string}, array&callable(): mixed given.', + 59, + 'Sealed array shape can only accept a constant array. Extra keys are not allowed.', + ], + [ + 'Parameter #1 $values of function Bug3842\checkClassString expects array{class-string|object, string}, array&callable(): mixed given.', + 60, + 'Sealed array shape can only accept a constant array. Extra keys are not allowed.', + ], + ]); } public function testBug11494(): void From 6bcb8e85a03a85ad86e64d864d265d0f62de7c7e Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 28 Apr 2026 14:50:49 +0200 Subject: [PATCH 27/34] 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 fd67793c2ed62160b4469b61c20398f3b1f9c617 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 28 Apr 2026 15:47:29 +0200 Subject: [PATCH 28/34] 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 07f584fb8a1..6d82dae484f 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; } @@ -74,8 +79,8 @@ public function getIterableTypesWithMissingValueTypehint(Type $type): array return $type; } if ($type instanceof ConditionalType || $type instanceof ConditionalTypeForParameter) { - $iterablesWithMissingValueTypehint = array_merge( - $iterablesWithMissingValueTypehint, + $descriptions = array_merge( + $descriptions, $this->getIterableTypesWithMissingValueTypehint($type->getIf()), $this->getIterableTypesWithMissingValueTypehint($type->getElse()), ); @@ -85,7 +90,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()) { @@ -98,7 +103,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 7e126f63087c4ea4447df2956d7c3ee331512c60 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 28 Apr 2026 15:55:04 +0200 Subject: [PATCH 29/34] 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 6d82dae484f..54548dad925 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; @@ -88,6 +90,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 51bce0b7f87..2f21e84ff06 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -190,6 +190,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 db4b818f924..721e7930077 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 14e158ee5f055ae623afeef6a3b3cb497791ab98 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 28 Apr 2026 16:06:44 +0200 Subject: [PATCH 30/34] 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 2f21e84ff06..d809713ce0b 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -2085,6 +2085,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 c10aad0274116c3b4df4fbf9384c12ad77607362 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 28 Apr 2026 16:28:09 +0200 Subject: [PATCH 31/34] Unsealed types awareness in more methods --- src/Type/Constant/ConstantArrayType.php | 39 +++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index d809713ce0b..0a1d30433f4 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -244,6 +244,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; } @@ -851,6 +861,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; } @@ -2152,6 +2181,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; } From cab38fcd85db8dba8bfc612c067cc26c4f2d4bd4 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 29 Apr 2026 17:01:35 +0200 Subject: [PATCH 32/34] 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 2952284956f..d0eba4d5407 100644 --- a/src/Type/Constant/ConstantArrayTypeBuilder.php +++ b/src/Type/Constant/ConstantArrayTypeBuilder.php @@ -287,9 +287,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; @@ -308,61 +307,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) { @@ -409,7 +444,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); @@ -418,7 +457,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 803d37fe8c9..893c51506b5 100644 --- a/tests/PHPStan/Analyser/AnalyserIntegrationTest.php +++ b/tests/PHPStan/Analyser/AnalyserIntegrationTest.php @@ -839,7 +839,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 deac6266e54694c1c9521a2ac8da2a6f168b24a7 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Thu, 30 Apr 2026 10:35:56 +0200 Subject: [PATCH 33/34] Correctly generalize unsealed array shapes --- src/Analyser/MutatingScope.php | 24 ++++- .../Analyser/nsrt/unsealed-array-shapes.php | 93 +++++++++++++++++++ 2 files changed, 116 insertions(+), 1 deletion(-) diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 7a90c22df1b..6bf2fc99918 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -4187,8 +4187,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/nsrt/unsealed-array-shapes.php b/tests/PHPStan/Analyser/nsrt/unsealed-array-shapes.php index 89a4473d118..03281bf951b 100644 --- a/tests/PHPStan/Analyser/nsrt/unsealed-array-shapes.php +++ b/tests/PHPStan/Analyser/nsrt/unsealed-array-shapes.php @@ -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 494f35c8852fce363b8d75d4d5fd09944b56a040 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Thu, 23 Apr 2026 21:24:46 +0200 Subject: [PATCH 34/34] TMP WIP for everyone, not just bleeding edge --- src/Type/Constant/ConstantArrayType.php | 2 +- src/Type/Constant/ConstantArrayTypeBuilder.php | 7 ++----- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/src/Type/Constant/ConstantArrayType.php b/src/Type/Constant/ConstantArrayType.php index 0a1d30433f4..ee100792a1c 100644 --- a/src/Type/Constant/ConstantArrayType.php +++ b/src/Type/Constant/ConstantArrayType.php @@ -157,7 +157,7 @@ public function __construct( if ($unsealed[0] instanceof StrictMixedType && !$unsealed[0] instanceof TemplateStrictMixedType) { $unsealed[0] = (new UnionType([new StringType(), new IntegerType()]))->toArrayKey(); } - } elseif (BleedingEdgeToggle::isBleedingEdge()) { + } else { $never = new NeverType(true); $unsealed = [$never, $never]; } diff --git a/src/Type/Constant/ConstantArrayTypeBuilder.php b/src/Type/Constant/ConstantArrayTypeBuilder.php index d0eba4d5407..0f83d1bee2d 100644 --- a/src/Type/Constant/ConstantArrayTypeBuilder.php +++ b/src/Type/Constant/ConstantArrayTypeBuilder.php @@ -66,11 +66,8 @@ private function __construct( public static function createEmpty(): self { - $unsealed = null; - if (BleedingEdgeToggle::isBleedingEdge()) { - $never = new NeverType(true); - $unsealed = [$never, $never]; - } + $never = new NeverType(true); + $unsealed = [$never, $never]; return new self([], [], [0], [], TrinaryLogic::createYes(), $unsealed); }