Skip to content

Commit 8be11a9

Browse files
phpstan-botclaude
andcommitted
Fix #14411 without reverting PR phpstan#5056 preserveVacuousConditionals
Restore preserveVacuousConditionals from PR phpstan#5056 and add a targeted guard overlap check in createConditionalExpressions() to prevent creating ambiguous conditional expressions when the guard type overlaps with the other branch's type for the guard variable. This preserves the improved type narrowing from phpstan#5056 (bug phpstan#4173, #12992, #14227) while also fixing the incorrect dependent type narrowing reported in #14411. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent f6ed6bd commit 8be11a9

File tree

10 files changed

+155
-60
lines changed

10 files changed

+155
-60
lines changed

src/Analyser/ExprHandler/MethodCallHandler.php

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -153,24 +153,22 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex
153153
$scope = $argsResult->getScope();
154154

155155
if ($methodReflection !== null) {
156-
if ($parametersAcceptor !== null) {
157-
$methodThrowPoint = $this->getMethodThrowPoint($methodReflection, $parametersAcceptor, $expr, $scope);
158-
if ($methodThrowPoint !== null) {
159-
$throwPoints[] = $methodThrowPoint;
160-
}
156+
$methodThrowPoint = $this->getMethodThrowPoint($methodReflection, $parametersAcceptor, $expr, $scope);
157+
if ($methodThrowPoint !== null) {
158+
$throwPoints[] = $methodThrowPoint;
161159
}
162160

163161
if ($methodReflection->getName() === '__construct' || $methodReflection->hasSideEffects()->yes()) {
164162
$nodeScopeResolver->callNodeCallback($nodeCallback, new InvalidateExprNode($normalizedExpr->var), $scope, $storage);
165163
$scope = $scope->invalidateExpression($normalizedExpr->var, true, $methodReflection->getDeclaringClass());
166-
} elseif ($this->rememberPossiblyImpureFunctionValues && $methodReflection->hasSideEffects()->maybe() && !$methodReflection->getDeclaringClass()->isBuiltin() && $parametersAcceptor !== null) {
164+
} elseif ($this->rememberPossiblyImpureFunctionValues && $methodReflection->hasSideEffects()->maybe() && !$methodReflection->getDeclaringClass()->isBuiltin()) {
167165
$scope = $scope->assignExpression(
168166
new PossiblyImpureCallExpr($normalizedExpr, $normalizedExpr->var, sprintf('%s::%s()', $methodReflection->getDeclaringClass()->getDisplayName(), $methodReflection->getName())),
169167
$parametersAcceptor->getReturnType(),
170168
new MixedType(),
171169
);
172170
}
173-
if ($parametersAcceptor !== null && !$methodReflection->isStatic()) {
171+
if (!$methodReflection->isStatic()) {
174172
$selfOutType = $methodReflection->getSelfOutType();
175173
if ($selfOutType !== null) {
176174
$scope = $scope->assignExpression(

src/Analyser/ExprHandler/StaticCallHandler.php

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -198,7 +198,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex
198198
$scope = $argsResult->getScope();
199199
$scopeFunction = $scope->getFunction();
200200

201-
if ($methodReflection !== null && $parametersAcceptor !== null) {
201+
if ($methodReflection !== null) {
202202
$methodThrowPoint = $this->getStaticMethodThrowPoint($methodReflection, $parametersAcceptor, $expr, $scope);
203203
if ($methodThrowPoint !== null) {
204204
$throwPoints[] = $methodThrowPoint;
@@ -221,7 +221,6 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex
221221
} elseif (
222222
$methodReflection !== null
223223
&& $this->rememberPossiblyImpureFunctionValues
224-
&& $parametersAcceptor !== null
225224
&& $scope->isInClass()
226225
&& $scope->getClassReflection()->is($methodReflection->getDeclaringClass()->getName())
227226
&& $methodReflection->hasSideEffects()->maybe()

src/Analyser/MutatingScope.php

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3205,7 +3205,7 @@ public function filterBySpecifiedTypes(SpecifiedTypes $specifiedTypes): self
32053205
} else {
32063206
$scope = $scope->removeTypeFromExpression($expr, $type);
32073207
}
3208-
$specifiedExpressions[$typeSpecification['exprString']] = ExpressionTypeHolder::createYes($expr, $scope->getType($expr));
3208+
$specifiedExpressions[$typeSpecification['exprString']] = ExpressionTypeHolder::createYes($expr, $scope->getScopeType($expr));
32093209
}
32103210

32113211
$conditions = [];
@@ -3337,7 +3337,7 @@ public function isInFirstLevelStatement(): bool
33373337
return $this->inFirstLevelStatement;
33383338
}
33393339

3340-
public function mergeWith(?self $otherScope): self
3340+
public function mergeWith(?self $otherScope, bool $preserveVacuousConditionals = false): self
33413341
{
33423342
if ($otherScope === null || $this === $otherScope) {
33433343
return $this;
@@ -3347,6 +3347,18 @@ public function mergeWith(?self $otherScope): self
33473347

33483348
$mergedExpressionTypes = $this->mergeVariableHolders($ourExpressionTypes, $theirExpressionTypes);
33493349
$conditionalExpressions = $this->intersectConditionalExpressions($otherScope->conditionalExpressions);
3350+
if ($preserveVacuousConditionals) {
3351+
$conditionalExpressions = $this->preserveVacuousConditionalExpressions(
3352+
$conditionalExpressions,
3353+
$this->conditionalExpressions,
3354+
$theirExpressionTypes,
3355+
);
3356+
$conditionalExpressions = $this->preserveVacuousConditionalExpressions(
3357+
$conditionalExpressions,
3358+
$otherScope->conditionalExpressions,
3359+
$ourExpressionTypes,
3360+
);
3361+
}
33503362
$conditionalExpressions = $this->createConditionalExpressions(
33513363
$conditionalExpressions,
33523364
$ourExpressionTypes,
@@ -3452,6 +3464,48 @@ private function intersectConditionalExpressions(array $otherConditionalExpressi
34523464
return $newConditionalExpressions;
34533465
}
34543466

3467+
/**
3468+
* @param array<string, ConditionalExpressionHolder[]> $currentConditionalExpressions
3469+
* @param array<string, ConditionalExpressionHolder[]> $sourceConditionalExpressions
3470+
* @param array<string, ExpressionTypeHolder> $otherExpressionTypes
3471+
* @return array<string, ConditionalExpressionHolder[]>
3472+
*/
3473+
private function preserveVacuousConditionalExpressions(
3474+
array $currentConditionalExpressions,
3475+
array $sourceConditionalExpressions,
3476+
array $otherExpressionTypes,
3477+
): array
3478+
{
3479+
foreach ($sourceConditionalExpressions as $exprString => $holders) {
3480+
foreach ($holders as $key => $holder) {
3481+
if (isset($currentConditionalExpressions[$exprString][$key])) {
3482+
continue;
3483+
}
3484+
3485+
$typeHolder = $holder->getTypeHolder();
3486+
if ($typeHolder->getCertainty()->no() && !$typeHolder->getExpr() instanceof Variable) {
3487+
continue;
3488+
}
3489+
3490+
foreach ($holder->getConditionExpressionTypeHolders() as $guardExprString => $guardTypeHolder) {
3491+
if (!array_key_exists($guardExprString, $otherExpressionTypes)) {
3492+
continue;
3493+
}
3494+
3495+
$otherType = $otherExpressionTypes[$guardExprString]->getType();
3496+
$guardType = $guardTypeHolder->getType();
3497+
3498+
if ($otherType->isSuperTypeOf($guardType)->no()) {
3499+
$currentConditionalExpressions[$exprString][$key] = $holder;
3500+
break;
3501+
}
3502+
}
3503+
}
3504+
}
3505+
3506+
return $currentConditionalExpressions;
3507+
}
3508+
34553509
/**
34563510
* @param array<string, ConditionalExpressionHolder[]> $newConditionalExpressions
34573511
* @param array<string, ConditionalExpressionHolder[]> $existingConditionalExpressions

src/Analyser/NodeScopeResolver.php

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1146,7 +1146,7 @@ public function processStmtNode(
11461146
$throwPoints = array_merge($throwPoints, $branchScopeStatementResult->getThrowPoints());
11471147
$impurePoints = array_merge($impurePoints, $branchScopeStatementResult->getImpurePoints());
11481148
$branchScope = $branchScopeStatementResult->getScope();
1149-
$finalScope = $branchScopeStatementResult->isAlwaysTerminating() ? $finalScope : $branchScope->mergeWith($finalScope);
1149+
$finalScope = $branchScopeStatementResult->isAlwaysTerminating() ? $finalScope : $branchScope->mergeWith($finalScope, true);
11501150
$alwaysTerminating = $alwaysTerminating && $branchScopeStatementResult->isAlwaysTerminating();
11511151
if (count($branchScopeStatementResult->getEndStatements()) > 0) {
11521152
$endStatements = array_merge($endStatements, $branchScopeStatementResult->getEndStatements());
@@ -1170,7 +1170,7 @@ public function processStmtNode(
11701170

11711171
if ($stmt->else === null) {
11721172
if (!$ifAlwaysTrue && !$lastElseIfConditionIsTrue) {
1173-
$finalScope = $scope->mergeWith($finalScope);
1173+
$finalScope = $scope->mergeWith($finalScope, true);
11741174
$alwaysTerminating = false;
11751175
}
11761176
} else {
@@ -1182,7 +1182,7 @@ public function processStmtNode(
11821182
$throwPoints = array_merge($throwPoints, $branchScopeStatementResult->getThrowPoints());
11831183
$impurePoints = array_merge($impurePoints, $branchScopeStatementResult->getImpurePoints());
11841184
$branchScope = $branchScopeStatementResult->getScope();
1185-
$finalScope = $branchScopeStatementResult->isAlwaysTerminating() ? $finalScope : $branchScope->mergeWith($finalScope);
1185+
$finalScope = $branchScopeStatementResult->isAlwaysTerminating() ? $finalScope : $branchScope->mergeWith($finalScope, true);
11861186
$alwaysTerminating = $alwaysTerminating && $branchScopeStatementResult->isAlwaysTerminating();
11871187
if (count($branchScopeStatementResult->getEndStatements()) > 0) {
11881188
$endStatements = array_merge($endStatements, $branchScopeStatementResult->getEndStatements());

src/Analyser/TypeSpecifier.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1896,7 +1896,7 @@ private function specifyTypesFromCallableCall(TypeSpecifierContext $context, Fun
18961896
}
18971897
}
18981898

1899-
if ($assertions === null || $assertions->getAll() === [] || $parametersAcceptor === null) {
1899+
if ($assertions === null || $assertions->getAll() === []) {
19001900
return null;
19011901
}
19021902

tests/PHPStan/Analyser/nsrt/bug-5051.php

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ public function testWithBooleans($data): void
5959
assertType('1|2|3|10', $data);
6060
assertType('bool', $update);
6161
} else {
62-
assertType('1|2|3|10', $data);
62+
assertType('1|2', $data);
6363
assertType('bool', $update);
6464
}
6565

@@ -81,15 +81,15 @@ public function testWithBooleans($data): void
8181

8282
if ($data === 3) {
8383
assertType('bool', $update);
84-
assertType('bool', $foo);
84+
assertType('true', $foo);
8585
} else {
8686
assertType('bool', $update);
8787
assertType('bool', $foo);
8888
}
8989

9090
if ($data === 1 || $data === 2) {
9191
assertType('bool', $update);
92-
assertType('bool', $foo);
92+
assertType('false', $foo);
9393
} else {
9494
assertType('bool', $update);
9595
assertType('bool', $foo);

tests/PHPStan/Rules/Variables/DefinedVariableRuleTest.php

Lines changed: 21 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -911,12 +911,7 @@ public function testBug4173(): void
911911
$this->polluteScopeWithLoopInitialAssignments = false;
912912
$this->checkMaybeUndefinedVariables = true;
913913
$this->polluteScopeWithAlwaysIterableForeach = true;
914-
$this->analyse([__DIR__ . '/data/bug-4173.php'], [
915-
[
916-
'Variable $value might not be defined.', // could be fixed
917-
30,
918-
],
919-
]);
914+
$this->analyse([__DIR__ . '/data/bug-4173.php'], []);
920915
}
921916

922917
public function testBug5805(): void
@@ -1119,29 +1114,13 @@ public function testDynamicAccess(): void
11191114
18,
11201115
],
11211116
[
1122-
'Variable $foo might not be defined.',
1123-
36,
1124-
],
1125-
[
1126-
'Variable $foo might not be defined.',
1127-
37,
1128-
],
1129-
[
1130-
'Variable $bar might not be defined.',
1117+
'Undefined variable: $bar',
11311118
38,
11321119
],
11331120
[
1134-
'Variable $bar might not be defined.',
1135-
40,
1136-
],
1137-
[
1138-
'Variable $foo might not be defined.',
1121+
'Undefined variable: $foo',
11391122
41,
11401123
],
1141-
[
1142-
'Variable $bar might not be defined.',
1143-
42,
1144-
],
11451124
[
11461125
'Undefined variable: $buz',
11471126
44,
@@ -1158,14 +1137,6 @@ public function testDynamicAccess(): void
11581137
'Undefined variable: $buz',
11591138
49,
11601139
],
1161-
[
1162-
'Variable $bar might not be defined.',
1163-
49,
1164-
],
1165-
[
1166-
'Variable $foo might not be defined.',
1167-
49,
1168-
],
11691140
[
11701141
'Variable $foo might not be defined.',
11711142
50,
@@ -1487,6 +1458,24 @@ public function testBug13920(): void
14871458
$this->analyse([__DIR__ . '/data/bug-13920.php'], []);
14881459
}
14891460

1461+
public function testBug12992(): void
1462+
{
1463+
$this->cliArgumentsVariablesRegistered = true;
1464+
$this->polluteScopeWithLoopInitialAssignments = false;
1465+
$this->checkMaybeUndefinedVariables = true;
1466+
$this->polluteScopeWithAlwaysIterableForeach = true;
1467+
$this->analyse([__DIR__ . '/data/bug-12992.php'], []);
1468+
}
1469+
1470+
public function testBug14227(): void
1471+
{
1472+
$this->cliArgumentsVariablesRegistered = true;
1473+
$this->polluteScopeWithLoopInitialAssignments = false;
1474+
$this->checkMaybeUndefinedVariables = true;
1475+
$this->polluteScopeWithAlwaysIterableForeach = true;
1476+
$this->analyse([__DIR__ . '/data/bug-14227.php'], []);
1477+
}
1478+
14901479
public function testBug14117(): void
14911480
{
14921481
$this->cliArgumentsVariablesRegistered = true;
@@ -1495,10 +1484,6 @@ public function testBug14117(): void
14951484
$this->polluteScopeWithAlwaysIterableForeach = true;
14961485

14971486
$this->analyse([__DIR__ . '/data/bug-14117.php'], [
1498-
[
1499-
'Variable $value might not be defined.',
1500-
33,
1501-
],
15021487
[
15031488
'Variable $value might not be defined.',
15041489
49,
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace Bug12992;
4+
5+
class HelloWorld
6+
{
7+
public function test(\Closure $fx): void
8+
{
9+
$capture = tmpfile();
10+
if ($capture !== false) {
11+
$capturePath = stream_get_meta_data($capture)['uri'] ?? '';
12+
if (@is_writable($capturePath)) {
13+
$errorLogPrevious = ini_set('error_log', $capturePath);
14+
} else {
15+
$capture = false;
16+
}
17+
}
18+
19+
if ($capture !== false) {
20+
fclose($capture);
21+
22+
ini_set('error_log', $errorLogPrevious);
23+
}
24+
}
25+
}
26+
27+
function test(bool $v): void
28+
{
29+
if ($v) {
30+
if (rand() === 3) {
31+
$newvar = 1;
32+
} else {
33+
$v = false;
34+
}
35+
}
36+
37+
if ($v === true) {
38+
echo $newvar;
39+
}
40+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace Bug14227;
4+
5+
function moo(): void {
6+
$key = rand(0, 2);
7+
8+
if ($key === 1) {
9+
$value = 'test';
10+
}
11+
12+
if ($key === 2) {
13+
unset($value);
14+
}
15+
16+
if ($key === 1) {
17+
echo $value;
18+
}
19+
}

tests/PHPStan/Rules/Variables/data/dynamic-access.php

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -35,18 +35,18 @@ public function testScope(): void
3535
if ($name === 'foo') {
3636
echo $$name; // ok
3737
echo $foo; // ok
38-
echo $bar;
38+
echo $bar; // not ok
3939
} elseif ($name === 'bar') {
4040
echo $$name; // ok
41-
echo $foo;
41+
echo $foo; // not ok
4242
echo $bar; // ok
4343
} else {
44-
echo $$name; // ok
45-
echo $foo;
46-
echo $bar;
44+
echo $$name; // not ok
45+
echo $foo; // not ok
46+
echo $bar; // not ok
4747
}
4848

49-
echo $$name; // ok
49+
echo $$name; // ok for foo and bar but not buz
5050
echo $foo;
5151
echo $bar;
5252
}

0 commit comments

Comments
 (0)