Skip to content

Commit 51c6e4b

Browse files
committed
1 parent cc71563 commit 51c6e4b

4 files changed

Lines changed: 78 additions & 6 deletions

File tree

CLAUDE.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,10 @@ Loops are a frequent source of false positives because PHPStan must reason about
257257
- **Always-overwritten arrays in foreach**: NodeScopeResolver examines `$a[$k]` at loop body end and `continue` statements. If no `break` exists, the entire array type can be rewritten based on the observed value types.
258258
- **Variable types across iterations**: PHP Fibers are used (PHP 8.1+) for more precise analysis of repeated variable assignments in loops, by running the loop body analysis multiple times to reach a fixpoint.
259259

260+
### Match expression scope merging
261+
262+
Match expressions in `NodeScopeResolver` (around line 4154) process each arm and merge the resulting scopes. The critical pattern for variable certainty is: when a match is exhaustive (has a `default` arm or an always-true condition), arm body scopes should be merged only with each other (not with the original pre-match scope). This mirrors how if/else merging works — `$finalScope` starts as `null`, and each branch's scope is merged via `$branchScope->mergeWith($finalScope)`. When the match is NOT exhaustive, the original scope must also participate in the merge (via `$scope->mergeWith($armBodyFinalScope)`) because execution may skip all arms and throw `UnhandledMatchError`. The `mergeVariableHolders()` method in `MutatingScope` uses `ExpressionTypeHolder::createMaybe()` for variables present in only one scope, so merging an arm scope that defines `$x` with the original scope that lacks `$x` degrades certainty to "maybe" — this is the root cause of false "might not be defined" reports for exhaustive match expressions.
263+
260264
### PHPDoc inheritance
261265

262266
PHPDoc types (`@return`, `@param`, `@throws`, `@property`) are inherited through class hierarchies. Bugs arise when:

src/Analyser/NodeScopeResolver.php

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4166,6 +4166,7 @@ function (MutatingScope $scope) use ($stmt, $expr, $nodeCallback, $context, $sto
41664166
$hasAlwaysTrueCond = false;
41674167
$arms = $expr->arms;
41684168
$armCondsToSkip = [];
4169+
$armBodyScopes = [];
41694170
if ($condType->isEnum()->yes()) {
41704171
// enum match analysis would work even without this if branch
41714172
// but would be much slower
@@ -4275,7 +4276,7 @@ function (MutatingScope $scope) use ($stmt, $expr, $nodeCallback, $context, $sto
42754276
ExpressionContext::createTopLevel(),
42764277
);
42774278
$armScope = $armResult->getScope();
4278-
$scope = $scope->mergeWith($armScope);
4279+
$armBodyScopes[] = $armScope;
42794280
$hasYield = $hasYield || $armResult->hasYield();
42804281
$throwPoints = array_merge($throwPoints, $armResult->getThrowPoints());
42814282
$impurePoints = array_merge($impurePoints, $armResult->getImpurePoints());
@@ -4312,7 +4313,7 @@ function (MutatingScope $scope) use ($stmt, $expr, $nodeCallback, $context, $sto
43124313
$hasYield = $hasYield || $armResult->hasYield();
43134314
$throwPoints = array_merge($throwPoints, $armResult->getThrowPoints());
43144315
$impurePoints = array_merge($impurePoints, $armResult->getImpurePoints());
4315-
$scope = $scope->mergeWith($matchScope);
4316+
$armBodyScopes[] = $matchScope;
43164317
continue;
43174318
}
43184319

@@ -4356,20 +4357,38 @@ function (MutatingScope $scope) use ($stmt, $expr, $nodeCallback, $context, $sto
43564357
ExpressionContext::createTopLevel(),
43574358
);
43584359
$armScope = $armResult->getScope();
4359-
$scope = $scope->mergeWith($armScope);
4360+
$armBodyScopes[] = $armScope;
43604361
$hasYield = $hasYield || $armResult->hasYield();
43614362
$throwPoints = array_merge($throwPoints, $armResult->getThrowPoints());
43624363
$impurePoints = array_merge($impurePoints, $armResult->getImpurePoints());
43634364
$matchScope = $matchScope->filterByFalseyValue($filteringExpr);
43644365
}
43654366

4366-
if (!$hasDefaultCond && !$hasAlwaysTrueCond) {
4367+
$isExhaustive = $hasDefaultCond || $hasAlwaysTrueCond;
4368+
if (!$isExhaustive) {
43674369
$remainingType = $matchScope->getType($expr->cond);
4368-
if (!$remainingType instanceof NeverType) {
4369-
$throwPoints[] = InternalThrowPoint::createExplicit($scope, new ObjectType(UnhandledMatchError::class), $expr, false);
4370+
if ($remainingType instanceof NeverType) {
4371+
$isExhaustive = true;
43704372
}
43714373
}
43724374

4375+
if ($isExhaustive) {
4376+
$armBodyFinalScope = null;
4377+
foreach ($armBodyScopes as $armBodyScope) {
4378+
$armBodyFinalScope = $armBodyScope->mergeWith($armBodyFinalScope);
4379+
}
4380+
$scope = $armBodyFinalScope ?? $scope;
4381+
} else {
4382+
$armBodyFinalScope = null;
4383+
foreach ($armBodyScopes as $armBodyScope) {
4384+
$armBodyFinalScope = $armBodyScope->mergeWith($armBodyFinalScope);
4385+
}
4386+
if ($armBodyFinalScope !== null) {
4387+
$scope = $scope->mergeWith($armBodyFinalScope);
4388+
}
4389+
$throwPoints[] = InternalThrowPoint::createExplicit($scope, new ObjectType(UnhandledMatchError::class), $expr, false);
4390+
}
4391+
43734392
ksort($armNodes, SORT_NUMERIC);
43744393

43754394
$this->callNodeCallback($nodeCallback, new MatchExpressionNode($expr->cond, array_values($armNodes), $expr, $matchScope), $scope, $storage);

tests/PHPStan/Rules/Variables/DefinedVariableRuleTest.php

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1208,4 +1208,20 @@ public function testBug13694(): void
12081208
$this->analyse([__DIR__ . '/data/bug-13694.php'], []);
12091209
}
12101210

1211+
#[RequiresPhp('>= 8.0')]
1212+
public function testBug5191(): void
1213+
{
1214+
$this->cliArgumentsVariablesRegistered = true;
1215+
$this->polluteScopeWithLoopInitialAssignments = true;
1216+
$this->checkMaybeUndefinedVariables = true;
1217+
$this->polluteScopeWithAlwaysIterableForeach = true;
1218+
1219+
$this->analyse([__DIR__ . '/data/bug-5191.php'], [
1220+
[
1221+
'Variable $pow might not be defined.',
1222+
23,
1223+
],
1224+
]);
1225+
}
1226+
12111227
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<?php // lint >= 8.0
2+
3+
declare(strict_types = 1);
4+
5+
namespace Bug5191;
6+
7+
function foo(int $value, ?string $suffix): int {
8+
match ($suffix) {
9+
'k', 'K' => $pow = 1,
10+
'm', 'M' => $pow = 2,
11+
'g', 'G' => $pow = 3,
12+
default => $pow = 0,
13+
};
14+
return $value * (1024 ** $pow);
15+
}
16+
17+
function bar(int $value, ?string $suffix): int {
18+
match ($suffix) {
19+
'k', 'K' => $pow = 1,
20+
'm', 'M' => $pow = 2,
21+
'g', 'G' => $pow = 3,
22+
};
23+
return $value * (1024 ** $pow); // no default, $pow might not be defined
24+
}
25+
26+
function baz(int $x): int {
27+
match (true) {
28+
$x > 0 => $result = 1,
29+
$x < 0 => $result = -1,
30+
default => $result = 0,
31+
};
32+
return $result;
33+
}

0 commit comments

Comments
 (0)