Skip to content

Commit 348debc

Browse files
committed
Fix false positives about uninitialized properties
Closes phpstan/phpstan#7198
1 parent 8fe4401 commit 348debc

File tree

6 files changed

+178
-17
lines changed

6 files changed

+178
-17
lines changed

src/Node/ClassPropertiesNode.php

Lines changed: 46 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,9 @@
2020
use PHPStan\Rules\Properties\ReadWritePropertiesExtension;
2121
use PHPStan\Rules\Properties\ReadWritePropertiesExtensionProvider;
2222
use PHPStan\ShouldNotHappenException;
23+
use PHPStan\TrinaryLogic;
2324
use function array_key_exists;
2425
use function array_keys;
25-
use function count;
2626
use function in_array;
2727

2828
/** @api */
@@ -100,6 +100,8 @@ public function getUninitializedProperties(
100100

101101
$properties = [];
102102
$originalProperties = [];
103+
$initialInitializedProperties = [];
104+
$initializedProperties = [];
103105
foreach ($this->getProperties() as $property) {
104106
if ($property->isStatic()) {
105107
continue;
@@ -111,6 +113,11 @@ public function getUninitializedProperties(
111113
continue;
112114
}
113115
$originalProperties[$property->getName()] = $property;
116+
$is = TrinaryLogic::createFromBoolean($property->isPromoted());
117+
$initialInitializedProperties[$property->getName()] = $is;
118+
foreach ($constructors as $constructor) {
119+
$initializedProperties[$constructor][$property->getName()] = $is;
120+
}
114121
if ($property->isPromoted()) {
115122
continue;
116123
}
@@ -138,7 +145,8 @@ public function getUninitializedProperties(
138145
if ($constructors === []) {
139146
return [$properties, [], []];
140147
}
141-
$methodsCalledFromConstructor = $this->getMethodsCalledFromConstructor($classReflection, $this->methodCalls, $constructors);
148+
149+
$methodsCalledFromConstructor = $this->getMethodsCalledFromConstructor($classReflection, $this->methodCalls, $initialInitializedProperties, $initializedProperties, $constructors);
142150
$prematureAccess = [];
143151
$additionalAssigns = [];
144152

@@ -158,10 +166,12 @@ public function getUninitializedProperties(
158166
if ($function->getDeclaringClass()->getName() !== $classReflection->getName()) {
159167
continue;
160168
}
161-
if (!in_array($function->getName(), $methodsCalledFromConstructor, true)) {
169+
if (!array_key_exists($function->getName(), $methodsCalledFromConstructor)) {
162170
continue;
163171
}
164172

173+
$initializedPropertiesMap = $methodsCalledFromConstructor[$function->getName()];
174+
165175
if (!$fetch->name instanceof Identifier) {
166176
continue;
167177
}
@@ -181,18 +191,18 @@ public function getUninitializedProperties(
181191
unset($properties[$propertyName]);
182192
}
183193

184-
if (array_key_exists($propertyName, $originalProperties)) {
185-
$hasInitialization = $usageScope->hasExpressionType(new PropertyInitializationExpr($propertyName));
186-
if (!$hasInitialization->no()) {
194+
if (array_key_exists($propertyName, $initializedPropertiesMap)) {
195+
$hasInitialization = $initializedPropertiesMap[$propertyName]->or($usageScope->hasExpressionType(new PropertyInitializationExpr($propertyName)));
196+
if (!$hasInitialization->no() && !$usage->isPromotedPropertyWrite()) {
187197
$additionalAssigns[] = [
188198
$propertyName,
189199
$fetch->getLine(),
190200
$originalProperties[$propertyName],
191201
];
192202
}
193203
}
194-
} elseif (array_key_exists($propertyName, $originalProperties)) {
195-
$hasInitialization = $usageScope->hasExpressionType(new PropertyInitializationExpr($propertyName));
204+
} elseif (array_key_exists($propertyName, $initializedPropertiesMap)) {
205+
$hasInitialization = $initializedPropertiesMap[$propertyName]->or($usageScope->hasExpressionType(new PropertyInitializationExpr($propertyName)));
196206
if (!$hasInitialization->yes()) {
197207
$prematureAccess[] = [
198208
$propertyName,
@@ -213,15 +223,20 @@ public function getUninitializedProperties(
213223
/**
214224
* @param MethodCall[] $methodCalls
215225
* @param string[] $methods
216-
* @return string[]
226+
* @param array<string, TrinaryLogic> $initialInitializedProperties
227+
* @param array<string, array<string, TrinaryLogic>> $initializedProperties
228+
* @return array<string, array<string, TrinaryLogic>>
217229
*/
218230
private function getMethodsCalledFromConstructor(
219231
ClassReflection $classReflection,
220232
array $methodCalls,
233+
array $initialInitializedProperties,
234+
array $initializedProperties,
221235
array $methods,
222236
): array
223237
{
224-
$originalCount = count($methods);
238+
$originalMap = $initializedProperties;
239+
$originalMethods = $methods;
225240
foreach ($methodCalls as $methodCall) {
226241
$methodCallNode = $methodCall->getNode();
227242
if ($methodCallNode instanceof Array_) {
@@ -242,7 +257,10 @@ private function getMethodsCalledFromConstructor(
242257
}
243258

244259
$methodName = $methodCallNode->name->toString();
245-
if (in_array($methodName, $methods, true)) {
260+
if (array_key_exists($methodName, $initializedProperties)) {
261+
foreach ($this->getInitializedProperties($callScope, $initialInitializedProperties) as $propertyName => $isInitialized) {
262+
$initializedProperties[$methodName][$propertyName] = $initializedProperties[$methodName][$propertyName]->and($isInitialized);
263+
}
246264
continue;
247265
}
248266
$methodReflection = $callScope->getMethodReflection($calledOnType, $methodName);
@@ -259,14 +277,28 @@ private function getMethodsCalledFromConstructor(
259277
if (!in_array($inMethod->getName(), $methods, true)) {
260278
continue;
261279
}
280+
$initializedProperties[$methodName] = $this->getInitializedProperties($callScope, $initialInitializedProperties);
262281
$methods[] = $methodName;
263282
}
264283

265-
if ($originalCount === count($methods)) {
266-
return $methods;
284+
if ($originalMap === $initializedProperties && $originalMethods === $methods) {
285+
return $initializedProperties;
286+
}
287+
288+
return $this->getMethodsCalledFromConstructor($classReflection, $methodCalls, $initialInitializedProperties, $initializedProperties, $methods);
289+
}
290+
291+
/**
292+
* @param array<string, TrinaryLogic> $initialInitializedProperties
293+
* @return array<string, TrinaryLogic>
294+
*/
295+
private function getInitializedProperties(Scope $scope, array $initialInitializedProperties): array
296+
{
297+
foreach ($initialInitializedProperties as $propertyName => $isInitialized) {
298+
$initialInitializedProperties[$propertyName] = $isInitialized->or($scope->hasExpressionType(new PropertyInitializationExpr($propertyName)));
267299
}
268300

269-
return $this->getMethodsCalledFromConstructor($classReflection, $methodCalls, $methods);
301+
return $initialInitializedProperties;
270302
}
271303

272304
}

src/Node/ClassStatementsGatherer.php

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,7 @@ private function gatherNodes(Node $node, Scope $scope): void
130130
$this->propertyUsages[] = new PropertyWrite(
131131
new PropertyFetch(new Expr\Variable('this'), new Identifier($node->getName())),
132132
$scope,
133+
true,
133134
);
134135
}
135136
return;
@@ -167,7 +168,7 @@ private function gatherNodes(Node $node, Scope $scope): void
167168
return;
168169
}
169170
if ($node instanceof PropertyAssignNode) {
170-
$this->propertyUsages[] = new PropertyWrite($node->getPropertyFetch(), $scope);
171+
$this->propertyUsages[] = new PropertyWrite($node->getPropertyFetch(), $scope, false);
171172
return;
172173
}
173174
if (!$node instanceof Expr) {
@@ -184,7 +185,7 @@ private function gatherNodes(Node $node, Scope $scope): void
184185
}
185186

186187
$this->propertyUsages[] = new PropertyRead($node->expr, $scope);
187-
$this->propertyUsages[] = new PropertyWrite($node->expr, $scope);
188+
$this->propertyUsages[] = new PropertyWrite($node->expr, $scope, false);
188189
return;
189190
}
190191
if ($node instanceof Node\Scalar\EncapsedStringPart) {

src/Node/Property/PropertyWrite.php

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
class PropertyWrite
1111
{
1212

13-
public function __construct(private PropertyFetch|StaticPropertyFetch $fetch, private Scope $scope)
13+
public function __construct(private PropertyFetch|StaticPropertyFetch $fetch, private Scope $scope, private bool $promotedPropertyWrite)
1414
{
1515
}
1616

@@ -27,4 +27,9 @@ public function getScope(): Scope
2727
return $this->scope;
2828
}
2929

30+
public function isPromotedPropertyWrite(): bool
31+
{
32+
return $this->promotedPropertyWrite;
33+
}
34+
3035
}

tests/PHPStan/Rules/Properties/MissingReadOnlyPropertyAssignRuleTest.php

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,14 @@ public function testRule(): void
112112
'Readonly property MissingReadOnlyPropertyAssign\AdditionalAssignOfReadonlyPromotedProperty::$x is already assigned.',
113113
188,
114114
],
115+
[
116+
'Access to an uninitialized readonly property MissingReadOnlyPropertyAssign\MethodCalledFromConstructorBeforeAssign::$foo.',
117+
226,
118+
],
119+
[
120+
'Access to an uninitialized readonly property MissingReadOnlyPropertyAssign\MethodCalledTwice::$foo.',
121+
244,
122+
],
115123
]);
116124
}
117125

@@ -174,4 +182,13 @@ public function testBug6402(): void
174182
]);
175183
}
176184

185+
public function testBug7198(): void
186+
{
187+
if (PHP_VERSION_ID < 80100) {
188+
$this->markTestSkipped('Test requires PHP 8.1.');
189+
}
190+
191+
$this->analyse([__DIR__ . '/data/bug-7198.php'], []);
192+
}
193+
177194
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
<?php
2+
3+
namespace Bug7198;
4+
5+
trait TestTrait {
6+
public function foo(): void
7+
{
8+
$this->callee->foo();
9+
}
10+
}
11+
12+
class TestCallee {
13+
public function foo(): void
14+
{
15+
echo "FOO\n";
16+
}
17+
}
18+
19+
class TestCaller {
20+
use TestTrait;
21+
22+
public function __construct(private readonly TestCallee $callee)
23+
{
24+
$this->foo();
25+
}
26+
}
27+
28+
class TestCaller2 {
29+
public function foo(): void
30+
{
31+
$this->callee->foo();
32+
}
33+
34+
public function __construct(private readonly TestCallee $callee)
35+
{
36+
$this->foo();
37+
}
38+
}
39+
40+
class TestCaller3 {
41+
42+
public function __construct(private readonly TestCallee $callee)
43+
{
44+
$this->foo();
45+
}
46+
47+
public function foo(): void
48+
{
49+
$this->callee->foo();
50+
}
51+
}

tests/PHPStan/Rules/Properties/data/missing-readonly-property-assign.php

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,3 +189,58 @@ public function __construct(private readonly int $x)
189189
}
190190

191191
}
192+
193+
class MethodCalledFromConstructorAfterAssign
194+
{
195+
196+
197+
private readonly int $foo;
198+
199+
public function __construct()
200+
{
201+
$this->foo = 1;
202+
$this->doFoo();
203+
}
204+
205+
public function doFoo(): void
206+
{
207+
echo $this->foo;
208+
}
209+
210+
}
211+
212+
class MethodCalledFromConstructorBeforeAssign
213+
{
214+
215+
216+
private readonly int $foo;
217+
218+
public function __construct()
219+
{
220+
$this->doFoo();
221+
$this->foo = 1;
222+
}
223+
224+
public function doFoo(): void
225+
{
226+
echo $this->foo;
227+
}
228+
229+
}
230+
231+
class MethodCalledTwice
232+
{
233+
private readonly int $foo;
234+
235+
public function __construct()
236+
{
237+
$this->doFoo();
238+
$this->foo = 1;
239+
$this->doFoo();
240+
}
241+
242+
public function doFoo(): void
243+
{
244+
echo $this->foo;
245+
}
246+
}

0 commit comments

Comments
 (0)