Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,10 @@ Loops are a frequent source of false positives because PHPStan must reason about

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.

### Boolean conditional expressions and match(true) exhaustiveness

`TypeSpecifier::processBooleanSureConditionalTypes()` and `processBooleanNotSureConditionalTypes()` create `ConditionalExpressionHolder` instances that link type narrowings across expressions in boolean `&&` / `||` conditions. These are used during `filterBySpecifiedTypes()` to propagate type narrowings — for example, if `$a && $b` is false and `$a` is known to be true, then `$b` must be false. These conditional holders are critical for `match(true)` exhaustiveness detection in `isScopeConditionallyImpossible()`. Originally these methods only created conditional holders for `Variable` expressions, which meant `match(true)` exhaustiveness worked for `$b && $c` but not for `$arr['a'] && $arr['b']`. The fix was to allow any expression type (including `ArrayDimFetch`) in these methods, and to use expression string comparison instead of variable name comparison for self-reference detection. The `isScopeConditionallyImpossible` method was also extended to extract boolean-typed offsets from constant array shapes and construct `ArrayDimFetch` expressions for them.

### GenericClassStringType narrowing and tryRemove

`GenericClassStringType` represents `class-string<T>` where `T` is the generic object type. When the generic type is a union (e.g., `class-string<Car|Bike|Boat>`), it's a single `GenericClassStringType` with an inner `UnionType`. This is distinct from `class-string<Car>|class-string<Bike>|class-string<Boat>` which is a `UnionType` of individual `GenericClassStringType`s.
Expand Down
57 changes: 39 additions & 18 deletions src/Analyser/NodeScopeResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -7634,33 +7634,54 @@
*/
private function isScopeConditionallyImpossible(MutatingScope $scope): bool
{
$boolVars = [];
$boolExprs = [];
foreach ($scope->getDefinedVariables() as $varName) {
$varType = $scope->getVariableType($varName);
if (!$varType->isBoolean()->yes() || $varType->isConstantScalarValue()->yes()) {
if ($varType->isBoolean()->yes() && !$varType->isConstantScalarValue()->yes()) {

Check warning on line 7640 in src/Analyser/NodeScopeResolver.php

View workflow job for this annotation

GitHub Actions / Mutation Testing (8.4, ubuntu-latest)

Escaped Mutant for Mutator "PHPStan\Infection\TrinaryLogicMutator": @@ @@ $boolExprs = []; foreach ($scope->getDefinedVariables() as $varName) { $varType = $scope->getVariableType($varName); - if ($varType->isBoolean()->yes() && !$varType->isConstantScalarValue()->yes()) { + if ($varType->isBoolean()->yes() && $varType->isConstantScalarValue()->no()) { $boolExprs[] = new Variable($varName); continue; }

Check warning on line 7640 in src/Analyser/NodeScopeResolver.php

View workflow job for this annotation

GitHub Actions / Mutation Testing (8.4, ubuntu-latest)

Escaped Mutant for Mutator "PHPStan\Infection\TrinaryLogicMutator": @@ @@ $boolExprs = []; foreach ($scope->getDefinedVariables() as $varName) { $varType = $scope->getVariableType($varName); - if ($varType->isBoolean()->yes() && !$varType->isConstantScalarValue()->yes()) { + if (!$varType->isBoolean()->no() && !$varType->isConstantScalarValue()->yes()) { $boolExprs[] = new Variable($varName); continue; }

Check warning on line 7640 in src/Analyser/NodeScopeResolver.php

View workflow job for this annotation

GitHub Actions / Mutation Testing (8.3, ubuntu-latest)

Escaped Mutant for Mutator "PHPStan\Infection\TrinaryLogicMutator": @@ @@ $boolExprs = []; foreach ($scope->getDefinedVariables() as $varName) { $varType = $scope->getVariableType($varName); - if ($varType->isBoolean()->yes() && !$varType->isConstantScalarValue()->yes()) { + if ($varType->isBoolean()->yes() && $varType->isConstantScalarValue()->no()) { $boolExprs[] = new Variable($varName); continue; }

Check warning on line 7640 in src/Analyser/NodeScopeResolver.php

View workflow job for this annotation

GitHub Actions / Mutation Testing (8.3, ubuntu-latest)

Escaped Mutant for Mutator "PHPStan\Infection\TrinaryLogicMutator": @@ @@ $boolExprs = []; foreach ($scope->getDefinedVariables() as $varName) { $varType = $scope->getVariableType($varName); - if ($varType->isBoolean()->yes() && !$varType->isConstantScalarValue()->yes()) { + if (!$varType->isBoolean()->no() && !$varType->isConstantScalarValue()->yes()) { $boolExprs[] = new Variable($varName); continue; }
$boolExprs[] = new Variable($varName);
continue;
}

$boolVars[] = $varName;
$constantArrays = $varType->getConstantArrays();
if (count($constantArrays) !== 1) {
continue;
}
foreach ($constantArrays[0]->getKeyTypes() as $i => $keyType) {
$valueType = $constantArrays[0]->getValueTypes()[$i];
if (!$valueType->isBoolean()->yes() || $valueType->isConstantScalarValue()->yes()) {

Check warning on line 7651 in src/Analyser/NodeScopeResolver.php

View workflow job for this annotation

GitHub Actions / Mutation Testing (8.4, ubuntu-latest)

Escaped Mutant for Mutator "PHPStan\Infection\TrinaryLogicMutator": @@ @@ } foreach ($constantArrays[0]->getKeyTypes() as $i => $keyType) { $valueType = $constantArrays[0]->getValueTypes()[$i]; - if (!$valueType->isBoolean()->yes() || $valueType->isConstantScalarValue()->yes()) { + if (!$valueType->isBoolean()->yes() || !$valueType->isConstantScalarValue()->no()) { continue; } $constantStrings = $keyType->getConstantStrings();

Check warning on line 7651 in src/Analyser/NodeScopeResolver.php

View workflow job for this annotation

GitHub Actions / Mutation Testing (8.4, ubuntu-latest)

Escaped Mutant for Mutator "PHPStan\Infection\TrinaryLogicMutator": @@ @@ } foreach ($constantArrays[0]->getKeyTypes() as $i => $keyType) { $valueType = $constantArrays[0]->getValueTypes()[$i]; - if (!$valueType->isBoolean()->yes() || $valueType->isConstantScalarValue()->yes()) { + if ($valueType->isBoolean()->no() || $valueType->isConstantScalarValue()->yes()) { continue; } $constantStrings = $keyType->getConstantStrings();

Check warning on line 7651 in src/Analyser/NodeScopeResolver.php

View workflow job for this annotation

GitHub Actions / Mutation Testing (8.3, ubuntu-latest)

Escaped Mutant for Mutator "PHPStan\Infection\TrinaryLogicMutator": @@ @@ } foreach ($constantArrays[0]->getKeyTypes() as $i => $keyType) { $valueType = $constantArrays[0]->getValueTypes()[$i]; - if (!$valueType->isBoolean()->yes() || $valueType->isConstantScalarValue()->yes()) { + if (!$valueType->isBoolean()->yes() || !$valueType->isConstantScalarValue()->no()) { continue; } $constantStrings = $keyType->getConstantStrings();

Check warning on line 7651 in src/Analyser/NodeScopeResolver.php

View workflow job for this annotation

GitHub Actions / Mutation Testing (8.3, ubuntu-latest)

Escaped Mutant for Mutator "PHPStan\Infection\TrinaryLogicMutator": @@ @@ } foreach ($constantArrays[0]->getKeyTypes() as $i => $keyType) { $valueType = $constantArrays[0]->getValueTypes()[$i]; - if (!$valueType->isBoolean()->yes() || $valueType->isConstantScalarValue()->yes()) { + if ($valueType->isBoolean()->no() || $valueType->isConstantScalarValue()->yes()) { continue; } $constantStrings = $keyType->getConstantStrings();
continue;
}
$constantStrings = $keyType->getConstantStrings();
if (count($constantStrings) === 1) {
$boolExprs[] = new Expr\ArrayDimFetch(
new Variable($varName),
new Node\Scalar\String_($constantStrings[0]->getValue()),
);
} elseif ($keyType->isInteger()->yes()) {
$keyValues = $keyType->getConstantScalarValues();
if (count($keyValues) === 1 && is_int($keyValues[0])) {
$boolExprs[] = new Expr\ArrayDimFetch(
new Variable($varName),
new Node\Scalar\Int_($keyValues[0]),
);
}
}
}
}

if ($boolVars === []) {
if ($boolExprs === []) {
return false;
}

// Check if any boolean variable's both truth values lead to contradictions
foreach ($boolVars as $varName) {
$varExpr = new Variable($varName);

$truthyScope = $scope->filterByTruthyValue($varExpr);
$truthyContradiction = $this->scopeHasNeverVariable($truthyScope, $boolVars);
if (!$truthyContradiction) {
// Check if any boolean expression's both truth values lead to contradictions
foreach ($boolExprs as $boolExpr) {
$truthyScope = $scope->filterByTruthyValue($boolExpr);
if (!$this->scopeHasNeverBooleanExpr($truthyScope, $boolExprs)) {
continue;
}

$falseyScope = $scope->filterByFalseyValue($varExpr);
$falseyContradiction = $this->scopeHasNeverVariable($falseyScope, $boolVars);
if ($falseyContradiction) {
$falseyScope = $scope->filterByFalseyValue($boolExpr);
if ($this->scopeHasNeverBooleanExpr($falseyScope, $boolExprs)) {
return true;
}
}
Expand All @@ -7669,12 +7690,12 @@
}

/**
* @param string[] $varNames
* @param Expr[] $boolExprs
*/
private function scopeHasNeverVariable(MutatingScope $scope, array $varNames): bool
private function scopeHasNeverBooleanExpr(MutatingScope $scope, array $boolExprs): bool
{
foreach ($varNames as $varName) {
$type = $scope->getVariableType($varName);
foreach ($boolExprs as $boolExpr) {
$type = $scope->getType($boolExpr);
if ($type instanceof NeverType) {
return true;
}
Expand Down
38 changes: 6 additions & 32 deletions src/Analyser/TypeSpecifier.php
Original file line number Diff line number Diff line change
Expand Up @@ -1629,10 +1629,7 @@ private function processBooleanSureConditionalTypes(Scope $scope, SpecifiedTypes
{
$conditionExpressionTypes = [];
foreach ($leftTypes->getSureTypes() as $exprString => [$expr, $type]) {
if (!$expr instanceof Expr\Variable) {
continue;
}
if (!is_string($expr->name)) {
if ($expr instanceof Expr\Variable && !is_string($expr->name)) {
continue;
}

Expand All @@ -1645,10 +1642,7 @@ private function processBooleanSureConditionalTypes(Scope $scope, SpecifiedTypes
if (count($conditionExpressionTypes) > 0) {
$holders = [];
foreach ($rightTypes->getSureTypes() as $exprString => [$expr, $type]) {
if (!$expr instanceof Expr\Variable) {
continue;
}
if (!is_string($expr->name)) {
if ($expr instanceof Expr\Variable && !is_string($expr->name)) {
continue;
}

Expand All @@ -1658,14 +1652,7 @@ private function processBooleanSureConditionalTypes(Scope $scope, SpecifiedTypes

$conditions = $conditionExpressionTypes;
foreach ($conditions as $conditionExprString => $conditionExprTypeHolder) {
$conditionExpr = $conditionExprTypeHolder->getExpr();
if (!$conditionExpr instanceof Expr\Variable) {
continue;
}
if (!is_string($conditionExpr->name)) {
continue;
}
if ($conditionExpr->name !== $expr->name) {
if ($conditionExprString !== $exprString) {
continue;
}

Expand Down Expand Up @@ -1696,10 +1683,7 @@ private function processBooleanNotSureConditionalTypes(Scope $scope, SpecifiedTy
{
$conditionExpressionTypes = [];
foreach ($leftTypes->getSureNotTypes() as $exprString => [$expr, $type]) {
if (!$expr instanceof Expr\Variable) {
continue;
}
if (!is_string($expr->name)) {
if ($expr instanceof Expr\Variable && !is_string($expr->name)) {
continue;
}

Expand All @@ -1712,10 +1696,7 @@ private function processBooleanNotSureConditionalTypes(Scope $scope, SpecifiedTy
if (count($conditionExpressionTypes) > 0) {
$holders = [];
foreach ($rightTypes->getSureNotTypes() as $exprString => [$expr, $type]) {
if (!$expr instanceof Expr\Variable) {
continue;
}
if (!is_string($expr->name)) {
if ($expr instanceof Expr\Variable && !is_string($expr->name)) {
continue;
}

Expand All @@ -1725,14 +1706,7 @@ private function processBooleanNotSureConditionalTypes(Scope $scope, SpecifiedTy

$conditions = $conditionExpressionTypes;
foreach ($conditions as $conditionExprString => $conditionExprTypeHolder) {
$conditionExpr = $conditionExprTypeHolder->getExpr();
if (!$conditionExpr instanceof Expr\Variable) {
continue;
}
if (!is_string($conditionExpr->name)) {
continue;
}
if ($conditionExpr->name !== $expr->name) {
if ($conditionExprString !== $exprString) {
continue;
}

Expand Down
19 changes: 19 additions & 0 deletions tests/PHPStan/Analyser/nsrt/bug-12517.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php declare(strict_types = 1);

namespace Bug12517;

use stdClass;
use function PHPStan\Testing\assertType;

class HelloWorld
{
public function sayHello(stdClass $foo): void
{
if ($foo->a !== null || $foo->b !== null) {
if ($foo->a === null) {
assertType('null', $foo->a);
assertType('mixed~null', $foo->b);
}
}
}
}
48 changes: 48 additions & 0 deletions tests/PHPStan/Analyser/nsrt/bug-13446.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
<?php declare(strict_types = 1); // lint >= 8.2

namespace Bug13446;

use function PHPStan\Testing\assertType;

final readonly class MoneyVO
{
public function __construct(
public float $value,
public string $currency,
) {}

public function add(self $money): self
{
return new self($this->getRoundedValue() + $money->getRoundedValue(), $this->currency);
}

public function getRoundedValue(): float
{
return round($this->value, 2);
}
}

final readonly class ContainerCarriageStepPriceVO
{
public function __construct(
public ?MoneyVO $mainCarriage,
public ?MoneyVO $destinationLocals,
) {}

public function getSum(): MoneyVO
{
if ($this->mainCarriage === null && $this->destinationLocals === null) {
return new MoneyVO(0.0, 'EUR');
}

if ($this->mainCarriage === null) {
assertType('Bug13446\MoneyVO', $this->destinationLocals);
}

if ($this->destinationLocals === null) {
assertType('Bug13446\MoneyVO', $this->mainCarriage);
}

return new MoneyVO(0.0, 'EUR');
}
}
44 changes: 44 additions & 0 deletions tests/PHPStan/Analyser/nsrt/bug-6486.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<?php declare(strict_types = 1);

namespace Bug6486;

use function PHPStan\Testing\assertType;

class Preg {
/**
* @param non-empty-string $pattern
* @param string $subject
* @return bool
*/
public static function isMatch($pattern, $subject)
{
return true;
}
}

class HelloWorld
{
/** @var ?string */
private $only = null;
/** @var ?non-empty-string */
private $exclude = null;

/**
* @param string $name
*
* @return bool
*/
private function isAllowed($name)
{
if (!$this->only && !$this->exclude) {
return true;
}

if ($this->only) {
return Preg::isMatch($this->only, $name);
}

assertType('non-falsy-string', $this->exclude);
return !Preg::isMatch($this->exclude, $name);
}
}
38 changes: 38 additions & 0 deletions tests/PHPStan/Analyser/nsrt/bug-9155.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?php declare(strict_types = 1);

namespace Bug9155;

use function PHPStan\Testing\assertType;

class Foo
{
public function fooF(): bool
{
return true;
}
}

class Bar
{
public function barF(): bool
{
return false;
}
}

class HelloWorld
{
private ?Foo $foo = null;
private ?Bar $bar = null;

public function test(): void
{
if (null === $this->foo && null === $this->bar) {
return;
}

if (null === $this->foo) {
assertType('Bug9155\Bar', $this->bar);
}
}
}
6 changes: 6 additions & 0 deletions tests/PHPStan/Rules/Comparison/MatchExpressionRuleTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -446,4 +446,10 @@ public function testBug9534(): void
]);
}

#[RequiresPhp('>= 8.0')]
public function testBug13029(): void
{
$this->analyse([__DIR__ . '/data/bug-13029.php'], []);
}

}
41 changes: 41 additions & 0 deletions tests/PHPStan/Rules/Comparison/data/bug-13029.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<?php // lint >= 8.0

namespace Bug13029;

/**
* @param array{a: bool, b: bool} $arr
*/
function matchWithBoolArrayExhaustive(array $arr): int
{
return match(true) {
$arr['a'] && $arr['b'] => 1,
!$arr['a'] && !$arr['b'] => 2,
!$arr['a'] && $arr['b'] => 3,
$arr['a'] && !$arr['b'] => 4,
};
}

/**
* @param array{a: bool, b: bool} $arr
*/
function matchWithBoolArrayExhaustive2(array $arr): int
{
return match(true) {
$arr['a'] === true && $arr['b'] === true => 1,
$arr['a'] === false && $arr['b'] === false => 2,
$arr['a'] === false && $arr['b'] === true => 3,
$arr['a'] === true && $arr['b'] === false => 4,
};
}

/**
* @param array{a: bool, b: bool} $arr
*/
function matchWithBoolArrayAndDefault(array $arr): int
{
return match(true) {
$arr['a'] && $arr['b'] => 1,
$arr['a'] || $arr['b'] => 2,
default => 3,
};
}
Loading