From 63ad6263f798c2903327ce2dc349044ccb450c15 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 27 Feb 2026 15:17:01 +0000 Subject: [PATCH 1/6] Fix false positive invariance check for template types with same identity - TemplateMixedType and TemplateStrictMixedType with same scope+name represent the same template parameter but fail equals() due to different concrete classes - At level 9+ transformCommonType converts TemplateMixedType to TemplateStrictMixedType in accepting type but not in accepted closure params - Added fallback check in isValidVariance() for invariant templates: if both sides are TemplateType with matching scope and name, treat as equal - New regression test in tests/PHPStan/Rules/Classes/data/bug-13440.php Closes https://github.com/phpstan/phpstan/issues/13440 --- src/Type/Generic/TemplateTypeVariance.php | 9 +++++ .../Rules/Classes/InstantiationRuleTest.php | 11 +++++- .../PHPStan/Rules/Classes/data/bug-13440.php | 37 +++++++++++++++++++ 3 files changed, 56 insertions(+), 1 deletion(-) create mode 100644 tests/PHPStan/Rules/Classes/data/bug-13440.php diff --git a/src/Type/Generic/TemplateTypeVariance.php b/src/Type/Generic/TemplateTypeVariance.php index 35536317dd..1e8e73b043 100644 --- a/src/Type/Generic/TemplateTypeVariance.php +++ b/src/Type/Generic/TemplateTypeVariance.php @@ -177,6 +177,15 @@ public function isValidVariance(TemplateType $templateType, Type $a, Type $b): I if ($this->invariant()) { $result = $a->equals($b); + if ( + !$result + && $a instanceof TemplateType + && $b instanceof TemplateType + && $a->getScope()->equals($b->getScope()) + && $a->getName() === $b->getName() + ) { + $result = true; + } $reasons = []; if (!$result) { if ( diff --git a/tests/PHPStan/Rules/Classes/InstantiationRuleTest.php b/tests/PHPStan/Rules/Classes/InstantiationRuleTest.php index 511bf6136d..a1db3f755a 100644 --- a/tests/PHPStan/Rules/Classes/InstantiationRuleTest.php +++ b/tests/PHPStan/Rules/Classes/InstantiationRuleTest.php @@ -20,6 +20,8 @@ class InstantiationRuleTest extends RuleTestCase { + private bool $checkExplicitMixed = false; + protected function getRule(): Rule { $reflectionProvider = self::createReflectionProvider(); @@ -27,7 +29,7 @@ protected function getRule(): Rule return new InstantiationRule( $container, $reflectionProvider, - new FunctionCallParametersCheck(new RuleLevelHelper($reflectionProvider, true, false, true, false, false, false, true), new NullsafeCheck(), new UnresolvableTypeHelper(), new PropertyReflectionFinder(), $reflectionProvider, true, true, true, true), + new FunctionCallParametersCheck(new RuleLevelHelper($reflectionProvider, true, false, true, $this->checkExplicitMixed, false, false, true), new NullsafeCheck(), new UnresolvableTypeHelper(), new PropertyReflectionFinder(), $reflectionProvider, true, true, true, true), new ClassNameCheck( new ClassCaseSensitivityCheck($reflectionProvider, true), new ClassForbiddenNameCheck($container), @@ -570,6 +572,13 @@ public function testBug14097(): void $this->analyse([__DIR__ . '/data/bug-14097.php'], []); } + #[RequiresPhp('>= 8.0')] + public function testBug13440(): void + { + $this->checkExplicitMixed = true; + $this->analyse([__DIR__ . '/data/bug-13440.php'], []); + } + public function testNewStaticWithConsistentConstructor(): void { $this->analyse([__DIR__ . '/data/instantiation-new-static-consistent-constructor.php'], [ diff --git a/tests/PHPStan/Rules/Classes/data/bug-13440.php b/tests/PHPStan/Rules/Classes/data/bug-13440.php new file mode 100644 index 0000000000..bfc5fddacd --- /dev/null +++ b/tests/PHPStan/Rules/Classes/data/bug-13440.php @@ -0,0 +1,37 @@ += 8.0 + +declare(strict_types = 1); + +namespace Bug13440; + +use Closure; + +/** @template T */ +interface Foo {} + +/** + * @template TVal + * @template TReturn + */ +class Box +{ + /** + * @param TVal $val + * @param Closure(Foo): TReturn $cb + */ + public function __construct( + private mixed $val, + private Closure $cb, + ) { + } + + /** + * @template TNewReturn + * @param Closure(Foo): TNewReturn $cb + * @return self + */ + public function test(Closure $cb): self + { + return new self($this->val, $cb); + } +} From 122b06b1d3758f6fe0bf9a0326143496b4fcebf9 Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Sat, 28 Feb 2026 12:55:34 +0100 Subject: [PATCH 2/6] Rework --- src/Rules/RuleLevelHelper.php | 12 ++++++------ src/Type/Generic/TemplateTypeVariance.php | 9 --------- 2 files changed, 6 insertions(+), 15 deletions(-) diff --git a/src/Rules/RuleLevelHelper.php b/src/Rules/RuleLevelHelper.php index 1b46ccfbe7..247af92458 100644 --- a/src/Rules/RuleLevelHelper.php +++ b/src/Rules/RuleLevelHelper.php @@ -93,15 +93,15 @@ private function transformAcceptedType(Type $acceptingType, Type $acceptedType): return $acceptedType; } - return new CallableType( + return $this->transformCommonType(new CallableType( $acceptedType->getParameters(), - $traverse($this->transformCommonType($acceptedType->getReturnType())), + $traverse($acceptedType->getReturnType()), $acceptedType->isVariadic(), $acceptedType->getTemplateTypeMap(), $acceptedType->getResolvedTemplateTypeMap(), $acceptedType->getTemplateTags(), $acceptedType->isPure(), - ); + )); } if ($acceptedType instanceof ClosureType) { @@ -109,9 +109,9 @@ private function transformAcceptedType(Type $acceptingType, Type $acceptedType): return $acceptedType; } - return new ClosureType( + return $this->transformCommonType(new ClosureType( $acceptedType->getParameters(), - $traverse($this->transformCommonType($acceptedType->getReturnType())), + $traverse($acceptedType->getReturnType()), $acceptedType->isVariadic(), $acceptedType->getTemplateTypeMap(), $acceptedType->getResolvedTemplateTypeMap(), @@ -123,7 +123,7 @@ private function transformAcceptedType(Type $acceptingType, Type $acceptedType): $acceptedType->getUsedVariables(), $acceptedType->acceptsNamedArguments(), $acceptedType->mustUseReturnValue(), - ); + )); } if ( diff --git a/src/Type/Generic/TemplateTypeVariance.php b/src/Type/Generic/TemplateTypeVariance.php index 1e8e73b043..35536317dd 100644 --- a/src/Type/Generic/TemplateTypeVariance.php +++ b/src/Type/Generic/TemplateTypeVariance.php @@ -177,15 +177,6 @@ public function isValidVariance(TemplateType $templateType, Type $a, Type $b): I if ($this->invariant()) { $result = $a->equals($b); - if ( - !$result - && $a instanceof TemplateType - && $b instanceof TemplateType - && $a->getScope()->equals($b->getScope()) - && $a->getName() === $b->getName() - ) { - $result = true; - } $reasons = []; if (!$result) { if ( From 384533888338f163a96be1cc4edac438a8241aa4 Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Sat, 28 Feb 2026 13:09:01 +0100 Subject: [PATCH 3/6] Simplify --- src/Rules/RuleLevelHelper.php | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/Rules/RuleLevelHelper.php b/src/Rules/RuleLevelHelper.php index 247af92458..9c05539935 100644 --- a/src/Rules/RuleLevelHelper.php +++ b/src/Rules/RuleLevelHelper.php @@ -93,7 +93,7 @@ private function transformAcceptedType(Type $acceptingType, Type $acceptedType): return $acceptedType; } - return $this->transformCommonType(new CallableType( + return new CallableType( $acceptedType->getParameters(), $traverse($acceptedType->getReturnType()), $acceptedType->isVariadic(), @@ -101,7 +101,7 @@ private function transformAcceptedType(Type $acceptingType, Type $acceptedType): $acceptedType->getResolvedTemplateTypeMap(), $acceptedType->getTemplateTags(), $acceptedType->isPure(), - )); + ); } if ($acceptedType instanceof ClosureType) { @@ -109,7 +109,7 @@ private function transformAcceptedType(Type $acceptingType, Type $acceptedType): return $acceptedType; } - return $this->transformCommonType(new ClosureType( + return new ClosureType( $acceptedType->getParameters(), $traverse($acceptedType->getReturnType()), $acceptedType->isVariadic(), @@ -123,7 +123,7 @@ private function transformAcceptedType(Type $acceptingType, Type $acceptedType): $acceptedType->getUsedVariables(), $acceptedType->acceptsNamedArguments(), $acceptedType->mustUseReturnValue(), - )); + ); } if ( @@ -142,10 +142,10 @@ private function transformAcceptedType(Type $acceptingType, Type $acceptedType): } } - return $traverse($this->transformCommonType($acceptedType)); + return $traverse($acceptedType); }); - return [$acceptedType, $checkForUnion]; + return [$this->transformCommonType($acceptedType), $checkForUnion]; } /** @api */ From e4b026a5f004041d03cb8b6da9d52ddfc152cc03 Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Sat, 28 Feb 2026 13:25:53 +0100 Subject: [PATCH 4/6] Add test --- .../PHPStan/Rules/Classes/data/bug-13440.php | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/tests/PHPStan/Rules/Classes/data/bug-13440.php b/tests/PHPStan/Rules/Classes/data/bug-13440.php index bfc5fddacd..dedd1b08fb 100644 --- a/tests/PHPStan/Rules/Classes/data/bug-13440.php +++ b/tests/PHPStan/Rules/Classes/data/bug-13440.php @@ -35,3 +35,30 @@ public function test(Closure $cb): self return new self($this->val, $cb); } } + +/** + * @template TVal + * @template TReturn + */ +class Box2 +{ + /** + * @param TVal $val + * @param callable(Foo): TReturn $cb + */ + public function __construct( + private mixed $val, + private $cb, + ) { + } + + /** + * @template TNewReturn + * @param callable(Foo): TNewReturn $cb + * @return self + */ + public function test($cb): self + { + return new self($this->val, $cb); + } +} From 4692e261c1091d6899fd23d0af5b2613bd8acc6f Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Sun, 1 Mar 2026 12:11:44 +0100 Subject: [PATCH 5/6] Add non regression test --- .../TypesAssignedToPropertiesRuleTest.php | 7 +++ .../Rules/Properties/data/bug-12688.php | 54 +++++++++++++++++++ 2 files changed, 61 insertions(+) create mode 100644 tests/PHPStan/Rules/Properties/data/bug-12688.php diff --git a/tests/PHPStan/Rules/Properties/TypesAssignedToPropertiesRuleTest.php b/tests/PHPStan/Rules/Properties/TypesAssignedToPropertiesRuleTest.php index cf7136dd1f..3748a787c2 100644 --- a/tests/PHPStan/Rules/Properties/TypesAssignedToPropertiesRuleTest.php +++ b/tests/PHPStan/Rules/Properties/TypesAssignedToPropertiesRuleTest.php @@ -1025,6 +1025,13 @@ public function testBug12250(): void $this->analyse([__DIR__ . '/data/bug-12250.php'], []); } + public function testBug12688(): void + { + $this->checkExplicitMixed = true; + $this->checkImplicitMixed = true; + $this->analyse([__DIR__ . '/data/bug-12688.php'], []); + } + public function testBug4525(): void { $this->analyse([__DIR__ . '/data/bug-4525.php'], []); diff --git a/tests/PHPStan/Rules/Properties/data/bug-12688.php b/tests/PHPStan/Rules/Properties/data/bug-12688.php new file mode 100644 index 0000000000..6d3b01c64b --- /dev/null +++ b/tests/PHPStan/Rules/Properties/data/bug-12688.php @@ -0,0 +1,54 @@ += 8.1 + +namespace Bug12688; + +/** + * @template T = mixed + */ +interface I {} + +/** + * @implements I + */ +enum E implements I +{ + case E; +} + +/** + * @template T + */ +final class TemplateWithoutDefaultWorks +{ + /** + * @var I + */ + public readonly I $i; + + /** + * @param I $i + */ + public function __construct(I $i = E::E) + { + $this->i = $i; + } +} + +/** + * @template T = mixed + */ +final class TemplateWithDefaultDoesNotWork +{ + /** + * @var I + */ + public readonly I $i; + + /** + * @param I $i + */ + public function __construct(I $i = E::E) + { + $this->i = $i; + } +} From f29c09c72c5d15bf98e40e4ef59f8b5f9f8ab75d Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Sun, 1 Mar 2026 12:18:31 +0100 Subject: [PATCH 6/6] Fix build on 7.4 --- .../Rules/Properties/TypesAssignedToPropertiesRuleTest.php | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/PHPStan/Rules/Properties/TypesAssignedToPropertiesRuleTest.php b/tests/PHPStan/Rules/Properties/TypesAssignedToPropertiesRuleTest.php index 3748a787c2..6c03bc86c3 100644 --- a/tests/PHPStan/Rules/Properties/TypesAssignedToPropertiesRuleTest.php +++ b/tests/PHPStan/Rules/Properties/TypesAssignedToPropertiesRuleTest.php @@ -1025,6 +1025,7 @@ public function testBug12250(): void $this->analyse([__DIR__ . '/data/bug-12250.php'], []); } + #[RequiresPhp('>= 8.0')] public function testBug12688(): void { $this->checkExplicitMixed = true;