Renamed trait constructor promotes properties when called - #6237
Conversation
Constructor promotion is not limited to methods named __construct, so calling a trait constructor imported under a different name initializes its promoted properties in the caller's scope.
| if ( | ||
| !$methodReflection->isStatic() | ||
| && $scope->isInClass() | ||
| && $scope->getClassReflection()->getName() === $methodReflection->getDeclaringClass()->getName() | ||
| ) { | ||
| $calledOnType = $scope->getType($normalizedExpr->var); | ||
| foreach ($this->getPromotedParameterNames($methodReflection) as $propertyName) { | ||
| $scope = $scope->assignInitializedProperty($calledOnType, $propertyName); | ||
| } | ||
| } |
There was a problem hiding this comment.
isn't this code running on every method call?
we are only interessted in method calls from within __construct, right?
|
Reviewed at The premise holds — I checked it in PHP rather than assuming. Calling an aliased trait constructor really does promote, and reflection separates it cleanly from a real constructor: trait T { public function __construct(public readonly string $value) {} }
class C { use T { __construct as protected init; } public function __construct(string $v) { $this->init($v); } }
// (new C('hello'))->value === 'hello'
// ReflectionMethod(C::class, 'init')->getParameters()[0]->isPromoted() === true
// ReflectionMethod(C::class, '__construct')->getParameters()[0]->isPromoted() === falseSo keying on promotion rather than the method name is both sufficient and precise, and the "no trait-specific special case" claim in the description is accurate. Worth adding to the description: Suggestion, hot path. The block computes foreach ($this->getPromotedParameterNames($methodReflection) as $propertyName) {
$scope = $scope->assignInitializedProperty($calledOnType, $propertyName);
}Reusing the existing One more uncovered case, alongside the non-promoted one you list: the alias inherited from a parent class. class P { use T { __construct as protected init; } }
class DChild extends P {
public function __construct(string $v) { $this->init($v); } // still reported
}The guard requires the declaring class to be the current class, so this keeps reporting - identical output on your branch and on its parent, I checked. Fine as a limitation, just worth a line so it does not read as covered. Verification on my side: the two new tests fail without |
|
@staabm — yes it runs on every method call, but The block is reached for every
The 316 are cache misses — one per distinct Gating on class TestCase {
use T { __construct as protected init; }
protected function setUp(): void // registered in additionalConstructors
{
$this->init('x');
echo $this->check();
}
private function check(): string { return $this->value; }
}With the gate, Two smaller reasons to keep it ungated:
The parent-inherited alias — I probed it and it is narrower than "still reported". When the parent has its own constructor, nothing is reported on either side: the property's declaring class is the parent, so class P {
use T { __construct as protected init; }
public function __construct(string $v) { $this->init($v); }
}
class DChild extends P {
public function __construct(string $v) { $this->init($v); echo $this->value; } // clean, both sides
}The residual false positive is on the parent's declaration when the parent never initializes the property, including when it is abstract and never instantiated: abstract class AbstractP {
use T { __construct as protected init; }
}
class Child extends AbstractP {
public function __construct(string $v) { $this->init($v); echo $this->value; }
}
// Class AbstractP has an uninitialized readonly property $value. <- identical on this branch and its parentThat one comes from the uninitialized-properties path rather than premature access, so a fix at the call site cannot reach it — the parent has no constructor whose scope could be corrected. Named in the description as a limitation, next to the non-promoted one. |
|
Checked
Gates on my side: 38 tests in the two rule classes, full suite 21328, self-analysis clean, phpcs clean. CI's four reds are the ones red for everyone today (two Symplify integrations, Turbo/macos One loose end: the two description additions you mentioned - the |
Constructor property promotion is not limited to methods named
__construct. A trait constructor imported under a different name keeps promoting its parameters:StaticCallHandleralready marks a parent's promoted properties as initialized afterparent::__construct(), butMethodCallHandlerhad no counterpart, so the scope after$this->init($value)still considered$valueuninitialized.ClassPropertiesNode::getUninitializedProperties()derives the initialized-property map for each method called from the constructor from that caller scope, soisValid()inherited the stale state and the read was reported as premature access — as was a directecho $this->valuein the constructor itself.MethodCallHandlernow mirrors theparent::__construct()handling for$this->method()calls whose callee is declared in the current class. Detection is by parameter promotion rather than by method name, so no trait-specific special case is needed, and a per-method cache keeps the native-reflection lookup off the hot path.Still reported, and pinned in the new test data: reading the property before the alias call, calling the alias conditionally, never calling it at all (
Class … has an uninitialized readonly property), and calling it on a different instance. The same fix also clears the identical false positive for non-readonly promoted properties (property.uninitialized).One knock-on:
isset($this->value)after the alias call now reportsisset.initializedProperty("not nullable nor uninitialized") where it previously reportedisset.property("not nullable") — the property is genuinely initialized at that point, so the new message is the accurate one.Not covered here: a trait constructor that assigns a non-promoted readonly property in its body still reports
property.readOnlyAssignNotInConstructor. That needsConstructorsHelperto treat renamed trait constructors as constructors as well, which is a separate change.Closes phpstan/phpstan#9789