From 461aa135d9a44b6e65ee45f833cd1b00b67f86b0 Mon Sep 17 00:00:00 2001 From: phpstan-bot <79867460+phpstan-bot@users.noreply.github.com> Date: Wed, 25 Mar 2026 12:44:14 +0000 Subject: [PATCH 1/4] Fix phpstan/phpstan#14138: argument.type errors not reported for template types on promoted properties - Promoted property @var T types were not resolved against the class template type map when the constructor had no phpDoc of its own - Moved template type resolution of phpDocParameterTypes outside the `if ($resolvedPhpDoc !== null)` block in PhpClassReflectionExtension::createUserlandMethodReflection - New regression test in tests/PHPStan/Rules/Classes/data/bug-14138.php - The root cause was that the phpDoc inheritance rework (617a3a8a5) made resolvePhpDocForMethod return null for constructors without doc comments, causing the template resolution to be skipped --- .../Php/PhpClassReflectionExtension.php | 17 +++++---- .../Rules/Classes/InstantiationRuleTest.php | 12 ++++++ .../PHPStan/Rules/Classes/data/bug-14138.php | 37 +++++++++++++++++++ 3 files changed, 58 insertions(+), 8 deletions(-) create mode 100644 tests/PHPStan/Rules/Classes/data/bug-14138.php diff --git a/src/Reflection/Php/PhpClassReflectionExtension.php b/src/Reflection/Php/PhpClassReflectionExtension.php index e471943e87c..47049f5ff17 100644 --- a/src/Reflection/Php/PhpClassReflectionExtension.php +++ b/src/Reflection/Php/PhpClassReflectionExtension.php @@ -891,14 +891,6 @@ public function createUserlandMethodReflection(ClassReflection $fileDeclaringCla } $phpDocParameterTypes[$paramName] = $paramTag->getType(); } - foreach ($phpDocParameterTypes as $paramName => $paramType) { - $phpDocParameterTypes[$paramName] = TemplateTypeHelper::resolveTemplateTypes( - $paramType, - $phpDocBlockClassReflection->getActiveTemplateTypeMap(), - $phpDocBlockClassReflection->getCallSiteVarianceMap(), - TemplateTypeVariance::createContravariant(), - ); - } foreach ($resolvedPhpDoc->getParamOutTags() as $paramName => $paramOutTag) { $phpDocParameterOutTypes[$paramName] = TemplateTypeHelper::resolveTemplateTypes( $paramOutTag->getType(), @@ -938,6 +930,15 @@ public function createUserlandMethodReflection(ClassReflection $fileDeclaringCla } } + foreach ($phpDocParameterTypes as $paramName => $paramType) { + $phpDocParameterTypes[$paramName] = TemplateTypeHelper::resolveTemplateTypes( + $paramType, + $phpDocBlockClassReflection->getActiveTemplateTypeMap(), + $phpDocBlockClassReflection->getCallSiteVarianceMap(), + TemplateTypeVariance::createContravariant(), + ); + } + return $this->methodReflectionFactory->create( $actualDeclaringClass, $declaringTrait, diff --git a/tests/PHPStan/Rules/Classes/InstantiationRuleTest.php b/tests/PHPStan/Rules/Classes/InstantiationRuleTest.php index 8558296e149..9232d2bc296 100644 --- a/tests/PHPStan/Rules/Classes/InstantiationRuleTest.php +++ b/tests/PHPStan/Rules/Classes/InstantiationRuleTest.php @@ -615,4 +615,16 @@ public function testBug11006(): void $this->analyse([__DIR__ . '/data/bug-11006.php'], []); } + #[RequiresPhp('>= 8.0')] + public function testBug14138(): void + { + $this->analyse([__DIR__ . '/data/bug-14138.php'], [ + [ + 'Parameter #1 $data of class Bug14138\Foo constructor expects array{foo: int, bar: int}, array{foo: 1} given.', + 36, + "Array does not have offset 'bar'.", + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Classes/data/bug-14138.php b/tests/PHPStan/Rules/Classes/data/bug-14138.php new file mode 100644 index 00000000000..185e16b309a --- /dev/null +++ b/tests/PHPStan/Rules/Classes/data/bug-14138.php @@ -0,0 +1,37 @@ += 8.0 + +declare(strict_types = 1); + +namespace Bug14138; + +/** + * @template T of array + */ +abstract class AbstractApiData +{ + public function __construct( + /** @var T */ + protected array $data + ) {} + + /** + * @return T + */ + public function getData(): array + { + return $this->data; + } +} + + +/** + * @extends AbstractApiData + */ +class Foo extends AbstractApiData {} + +function testing(): void { + $a = new Foo(["foo" => 1]); +} From dbef03cc65f7300617023ceb58214dae627c82b0 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Wed, 25 Mar 2026 13:38:18 +0000 Subject: [PATCH 2/4] Move class-level isPure check outside the resolvedPhpDoc null check The class-level purity check (areAllMethodsPure/areAllMethodsImpure) reads from phpDocBlockClassReflection->getResolvedPhpDoc(), not from the method's resolvedPhpDoc. It should run even when resolvedPhpDoc is null (e.g. constructors with promoted properties and no method doc). Co-Authored-By: Claude Opus 4.6 --- .../Php/PhpClassReflectionExtension.php | 33 ++++++++++--------- 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/src/Reflection/Php/PhpClassReflectionExtension.php b/src/Reflection/Php/PhpClassReflectionExtension.php index 47049f5ff17..fccce2fa772 100644 --- a/src/Reflection/Php/PhpClassReflectionExtension.php +++ b/src/Reflection/Php/PhpClassReflectionExtension.php @@ -906,22 +906,6 @@ public function createUserlandMethodReflection(ClassReflection $fileDeclaringCla $isInternal = $resolvedPhpDoc->isInternal(); $isFinal = $resolvedPhpDoc->isFinal(); $isPure ??= $resolvedPhpDoc->isPure(); - if ($isPure === null) { - $classResolvedPhpDoc = $phpDocBlockClassReflection->getResolvedPhpDoc(); - if ($classResolvedPhpDoc !== null && $classResolvedPhpDoc->areAllMethodsPure()) { - if ( - strtolower($methodReflection->getName()) === '__construct' - || ( - ($phpDocReturnType === null || !$phpDocReturnType->isVoid()->yes()) - && !$nativeReturnType->isVoid()->yes() - ) - ) { - $isPure = true; - } - } elseif ($classResolvedPhpDoc !== null && $classResolvedPhpDoc->areAllMethodsImpure()) { - $isPure = false; - } - } $asserts = Assertions::createFromResolvedPhpDocBlock($resolvedPhpDoc); $acceptsNamedArguments = $resolvedPhpDoc->acceptsNamedArguments(); $selfOutType = $resolvedPhpDoc->getSelfOutTag() !== null ? $resolvedPhpDoc->getSelfOutTag()->getType() : null; @@ -930,6 +914,23 @@ public function createUserlandMethodReflection(ClassReflection $fileDeclaringCla } } + if ($isPure === null) { + $classResolvedPhpDoc = $phpDocBlockClassReflection->getResolvedPhpDoc(); + if ($classResolvedPhpDoc !== null && $classResolvedPhpDoc->areAllMethodsPure()) { + if ( + strtolower($methodReflection->getName()) === '__construct' + || ( + ($phpDocReturnType === null || !$phpDocReturnType->isVoid()->yes()) + && !$nativeReturnType->isVoid()->yes() + ) + ) { + $isPure = true; + } + } elseif ($classResolvedPhpDoc !== null && $classResolvedPhpDoc->areAllMethodsImpure()) { + $isPure = false; + } + } + foreach ($phpDocParameterTypes as $paramName => $paramType) { $phpDocParameterTypes[$paramName] = TemplateTypeHelper::resolveTemplateTypes( $paramType, From 15eabe338cb64c6401aae215499292cfe3d7e28d Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Wed, 25 Mar 2026 13:58:49 +0000 Subject: [PATCH 3/4] Add regression test for class-level isPure check with promoted properties Verifies that @phpstan-all-methods-pure correctly propagates to methods in classes with promoted constructor properties, even when the method's $resolvedPhpDoc is null. This is a non-regression test for the isPure condition moved outside the `if ($resolvedPhpDoc !== null)` block. Co-Authored-By: Claude Opus 4.6 --- .../PHPStan/Rules/Pure/PureMethodRuleTest.php | 12 +++++++ .../Rules/Pure/data/bug-14138-pure.php | 34 +++++++++++++++++++ 2 files changed, 46 insertions(+) create mode 100644 tests/PHPStan/Rules/Pure/data/bug-14138-pure.php diff --git a/tests/PHPStan/Rules/Pure/PureMethodRuleTest.php b/tests/PHPStan/Rules/Pure/PureMethodRuleTest.php index 0aa586868ed..5a059b672f0 100644 --- a/tests/PHPStan/Rules/Pure/PureMethodRuleTest.php +++ b/tests/PHPStan/Rules/Pure/PureMethodRuleTest.php @@ -279,6 +279,18 @@ public function testAllMethodsArePure(): void ]); } + #[RequiresPhp('>= 8.0')] + public function testBug14138Pure(): void + { + $this->treatPhpDocTypesAsCertain = true; + $this->analyse([__DIR__ . '/data/bug-14138-pure.php'], [ + [ + 'Impure echo in pure method Bug14138Pure\PureClassWithSideEffect::doSomething().', + 31, + ], + ]); + } + public function testBug12382(): void { $this->treatPhpDocTypesAsCertain = true; diff --git a/tests/PHPStan/Rules/Pure/data/bug-14138-pure.php b/tests/PHPStan/Rules/Pure/data/bug-14138-pure.php new file mode 100644 index 00000000000..9d4f5425a03 --- /dev/null +++ b/tests/PHPStan/Rules/Pure/data/bug-14138-pure.php @@ -0,0 +1,34 @@ += 8.0 + +namespace Bug14138Pure; + +/** + * @phpstan-all-methods-pure + */ +class PureClassWithPromotedProperties +{ + public function __construct( + protected int $value + ) {} + + public function getValue(): int + { + return $this->value; + } +} + +/** + * @phpstan-all-methods-pure + */ +class PureClassWithSideEffect +{ + public function __construct( + protected int $value + ) {} + + public function doSomething(): int + { + echo 'side effect'; + return $this->value; + } +} From 7b59366ea34044bcd3dc071f06a6e9f1acb9fc0a Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Thu, 26 Mar 2026 07:30:55 +0000 Subject: [PATCH 4/4] Fix isPure regression test to actually test the reflection-layer fix The previous test passed even without the fix because PureMethodRule gets purity from NodeScopeResolver (which already had the class-level check outside the null block). The new test verifies the fix at the reflection layer: a @phpstan-pure method instantiating a class with @phpstan-all-methods-pure and promoted constructor properties. Without the fix, the constructor's isPure() returns "maybe" from reflection, causing a false "Possibly impure instantiation" error. Co-Authored-By: Claude Opus 4.6 --- tests/PHPStan/Rules/Pure/PureMethodRuleTest.php | 7 +------ .../PHPStan/Rules/Pure/data/bug-14138-pure.php | 17 +++++------------ 2 files changed, 6 insertions(+), 18 deletions(-) diff --git a/tests/PHPStan/Rules/Pure/PureMethodRuleTest.php b/tests/PHPStan/Rules/Pure/PureMethodRuleTest.php index 5a059b672f0..9c1a95adc9f 100644 --- a/tests/PHPStan/Rules/Pure/PureMethodRuleTest.php +++ b/tests/PHPStan/Rules/Pure/PureMethodRuleTest.php @@ -283,12 +283,7 @@ public function testAllMethodsArePure(): void public function testBug14138Pure(): void { $this->treatPhpDocTypesAsCertain = true; - $this->analyse([__DIR__ . '/data/bug-14138-pure.php'], [ - [ - 'Impure echo in pure method Bug14138Pure\PureClassWithSideEffect::doSomething().', - 31, - ], - ]); + $this->analyse([__DIR__ . '/data/bug-14138-pure.php'], []); } public function testBug12382(): void diff --git a/tests/PHPStan/Rules/Pure/data/bug-14138-pure.php b/tests/PHPStan/Rules/Pure/data/bug-14138-pure.php index 9d4f5425a03..0c4f6668194 100644 --- a/tests/PHPStan/Rules/Pure/data/bug-14138-pure.php +++ b/tests/PHPStan/Rules/Pure/data/bug-14138-pure.php @@ -5,7 +5,7 @@ /** * @phpstan-all-methods-pure */ -class PureClassWithPromotedProperties +class PureClassWithPromotedProps { public function __construct( protected int $value @@ -17,18 +17,11 @@ public function getValue(): int } } -/** - * @phpstan-all-methods-pure - */ -class PureClassWithSideEffect +class TestCaller { - public function __construct( - protected int $value - ) {} - - public function doSomething(): int + /** @phpstan-pure */ + public function callPureConstructor(): PureClassWithPromotedProps { - echo 'side effect'; - return $this->value; + return new PureClassWithPromotedProps(1); } }