Skip to content

Commit de17d49

Browse files
committed
Fix phpstan/phpstan#14368: False positive match.alwaysTrue with consecutive match(true) expressions
After an exhaustive match(true) with a throwing default arm, the post-match scope carried conditional expressions encoding relationships between arm-narrowed types (e.g. "if equals(A) is false, then equals(B) is true"). When a subsequent match(true) filtered by falsey for the first arm, these conditional expressions activated and incorrectly narrowed the second arm's condition to true. The fix restores the pre-match conditional expressions after the arm body scope merge, preventing match-internal type relationships from leaking into the post-match scope.
1 parent cfba43c commit de17d49

4 files changed

Lines changed: 101 additions & 1 deletion

File tree

src/Analyser/ExprHandler/MatchHandler.php

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -454,11 +454,18 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex
454454
}
455455

456456
if ($isExhaustive) {
457+
$preMatchConditionalExpressions = $scope->getConditionalExpressions();
457458
$armBodyFinalScope = null;
458459
foreach ($armBodyScopes as $armBodyScope) {
459460
$armBodyFinalScope = $armBodyScope->mergeWith($armBodyFinalScope);
460461
}
461-
$scope = $armBodyFinalScope ?? $scope;
462+
if ($armBodyFinalScope !== null) {
463+
// Prevent conditional expressions created during arm scope merging
464+
// from leaking into the post-match scope. These encode relationships
465+
// between arm-narrowed types (e.g. "if equals(A) is false, then
466+
// equals(B) is true") that should not affect subsequent code.
467+
$scope = $armBodyFinalScope->replaceConditionalExpressions($preMatchConditionalExpressions);
468+
}
462469
} else {
463470
$armBodyFinalScope = null;
464471
foreach ($armBodyScopes as $armBodyScope) {

src/Analyser/MutatingScope.php

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3320,6 +3320,39 @@ public function addConditionalExpressions(string $exprString, array $conditional
33203320
);
33213321
}
33223322

3323+
/**
3324+
* @return array<string, ConditionalExpressionHolder[]>
3325+
*/
3326+
public function getConditionalExpressions(): array
3327+
{
3328+
return $this->conditionalExpressions;
3329+
}
3330+
3331+
/**
3332+
* @param array<string, ConditionalExpressionHolder[]> $conditionalExpressions
3333+
*/
3334+
public function replaceConditionalExpressions(array $conditionalExpressions): self
3335+
{
3336+
return $this->scopeFactory->create(
3337+
$this->context,
3338+
$this->isDeclareStrictTypes(),
3339+
$this->getFunction(),
3340+
$this->getNamespace(),
3341+
$this->expressionTypes,
3342+
$this->nativeExpressionTypes,
3343+
$conditionalExpressions,
3344+
$this->inClosureBindScopeClasses,
3345+
$this->anonymousFunctionReflection,
3346+
$this->inFirstLevelStatement,
3347+
$this->currentlyAssignedExpressions,
3348+
$this->currentlyAllowedUndefinedExpressions,
3349+
$this->inFunctionCallsStack,
3350+
$this->afterExtractCall,
3351+
$this->parentScope,
3352+
$this->nativeTypesPromoted,
3353+
);
3354+
}
3355+
33233356
public function exitFirstLevelStatements(): self
33243357
{
33253358
if (!$this->inFirstLevelStatement) {

tests/PHPStan/Rules/Comparison/MatchExpressionRuleTest.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -459,6 +459,12 @@ public function testBug12790(): void
459459
$this->analyse([__DIR__ . '/data/bug-12790.php'], []);
460460
}
461461

462+
#[RequiresPhp('>= 8.0')]
463+
public function testBug14368(): void
464+
{
465+
$this->analyse([__DIR__ . '/data/bug-14368.php'], []);
466+
}
467+
462468
#[RequiresPhp('>= 8.0')]
463469
public function testBug11310(): void
464470
{
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
<?php // lint >= 8.0
2+
3+
namespace Bug14368;
4+
5+
final class Snowflake
6+
{
7+
public function __construct(
8+
private readonly int $value,
9+
) {}
10+
11+
public static function cast(int $snowflake): self
12+
{
13+
return new self($snowflake);
14+
}
15+
16+
public function equals(?self $other): bool
17+
{
18+
return $this->value === $other?->value;
19+
}
20+
}
21+
22+
final class BalanceId
23+
{
24+
public static function work(): Snowflake
25+
{
26+
/** @var Snowflake */
27+
static $work = Snowflake::cast(1);
28+
return $work;
29+
}
30+
31+
public static function holiday(): Snowflake
32+
{
33+
/** @var Snowflake */
34+
static $holiday = Snowflake::cast(2);
35+
return $holiday;
36+
}
37+
}
38+
39+
function test(Snowflake $balanceId): void
40+
{
41+
// First match — no error expected
42+
$a = match (true) {
43+
$balanceId->equals(BalanceId::work()) => -1.0,
44+
$balanceId->equals(BalanceId::holiday()) => 1.0,
45+
default => throw new \InvalidArgumentException(),
46+
};
47+
48+
// Second match — should not report match.alwaysTrue
49+
$b = match (true) {
50+
$balanceId->equals(BalanceId::work()) => -2.0,
51+
$balanceId->equals(BalanceId::holiday()) => 2.0,
52+
default => throw new \InvalidArgumentException(),
53+
};
54+
}

0 commit comments

Comments
 (0)