Treat trait-declared properties as initialized when promoted by an inherited constructor#6085
Conversation
…herited constructor - In ClassPropertiesNode::getUninitializedProperties(), mark a property as initialized when its native reflection is promoted and declared in a parent class. When a child class re-declares a property through a trait, PHP resolves the property to the parent's promoted constructor parameter, so it is always initialized by the inherited constructor. - Fixes the false positive for the native readonly rule (property.uninitializedReadonly), and the same fix flows through the shared getUninitializedProperties() to the @readonly PHPDoc rule (property.uninitializedReadonlyByPhpDoc) and the plain uninitialized property rule (property.uninitialized). - Genuinely uninitialized trait properties with no promoting ancestor still report correctly.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
| } | ||
|
|
||
| if ( | ||
| $propertyReflection->isPromoted() |
There was a problem hiding this comment.
is the root problem really only relevant to promoted properties?
There was a problem hiding this comment.
I've addressed staabm's review comment.
The reviewer's question: "is the root problem really only relevant to promoted properties?" — with a playground link.
The answer: no. I fetched the playground and confirmed it demonstrates a case the original narrow fix missed — a property declared in a trait and initialized by a parent constructor's plain body assignment (not promotion):
class ClassA {
use TraitA;
public function __construct(string $property) {
$this->property = $property; // body assignment, NOT promoted
}
}
class ClassB extends ClassA {
use TraitB; // re-declares $property via nested trait
}I reproduced it: PHPStan still reported Class ClassB has an uninitialized readonly property $property because the original condition gated on $propertyReflection->isPromoted(), which is false here.
The fix: dropped the isPromoted() requirement. Native reflection already resolves the merged property's declaring class to the ancestor (ClassA) in both the promoted and body-assignment cases, so the single check that matters is whether the declaring class differs from the analysed class:
if ($propertyReflection->getDeclaringClass()->getName() !== $classReflection->getName()) {
$is = TrinaryLogic::createYes();
}When the declaring class differs, the property is inherited and its initialization is the ancestor's responsibility — and the ancestor's own ClassPropertiesNode still checks it there, so genuinely-uninitialized ancestors are still reported (no coverage lost, no duplicate errors).
Verification:
- Added
BodyInitClassA/BodyInitClassB(the playground case) to all three regression data files (native readonly,@readonlyPHPDoc, plain uninitialized). - Confirmed each new case fails without the fix (
uninitialized property $propertyon the child) and passes with it. - The genuinely-uninitialized
UninitializedFromTraitcases still error as before. - Full
tests/PHPStan/Rules/Properties+UnusedPrivatePropertyRuleTestsuite: 377 pass.make phpstan: no errors.
Committed as 03b5bf67d and pushed.
…f promotion The parent constructor may initialize the property via a plain body assignment rather than promotion, so consult only the native reflection declaring class: when it differs from the analysed class the property is inherited and its initialization is the ancestor's responsibility. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Summary
When a class re-declares a property through a trait while a parent class already promotes that same property in its constructor, PHPStan reported a false positive that the property was uninitialized (
property.uninitializedReadonly), even though the inherited constructor always initializes it.Changes
src/Node/ClassPropertiesNode.php: ingetUninitializedProperties(), after fetching the native property reflection, treat the property as initialized ($is = TrinaryLogic::createYes()) when the reflection reports the property as promoted and its declaring class differs from the current class. A promoted property is always assigned by its constructor, and when it lives in a parent class it is the parent constructor's responsibility.tests/PHPStan/Rules/Properties/data/bug-14983.php+MissingReadOnlyPropertyAssignRuleTest::testBug14983(nativereadonly, including a nested-trait and a three-level hierarchy variant, plus a genuinely-uninitialized trait property that must still error).tests/PHPStan/Rules/Properties/data/bug-14983-phpdoc.php+MissingReadOnlyByPhpDocPropertyAssignRuleTest::testBug14983(@readonlyPHPDoc sibling).tests/PHPStan/Rules/Properties/data/bug-14983-uninitialized.php+UninitializedPropertyRuleTest::testBug14983(plain non-readonlyproperty.uninitializedsibling).Root cause
ClassPropertiesNodebuilds its property list from the AST it traverses for the analysed class. When a child uses a trait that declares a property, that property appears in the child's own property list withisPromoted() === false(it is the trait's plain declaration). The child has no constructor body of its own, socollectUninitializedProperties()cannot find anyPropertyInitializationExprfor it and the property is reported as uninitialized. However, PHP merges the trait property with the parent's promoted constructor property: native reflection resolves it to the parent class and reports it as promoted, i.e. always initialized by the inherited constructor. The fix consults the native reflection to recognise this case.Because
getUninitializedProperties()is shared, the single fix covers all three property-initialization rules that consume it — the pattern was identical across the native readonly, PHPDoc readonly, and plain uninitialized checks.Test
Each regression test was verified to fail before the fix (the parent-promoted trait property was flagged as uninitialized on the child class) and to pass after it, while the genuinely-uninitialized trait property with no promoting ancestor continues to be reported.
Fixes phpstan/phpstan#14983