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] 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 35536317dd6..1e8e73b043b 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 511bf6136d9..a1db3f755a2 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 00000000000..bfc5fddacda --- /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); + } +}