From 7b29df65c7bcfd95958f7ac07f99dae4a8ccb4f9 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 29 Mar 2026 00:19:22 +0000 Subject: [PATCH 1/2] Fix phpstan/phpstan#11507: false positive for invariant template with HasOffsetValueType - When setting an explicit array key on a generic array type (e.g. $item['foo'] = '100'), PHPStan adds HasOffsetValueType to the intersection, making the type structurally unequal to the base array type even though they describe the same type to the user - The invariance check in TemplateTypeVariance::isValidVariance used strict equals(), which failed due to the extra HasOffsetValueType accessory type - Added a fallback that strips HasOffsetValueType/HasOffsetType before comparing, so types differing only in offset tracking are treated as equal for invariance - New regression test in tests/PHPStan/Rules/Functions/data/bug-11507.php --- src/Type/Generic/TemplateTypeVariance.php | 21 ++++++++ .../Rules/Functions/ReturnTypeRuleTest.php | 7 +++ .../Rules/Functions/data/bug-11507.php | 50 +++++++++++++++++++ 3 files changed, 78 insertions(+) create mode 100644 tests/PHPStan/Rules/Functions/data/bug-11507.php diff --git a/src/Type/Generic/TemplateTypeVariance.php b/src/Type/Generic/TemplateTypeVariance.php index 35536317dd6..97f5d11c43c 100644 --- a/src/Type/Generic/TemplateTypeVariance.php +++ b/src/Type/Generic/TemplateTypeVariance.php @@ -5,11 +5,14 @@ use PHPStan\PhpDocParser\Ast\Type\GenericTypeNode; use PHPStan\ShouldNotHappenException; use PHPStan\TrinaryLogic; +use PHPStan\Type\Accessory\HasOffsetType; +use PHPStan\Type\Accessory\HasOffsetValueType; use PHPStan\Type\BenevolentUnionType; use PHPStan\Type\IsSuperTypeOfResult; use PHPStan\Type\MixedType; use PHPStan\Type\NeverType; use PHPStan\Type\Type; +use PHPStan\Type\TypeTraverser; use function sprintf; /** @@ -177,6 +180,13 @@ public function isValidVariance(TemplateType $templateType, Type $a, Type $b): I if ($this->invariant()) { $result = $a->equals($b); + if (!$result) { + $strippedA = self::stripHasOffsetTypes($a); + $strippedB = self::stripHasOffsetTypes($b); + if ($strippedA !== $a || $strippedB !== $b) { + $result = $strippedA->equals($strippedB); + } + } $reasons = []; if (!$result) { if ( @@ -259,4 +269,15 @@ public function toPhpDocNodeVariance(): string throw new ShouldNotHappenException(); } + private static function stripHasOffsetTypes(Type $type): Type + { + return TypeTraverser::map($type, static function (Type $type, callable $traverse): Type { + if ($type instanceof HasOffsetValueType || $type instanceof HasOffsetType) { + return new MixedType(); + } + + return $traverse($type); + }); + } + } diff --git a/tests/PHPStan/Rules/Functions/ReturnTypeRuleTest.php b/tests/PHPStan/Rules/Functions/ReturnTypeRuleTest.php index 6b63c91b88f..9b9cfefb487 100644 --- a/tests/PHPStan/Rules/Functions/ReturnTypeRuleTest.php +++ b/tests/PHPStan/Rules/Functions/ReturnTypeRuleTest.php @@ -411,4 +411,11 @@ public function testBug12397(): void $this->analyse([__DIR__ . '/data/bug-12397.php'], []); } + public function testBug11507(): void + { + $this->checkNullables = true; + $this->checkExplicitMixed = true; + $this->analyse([__DIR__ . '/data/bug-11507.php'], []); + } + } diff --git a/tests/PHPStan/Rules/Functions/data/bug-11507.php b/tests/PHPStan/Rules/Functions/data/bug-11507.php new file mode 100644 index 00000000000..8b10076dfa6 --- /dev/null +++ b/tests/PHPStan/Rules/Functions/data/bug-11507.php @@ -0,0 +1,50 @@ += 8.0 + +declare(strict_types = 1); + +namespace Bug11507; + +/** + * @template TValue + */ +class Collection +{ + /** + * @param array $items + */ + public function __construct( + public array $items, + ) {} + + /** + * Run a map over each of the items. + * + * @template TMapValue + * + * @param callable(TValue, int=): TMapValue $callback + * @return Collection + */ + public function map(callable $callback): Collection + { + $keys = array_keys($this->items); + + $items = array_map($callback, $this->items); + + $result = array_combine($keys, $items); + + return new self($result); + } +} + +/** + * @param Collection> $collection + * @return Collection> + */ +function foo(Collection $collection): Collection +{ + return $collection->map(function (array $item) { + $item['foo'] = '100'; + + return $item; + }); +} From 9f0b709d474113ff87bd9abdebaea4ff9487875f Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Sun, 29 Mar 2026 07:17:37 +0000 Subject: [PATCH 2/2] Move HasOffset type stripping from isValidVariance to generalizeInferredTemplateType Instead of working around the equals check in TemplateTypeVariance::isValidVariance(), strip HasOffsetValueType and HasOffsetType during template type generalization in TemplateTypeHelper::generalizeInferredTemplateType(). This normalizes inferred template types before they reach the invariance check, so equals() naturally returns true without needing special handling. Changing IntersectionType::equals() globally was investigated but breaks scope tracking and type narrowing (introduces false positives in PHPStan self-analysis). The generalization approach is more targeted: it only affects inferred template type arguments for non-covariant templates, which is the correct normalization point for this precision. Co-Authored-By: Claude Opus 4.6 --- src/Type/Generic/TemplateTypeHelper.php | 11 +++++++++++ src/Type/Generic/TemplateTypeVariance.php | 21 --------------------- 2 files changed, 11 insertions(+), 21 deletions(-) diff --git a/src/Type/Generic/TemplateTypeHelper.php b/src/Type/Generic/TemplateTypeHelper.php index 6f09c29ccb2..f5a8c6715aa 100644 --- a/src/Type/Generic/TemplateTypeHelper.php +++ b/src/Type/Generic/TemplateTypeHelper.php @@ -3,8 +3,11 @@ namespace PHPStan\Type\Generic; use PHPStan\Reflection\ParametersAcceptor; +use PHPStan\Type\Accessory\HasOffsetType; +use PHPStan\Type\Accessory\HasOffsetValueType; use PHPStan\Type\ErrorType; use PHPStan\Type\GeneralizePrecision; +use PHPStan\Type\MixedType; use PHPStan\Type\NonAcceptingNeverType; use PHPStan\Type\Type; use PHPStan\Type\TypeTraverser; @@ -147,6 +150,14 @@ public static function generalizeInferredTemplateType(TemplateType $templateType } elseif ($type->isConstantValue()->yes() && (!$templateType->getBound()->isScalar()->yes() || $isArrayKey)) { $type = $type->generalize(GeneralizePrecision::templateArgument()); } + + $type = TypeTraverser::map($type, static function (Type $type, callable $traverse): Type { + if ($type instanceof HasOffsetValueType || $type instanceof HasOffsetType) { + return new MixedType(); + } + + return $traverse($type); + }); } return $type; diff --git a/src/Type/Generic/TemplateTypeVariance.php b/src/Type/Generic/TemplateTypeVariance.php index 97f5d11c43c..35536317dd6 100644 --- a/src/Type/Generic/TemplateTypeVariance.php +++ b/src/Type/Generic/TemplateTypeVariance.php @@ -5,14 +5,11 @@ use PHPStan\PhpDocParser\Ast\Type\GenericTypeNode; use PHPStan\ShouldNotHappenException; use PHPStan\TrinaryLogic; -use PHPStan\Type\Accessory\HasOffsetType; -use PHPStan\Type\Accessory\HasOffsetValueType; use PHPStan\Type\BenevolentUnionType; use PHPStan\Type\IsSuperTypeOfResult; use PHPStan\Type\MixedType; use PHPStan\Type\NeverType; use PHPStan\Type\Type; -use PHPStan\Type\TypeTraverser; use function sprintf; /** @@ -180,13 +177,6 @@ public function isValidVariance(TemplateType $templateType, Type $a, Type $b): I if ($this->invariant()) { $result = $a->equals($b); - if (!$result) { - $strippedA = self::stripHasOffsetTypes($a); - $strippedB = self::stripHasOffsetTypes($b); - if ($strippedA !== $a || $strippedB !== $b) { - $result = $strippedA->equals($strippedB); - } - } $reasons = []; if (!$result) { if ( @@ -269,15 +259,4 @@ public function toPhpDocNodeVariance(): string throw new ShouldNotHappenException(); } - private static function stripHasOffsetTypes(Type $type): Type - { - return TypeTraverser::map($type, static function (Type $type, callable $traverse): Type { - if ($type instanceof HasOffsetValueType || $type instanceof HasOffsetType) { - return new MixedType(); - } - - return $traverse($type); - }); - } - }