From 43dc704192ca43157cbd529744c5da14e65494f5 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 27 Feb 2026 14:33:10 +0000 Subject: [PATCH 1/3] Fix BenevolentUnionType with generics producing false positive errors - Fixed ArrayType constructor converting StrictMixedType key to regular UnionType instead of BenevolentUnionType, which caused loss of benevolent semantics during RuleLevelHelper type transformation - Added BenevolentUnionType handling in TemplateTypeTrait::inferTemplateTypes() to filter matching types from a benevolent union when resolving template bounds - New regression test in tests/PHPStan/Rules/Methods/data/bug-9732.php Closes https://github.com/phpstan/phpstan/issues/9732 --- src/Type/ArrayType.php | 2 +- src/Type/Generic/TemplateTypeTrait.php | 19 +++++++++++++++ .../Methods/CallStaticMethodsRuleTest.php | 9 ++++++++ tests/PHPStan/Rules/Methods/data/bug-9732.php | 23 +++++++++++++++++++ 4 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 tests/PHPStan/Rules/Methods/data/bug-9732.php diff --git a/src/Type/ArrayType.php b/src/Type/ArrayType.php index f6550a5845..1398a46bbc 100644 --- a/src/Type/ArrayType.php +++ b/src/Type/ArrayType.php @@ -54,7 +54,7 @@ public function __construct(Type $keyType, private Type $itemType) $keyType = new MixedType(); } if ($keyType instanceof StrictMixedType && !$keyType instanceof TemplateStrictMixedType) { - $keyType = new UnionType([new StringType(), new IntegerType()]); + $keyType = new BenevolentUnionType([new IntegerType(), new StringType()]); } $this->keyType = $keyType; diff --git a/src/Type/Generic/TemplateTypeTrait.php b/src/Type/Generic/TemplateTypeTrait.php index a1f381e6f5..8fdbe5dd28 100644 --- a/src/Type/Generic/TemplateTypeTrait.php +++ b/src/Type/Generic/TemplateTypeTrait.php @@ -6,6 +6,7 @@ use PHPStan\PhpDocParser\Ast\Type\TypeNode; use PHPStan\TrinaryLogic; use PHPStan\Type\AcceptsResult; +use PHPStan\Type\BenevolentUnionType; use PHPStan\Type\ErrorType; use PHPStan\Type\IntersectionType; use PHPStan\Type\IsSuperTypeOfResult; @@ -18,6 +19,7 @@ use PHPStan\Type\TypeUtils; use PHPStan\Type\UnionType; use PHPStan\Type\VerbosityLevel; +use function count; use function sprintf; /** @@ -287,6 +289,23 @@ public function inferTemplateTypes(Type $receivedType): TemplateTypeMap ]))->union($map); } + if ($receivedType instanceof BenevolentUnionType) { + $matchingTypes = []; + foreach ($receivedType->getTypes() as $innerType) { + if (!$resolvedBound->isSuperTypeOf($innerType)->yes()) { + continue; + } + + $matchingTypes[] = $innerType; + } + if (count($matchingTypes) > 0) { + $filteredType = TypeCombinator::union(...$matchingTypes); + return (new TemplateTypeMap([ + $this->name => $filteredType, + ]))->union($map); + } + } + return $map; } diff --git a/tests/PHPStan/Rules/Methods/CallStaticMethodsRuleTest.php b/tests/PHPStan/Rules/Methods/CallStaticMethodsRuleTest.php index 3ecba00c33..ad0fdcf2b6 100644 --- a/tests/PHPStan/Rules/Methods/CallStaticMethodsRuleTest.php +++ b/tests/PHPStan/Rules/Methods/CallStaticMethodsRuleTest.php @@ -931,6 +931,15 @@ public function testBug13556(): void $this->analyse([__DIR__ . '/data/bug-13556.php'], []); } + public function testBug9732(): void + { + $this->checkThisOnly = false; + $this->checkExplicitMixed = true; + $this->checkImplicitMixed = true; + + $this->analyse([__DIR__ . '/data/bug-9732.php'], []); + } + #[RequiresPhp('>= 8.5')] public function testPipeOperator(): void { diff --git a/tests/PHPStan/Rules/Methods/data/bug-9732.php b/tests/PHPStan/Rules/Methods/data/bug-9732.php new file mode 100644 index 0000000000..292649803f --- /dev/null +++ b/tests/PHPStan/Rules/Methods/data/bug-9732.php @@ -0,0 +1,23 @@ + $array + * @phpstan-return \Generator + */ + public static function stringifyKeys(array $array) : \Generator{ + foreach($array as $key => $value){ + yield (string) $key => $value; + } + } + + public function sayHello(): void + { + self::stringifyKeys($GLOBALS); + } +} From 2f36068d4c22f2da0d2ed218b0780bd061c42627 Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Sat, 28 Feb 2026 11:25:56 +0100 Subject: [PATCH 2/3] Rework --- src/Type/ArrayType.php | 2 +- src/Type/Generic/TemplateTypeTrait.php | 3 +-- tests/PHPStan/Rules/Methods/CallStaticMethodsRuleTest.php | 7 ++++++- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/Type/ArrayType.php b/src/Type/ArrayType.php index 1398a46bbc..f6550a5845 100644 --- a/src/Type/ArrayType.php +++ b/src/Type/ArrayType.php @@ -54,7 +54,7 @@ public function __construct(Type $keyType, private Type $itemType) $keyType = new MixedType(); } if ($keyType instanceof StrictMixedType && !$keyType instanceof TemplateStrictMixedType) { - $keyType = new BenevolentUnionType([new IntegerType(), new StringType()]); + $keyType = new UnionType([new StringType(), new IntegerType()]); } $this->keyType = $keyType; diff --git a/src/Type/Generic/TemplateTypeTrait.php b/src/Type/Generic/TemplateTypeTrait.php index 8fdbe5dd28..aa6b5e17c0 100644 --- a/src/Type/Generic/TemplateTypeTrait.php +++ b/src/Type/Generic/TemplateTypeTrait.php @@ -6,7 +6,6 @@ use PHPStan\PhpDocParser\Ast\Type\TypeNode; use PHPStan\TrinaryLogic; use PHPStan\Type\AcceptsResult; -use PHPStan\Type\BenevolentUnionType; use PHPStan\Type\ErrorType; use PHPStan\Type\IntersectionType; use PHPStan\Type\IsSuperTypeOfResult; @@ -289,7 +288,7 @@ public function inferTemplateTypes(Type $receivedType): TemplateTypeMap ]))->union($map); } - if ($receivedType instanceof BenevolentUnionType) { + if ($receivedType instanceof UnionType) { $matchingTypes = []; foreach ($receivedType->getTypes() as $innerType) { if (!$resolvedBound->isSuperTypeOf($innerType)->yes()) { diff --git a/tests/PHPStan/Rules/Methods/CallStaticMethodsRuleTest.php b/tests/PHPStan/Rules/Methods/CallStaticMethodsRuleTest.php index ad0fdcf2b6..a4dcbc0b7b 100644 --- a/tests/PHPStan/Rules/Methods/CallStaticMethodsRuleTest.php +++ b/tests/PHPStan/Rules/Methods/CallStaticMethodsRuleTest.php @@ -937,7 +937,12 @@ public function testBug9732(): void $this->checkExplicitMixed = true; $this->checkImplicitMixed = true; - $this->analyse([__DIR__ . '/data/bug-9732.php'], []); + $this->analyse([__DIR__ . '/data/bug-9732.php'], [ + [ + 'Parameter #1 $array of static method Bug9732\HelloWorld::stringifyKeys() expects array, array given.', + 21, + ], + ]); } #[RequiresPhp('>= 8.5')] From 1d71ee0e8b63bf622fa2f21088c20199f64b23df Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Sun, 1 Mar 2026 22:26:16 +0100 Subject: [PATCH 3/3] Add test --- .../Methods/CallStaticMethodsRuleTest.php | 18 +++++++ .../PHPStan/Rules/Methods/data/bug-12558.php | 48 +++++++++++++++++++ 2 files changed, 66 insertions(+) create mode 100644 tests/PHPStan/Rules/Methods/data/bug-12558.php diff --git a/tests/PHPStan/Rules/Methods/CallStaticMethodsRuleTest.php b/tests/PHPStan/Rules/Methods/CallStaticMethodsRuleTest.php index a4dcbc0b7b..82ee7e1566 100644 --- a/tests/PHPStan/Rules/Methods/CallStaticMethodsRuleTest.php +++ b/tests/PHPStan/Rules/Methods/CallStaticMethodsRuleTest.php @@ -945,6 +945,24 @@ public function testBug9732(): void ]); } + public function testBug12558(): void + { + $this->checkThisOnly = false; + $this->checkExplicitMixed = true; + $this->checkImplicitMixed = true; + + $this->analyse([__DIR__ . '/data/bug-12558.php'], [ + [ + 'Parameter #1 $object of static method Bug12558\Foo::assertStatic() expects Bug12558\Foo, Bug12558\Foo|null given.', + 45, + ], + [ + 'Parameter #1 $object of static method Bug12558\Foo::assertStatic() expects Bug12558\Foo, bool|Bug12558\Foo given.', + 46, + ], + ]); + } + #[RequiresPhp('>= 8.5')] public function testPipeOperator(): void { diff --git a/tests/PHPStan/Rules/Methods/data/bug-12558.php b/tests/PHPStan/Rules/Methods/data/bug-12558.php new file mode 100644 index 0000000000..88a8224924 --- /dev/null +++ b/tests/PHPStan/Rules/Methods/data/bug-12558.php @@ -0,0 +1,48 @@ +createFoo())->foo(); + (static::class)::assertStatic($this->createFooNullable())->foo(); + (static::class)::assertStatic($this->createFooUnionedWithBool())->foo(); + } +}