From 4549f7ff86c23ec7d8921a99e7a8b013216b6961 Mon Sep 17 00:00:00 2001 From: VincentLanglet <9052536+VincentLanglet@users.noreply.github.com> Date: Tue, 26 May 2026 18:20:33 +0000 Subject: [PATCH 01/28] Add `setShouldNotImplyOppositeCase()` on `SpecifiedTypes` to replace FAUX function call workarounds - Add `shouldNotImplyOppositeCase` flag to `SpecifiedTypes` with `@api`-tagged setter and getter methods, propagated through all immutable-copy operations (setAlwaysOverwriteTypes, setRootExpr, setNewConditionalExpressionHolders, removeExpr, intersectWith, unionWith, normalize) - Check the flag in `ImpossibleCheckTypeHelper::findSpecifiedType()` to return null early, preventing false "always true/false" reports when sureTypes are side effects of a check rather than its determining condition - Replace `FAUX_FUNCTION` rootExpr in `StrContainingTypeSpecifyingExtension` with `setShouldNotImplyOppositeCase()` - Replace `__PHPSTAN_FAUX_CONSTANT` rootExpr in `ArrayKeyExistsFunctionTypeSpecifyingExtension` with `setShouldNotImplyOppositeCase()` - Use the flag for equality assertions in `TypeSpecifier::specifyTypesFromAsserts()` instead of setting rootExpr to the call expression - Remove unused imports (Arg, BooleanAnd, NotIdentical, String_, Name, Identical, ConstFetch) from the two extension files Closes https://github.com/phpstan/phpstan/issues/14705 --- src/Analyser/SpecifiedTypes.php | 42 +++++++++++ src/Analyser/TypeSpecifier.php | 5 +- .../Comparison/ImpossibleCheckTypeHelper.php | 4 ++ ...yExistsFunctionTypeSpecifyingExtension.php | 5 +- .../StrContainingTypeSpecifyingExtension.php | 15 +--- ...mpossibleCheckTypeFunctionCallRuleTest.php | 6 ++ .../Rules/Comparison/data/bug-14705.php | 69 +++++++++++++++++++ 7 files changed, 127 insertions(+), 19 deletions(-) create mode 100644 tests/PHPStan/Rules/Comparison/data/bug-14705.php diff --git a/src/Analyser/SpecifiedTypes.php b/src/Analyser/SpecifiedTypes.php index 5cfa65dc53..8c8c9c3aca 100644 --- a/src/Analyser/SpecifiedTypes.php +++ b/src/Analyser/SpecifiedTypes.php @@ -13,6 +13,8 @@ final class SpecifiedTypes private bool $overwrite = false; + private bool $shouldNotImplyOppositeCase = false; + /** @var array */ private array $newConditionalExpressionHolders = []; @@ -51,6 +53,29 @@ public function setAlwaysOverwriteTypes(): self { $self = new self($this->sureTypes, $this->sureNotTypes); $self->overwrite = true; + $self->shouldNotImplyOppositeCase = $this->shouldNotImplyOppositeCase; + $self->newConditionalExpressionHolders = $this->newConditionalExpressionHolders; + $self->rootExpr = $this->rootExpr; + + return $self; + } + + /** + * Normally, when a type-specifying extension returns SpecifiedTypes with sureTypes, + * ImpossibleCheckTypeHelper will analyze whether those types are already satisfied + * and conclude the check is always-true/always-false. + * + * When this flag is set, that analysis is skipped. Use this when the sureTypes + * are a side effect of the check (e.g. str_contains narrowing haystack to non-empty-string) + * rather than the determining condition. + * + * @api + */ + public function setShouldNotImplyOppositeCase(): self + { + $self = new self($this->sureTypes, $this->sureNotTypes); + $self->overwrite = $this->overwrite; + $self->shouldNotImplyOppositeCase = true; $self->newConditionalExpressionHolders = $this->newConditionalExpressionHolders; $self->rootExpr = $this->rootExpr; @@ -64,6 +89,7 @@ public function setRootExpr(?Expr $rootExpr): self { $self = new self($this->sureTypes, $this->sureNotTypes); $self->overwrite = $this->overwrite; + $self->shouldNotImplyOppositeCase = $this->shouldNotImplyOppositeCase; $self->newConditionalExpressionHolders = $this->newConditionalExpressionHolders; $self->rootExpr = $rootExpr; @@ -77,6 +103,7 @@ public function setNewConditionalExpressionHolders(array $newConditionalExpressi { $self = new self($this->sureTypes, $this->sureNotTypes); $self->overwrite = $this->overwrite; + $self->shouldNotImplyOppositeCase = $this->shouldNotImplyOppositeCase; $self->newConditionalExpressionHolders = $newConditionalExpressionHolders; $self->rootExpr = $this->rootExpr; @@ -106,6 +133,11 @@ public function shouldOverwrite(): bool return $this->overwrite; } + public function shouldNotImplyOppositeCase(): bool + { + return $this->shouldNotImplyOppositeCase; + } + /** * @return array */ @@ -128,6 +160,7 @@ public function removeExpr(string $exprString): self $self = new self($sureTypes, $sureNotTypes); $self->overwrite = $this->overwrite; + $self->shouldNotImplyOppositeCase = $this->shouldNotImplyOppositeCase; $self->newConditionalExpressionHolders = $this->newConditionalExpressionHolders; $self->rootExpr = $this->rootExpr; @@ -167,6 +200,9 @@ public function intersectWith(SpecifiedTypes $other): self if ($this->overwrite && $other->overwrite) { $result = $result->setAlwaysOverwriteTypes(); } + if ($this->shouldNotImplyOppositeCase || $other->shouldNotImplyOppositeCase) { + $result = $result->setShouldNotImplyOppositeCase(); + } return $result->setRootExpr($rootExpr); } @@ -204,6 +240,9 @@ public function unionWith(SpecifiedTypes $other): self if ($this->overwrite || $other->overwrite) { $result = $result->setAlwaysOverwriteTypes(); } + if ($this->shouldNotImplyOppositeCase || $other->shouldNotImplyOppositeCase) { + $result = $result->setShouldNotImplyOppositeCase(); + } $conditionalExpressionHolders = $this->newConditionalExpressionHolders; foreach ($other->newConditionalExpressionHolders as $exprString => $holders) { @@ -235,6 +274,9 @@ public function normalize(Scope $scope): self if ($this->overwrite) { $result = $result->setAlwaysOverwriteTypes(); } + if ($this->shouldNotImplyOppositeCase) { + $result = $result->setShouldNotImplyOppositeCase(); + } return $result->setRootExpr($this->rootExpr); } diff --git a/src/Analyser/TypeSpecifier.php b/src/Analyser/TypeSpecifier.php index 27156a8b3f..547a1bbecd 100644 --- a/src/Analyser/TypeSpecifier.php +++ b/src/Analyser/TypeSpecifier.php @@ -473,7 +473,10 @@ static function (Type $type, callable $traverse) use ($templateTypeMap, &$contai $assertedType, $assert->isNegated() ? TypeSpecifierContext::createFalse() : TypeSpecifierContext::createTrue(), $scope, - )->setRootExpr($containsUnresolvedTemplate || $assert->isEquality() ? $call : null); + )->setRootExpr($containsUnresolvedTemplate ? $call : null); + if ($assert->isEquality()) { + $newTypes = $newTypes->setShouldNotImplyOppositeCase(); + } $types = $types !== null ? $types->unionWith($newTypes) : $newTypes; if (!$context->null() || !$assertedType instanceof ConstantBooleanType) { diff --git a/src/Rules/Comparison/ImpossibleCheckTypeHelper.php b/src/Rules/Comparison/ImpossibleCheckTypeHelper.php index f5599c6394..146f34d7fe 100644 --- a/src/Rules/Comparison/ImpossibleCheckTypeHelper.php +++ b/src/Rules/Comparison/ImpossibleCheckTypeHelper.php @@ -309,6 +309,10 @@ private function getSpecifiedType( return null; } + if ($specifiedTypes->shouldNotImplyOppositeCase()) { + return null; + } + $sureTypes = $specifiedTypes->getSureTypes(); $sureNotTypes = $specifiedTypes->getSureNotTypes(); diff --git a/src/Type/Php/ArrayKeyExistsFunctionTypeSpecifyingExtension.php b/src/Type/Php/ArrayKeyExistsFunctionTypeSpecifyingExtension.php index c48fee653f..f10a4aeb19 100644 --- a/src/Type/Php/ArrayKeyExistsFunctionTypeSpecifyingExtension.php +++ b/src/Type/Php/ArrayKeyExistsFunctionTypeSpecifyingExtension.php @@ -3,10 +3,7 @@ namespace PHPStan\Type\Php; use PhpParser\Node\Expr\ArrayDimFetch; -use PhpParser\Node\Expr\BinaryOp\Identical; -use PhpParser\Node\Expr\ConstFetch; use PhpParser\Node\Expr\FuncCall; -use PhpParser\Node\Name; use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; use PHPStan\Analyser\TypeSpecifier; @@ -115,7 +112,7 @@ public function specifyTypes( $arrayType->getIterableValueType(), $context, $scope, - ))->setRootExpr(new Identical($arrayDimFetch, new ConstFetch(new Name('__PHPSTAN_FAUX_CONSTANT')))); + ))->setShouldNotImplyOppositeCase(); } return new SpecifiedTypes(); diff --git a/src/Type/Php/StrContainingTypeSpecifyingExtension.php b/src/Type/Php/StrContainingTypeSpecifyingExtension.php index 84b50e00cf..4e50b181ea 100644 --- a/src/Type/Php/StrContainingTypeSpecifyingExtension.php +++ b/src/Type/Php/StrContainingTypeSpecifyingExtension.php @@ -2,12 +2,7 @@ namespace PHPStan\Type\Php; -use PhpParser\Node\Arg; -use PhpParser\Node\Expr\BinaryOp\BooleanAnd; -use PhpParser\Node\Expr\BinaryOp\NotIdentical; use PhpParser\Node\Expr\FuncCall; -use PhpParser\Node\Name; -use PhpParser\Node\Scalar\String_; use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; use PHPStan\Analyser\TypeSpecifier; @@ -89,15 +84,7 @@ public function specifyTypes(FunctionReflection $functionReflection, FuncCall $n new IntersectionType($accessories), $context, $scope, - )->setRootExpr(new BooleanAnd( - new NotIdentical( - $args[$needleArg]->value, - new String_(''), - ), - new FuncCall(new Name('FAUX_FUNCTION'), [ - new Arg($args[$needleArg]->value), - ]), - )); + )->setShouldNotImplyOppositeCase(); } } diff --git a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php index 4c8c769532..bced3b1aac 100644 --- a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php @@ -543,6 +543,12 @@ public function testNonEmptySpecifiedString(): void $this->analyse([__DIR__ . '/data/non-empty-string-impossible-type.php'], []); } + public function testBug14705(): void + { + $this->treatPhpDocTypesAsCertain = true; + $this->analyse([__DIR__ . '/data/bug-14705.php'], []); + } + public function testBug2755(): void { $this->treatPhpDocTypesAsCertain = true; diff --git a/tests/PHPStan/Rules/Comparison/data/bug-14705.php b/tests/PHPStan/Rules/Comparison/data/bug-14705.php new file mode 100644 index 0000000000..fb66358806 --- /dev/null +++ b/tests/PHPStan/Rules/Comparison/data/bug-14705.php @@ -0,0 +1,69 @@ + $array + */ + public function arrayKeyExistsNonEmpty(array $array, string $key): void + { + if (array_key_exists($key, $array)) { + + } + } + +} From e8a35930e616ab8e59654ca4f46534e6b1ef9564 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Tue, 26 May 2026 19:14:56 +0000 Subject: [PATCH 02/28] Add comment explaining why shouldNotImplyOppositeCase causes early return Co-Authored-By: Claude Opus 4.6 --- src/Rules/Comparison/ImpossibleCheckTypeHelper.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/Rules/Comparison/ImpossibleCheckTypeHelper.php b/src/Rules/Comparison/ImpossibleCheckTypeHelper.php index 146f34d7fe..7cd3fcdbd1 100644 --- a/src/Rules/Comparison/ImpossibleCheckTypeHelper.php +++ b/src/Rules/Comparison/ImpossibleCheckTypeHelper.php @@ -309,6 +309,9 @@ private function getSpecifiedType( return null; } + // sureTypes are side effects of the check (e.g. str_contains narrowing + // haystack to non-empty-string), not the determining condition — they + // can't tell us whether the check is always-true or always-false. if ($specifiedTypes->shouldNotImplyOppositeCase()) { return null; } From 3ddd2aeff83386f3ca64d4e9887f848dfaaf8a6a Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Tue, 26 May 2026 19:42:03 +0000 Subject: [PATCH 03/28] Rename `shouldNotImplyOppositeCase` to `shouldNotDetermineCheckResult` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The old name suggested the flag only prevented inferring the negated (opposite) case, but it actually prevents ImpossibleCheckTypeHelper from determining any outcome — both always-true and always-false. The new name accurately describes the flag's effect: the sureTypes should not be used to determine the check result. Co-Authored-By: Claude Opus 4.6 --- src/Analyser/SpecifiedTypes.php | 30 +++++++++---------- src/Analyser/TypeSpecifier.php | 2 +- .../Comparison/ImpossibleCheckTypeHelper.php | 2 +- ...yExistsFunctionTypeSpecifyingExtension.php | 2 +- .../StrContainingTypeSpecifyingExtension.php | 2 +- 5 files changed, 19 insertions(+), 19 deletions(-) diff --git a/src/Analyser/SpecifiedTypes.php b/src/Analyser/SpecifiedTypes.php index 8c8c9c3aca..dde5a235b0 100644 --- a/src/Analyser/SpecifiedTypes.php +++ b/src/Analyser/SpecifiedTypes.php @@ -13,7 +13,7 @@ final class SpecifiedTypes private bool $overwrite = false; - private bool $shouldNotImplyOppositeCase = false; + private bool $shouldNotDetermineCheckResult = false; /** @var array */ private array $newConditionalExpressionHolders = []; @@ -53,7 +53,7 @@ public function setAlwaysOverwriteTypes(): self { $self = new self($this->sureTypes, $this->sureNotTypes); $self->overwrite = true; - $self->shouldNotImplyOppositeCase = $this->shouldNotImplyOppositeCase; + $self->shouldNotDetermineCheckResult = $this->shouldNotDetermineCheckResult; $self->newConditionalExpressionHolders = $this->newConditionalExpressionHolders; $self->rootExpr = $this->rootExpr; @@ -71,11 +71,11 @@ public function setAlwaysOverwriteTypes(): self * * @api */ - public function setShouldNotImplyOppositeCase(): self + public function setShouldNotDetermineCheckResult(): self { $self = new self($this->sureTypes, $this->sureNotTypes); $self->overwrite = $this->overwrite; - $self->shouldNotImplyOppositeCase = true; + $self->shouldNotDetermineCheckResult = true; $self->newConditionalExpressionHolders = $this->newConditionalExpressionHolders; $self->rootExpr = $this->rootExpr; @@ -89,7 +89,7 @@ public function setRootExpr(?Expr $rootExpr): self { $self = new self($this->sureTypes, $this->sureNotTypes); $self->overwrite = $this->overwrite; - $self->shouldNotImplyOppositeCase = $this->shouldNotImplyOppositeCase; + $self->shouldNotDetermineCheckResult = $this->shouldNotDetermineCheckResult; $self->newConditionalExpressionHolders = $this->newConditionalExpressionHolders; $self->rootExpr = $rootExpr; @@ -103,7 +103,7 @@ public function setNewConditionalExpressionHolders(array $newConditionalExpressi { $self = new self($this->sureTypes, $this->sureNotTypes); $self->overwrite = $this->overwrite; - $self->shouldNotImplyOppositeCase = $this->shouldNotImplyOppositeCase; + $self->shouldNotDetermineCheckResult = $this->shouldNotDetermineCheckResult; $self->newConditionalExpressionHolders = $newConditionalExpressionHolders; $self->rootExpr = $this->rootExpr; @@ -133,9 +133,9 @@ public function shouldOverwrite(): bool return $this->overwrite; } - public function shouldNotImplyOppositeCase(): bool + public function shouldNotDetermineCheckResult(): bool { - return $this->shouldNotImplyOppositeCase; + return $this->shouldNotDetermineCheckResult; } /** @@ -160,7 +160,7 @@ public function removeExpr(string $exprString): self $self = new self($sureTypes, $sureNotTypes); $self->overwrite = $this->overwrite; - $self->shouldNotImplyOppositeCase = $this->shouldNotImplyOppositeCase; + $self->shouldNotDetermineCheckResult = $this->shouldNotDetermineCheckResult; $self->newConditionalExpressionHolders = $this->newConditionalExpressionHolders; $self->rootExpr = $this->rootExpr; @@ -200,8 +200,8 @@ public function intersectWith(SpecifiedTypes $other): self if ($this->overwrite && $other->overwrite) { $result = $result->setAlwaysOverwriteTypes(); } - if ($this->shouldNotImplyOppositeCase || $other->shouldNotImplyOppositeCase) { - $result = $result->setShouldNotImplyOppositeCase(); + if ($this->shouldNotDetermineCheckResult || $other->shouldNotDetermineCheckResult) { + $result = $result->setShouldNotDetermineCheckResult(); } return $result->setRootExpr($rootExpr); @@ -240,8 +240,8 @@ public function unionWith(SpecifiedTypes $other): self if ($this->overwrite || $other->overwrite) { $result = $result->setAlwaysOverwriteTypes(); } - if ($this->shouldNotImplyOppositeCase || $other->shouldNotImplyOppositeCase) { - $result = $result->setShouldNotImplyOppositeCase(); + if ($this->shouldNotDetermineCheckResult || $other->shouldNotDetermineCheckResult) { + $result = $result->setShouldNotDetermineCheckResult(); } $conditionalExpressionHolders = $this->newConditionalExpressionHolders; @@ -274,8 +274,8 @@ public function normalize(Scope $scope): self if ($this->overwrite) { $result = $result->setAlwaysOverwriteTypes(); } - if ($this->shouldNotImplyOppositeCase) { - $result = $result->setShouldNotImplyOppositeCase(); + if ($this->shouldNotDetermineCheckResult) { + $result = $result->setShouldNotDetermineCheckResult(); } return $result->setRootExpr($this->rootExpr); diff --git a/src/Analyser/TypeSpecifier.php b/src/Analyser/TypeSpecifier.php index 547a1bbecd..f4b1cf08e8 100644 --- a/src/Analyser/TypeSpecifier.php +++ b/src/Analyser/TypeSpecifier.php @@ -475,7 +475,7 @@ static function (Type $type, callable $traverse) use ($templateTypeMap, &$contai $scope, )->setRootExpr($containsUnresolvedTemplate ? $call : null); if ($assert->isEquality()) { - $newTypes = $newTypes->setShouldNotImplyOppositeCase(); + $newTypes = $newTypes->setShouldNotDetermineCheckResult(); } $types = $types !== null ? $types->unionWith($newTypes) : $newTypes; diff --git a/src/Rules/Comparison/ImpossibleCheckTypeHelper.php b/src/Rules/Comparison/ImpossibleCheckTypeHelper.php index 7cd3fcdbd1..56e3182ec7 100644 --- a/src/Rules/Comparison/ImpossibleCheckTypeHelper.php +++ b/src/Rules/Comparison/ImpossibleCheckTypeHelper.php @@ -312,7 +312,7 @@ private function getSpecifiedType( // sureTypes are side effects of the check (e.g. str_contains narrowing // haystack to non-empty-string), not the determining condition — they // can't tell us whether the check is always-true or always-false. - if ($specifiedTypes->shouldNotImplyOppositeCase()) { + if ($specifiedTypes->shouldNotDetermineCheckResult()) { return null; } diff --git a/src/Type/Php/ArrayKeyExistsFunctionTypeSpecifyingExtension.php b/src/Type/Php/ArrayKeyExistsFunctionTypeSpecifyingExtension.php index f10a4aeb19..af17a5e31a 100644 --- a/src/Type/Php/ArrayKeyExistsFunctionTypeSpecifyingExtension.php +++ b/src/Type/Php/ArrayKeyExistsFunctionTypeSpecifyingExtension.php @@ -112,7 +112,7 @@ public function specifyTypes( $arrayType->getIterableValueType(), $context, $scope, - ))->setShouldNotImplyOppositeCase(); + ))->setShouldNotDetermineCheckResult(); } return new SpecifiedTypes(); diff --git a/src/Type/Php/StrContainingTypeSpecifyingExtension.php b/src/Type/Php/StrContainingTypeSpecifyingExtension.php index 4e50b181ea..59f03b423e 100644 --- a/src/Type/Php/StrContainingTypeSpecifyingExtension.php +++ b/src/Type/Php/StrContainingTypeSpecifyingExtension.php @@ -84,7 +84,7 @@ public function specifyTypes(FunctionReflection $functionReflection, FuncCall $n new IntersectionType($accessories), $context, $scope, - )->setShouldNotImplyOppositeCase(); + )->setShouldNotDetermineCheckResult(); } } From 024909e90783fb21b3225d95202d62bb00a5184a Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Tue, 26 May 2026 19:56:14 +0000 Subject: [PATCH 04/28] Rename `shouldNotDetermineCheckResult` to `specifyOnly` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The flag means "these SpecifiedTypes only narrow types, don't use them to determine check outcome" — `specifyOnly` captures this concisely using the domain language of the SpecifiedTypes class. `overwrite` remains independent: it controls how types are applied in MutatingScope (assign vs intersect), while `specifyOnly` controls ImpossibleCheckTypeHelper. They already have separate early-return checks and serve different purposes. Co-Authored-By: Claude Opus 4.6 --- src/Analyser/SpecifiedTypes.php | 39 +++++++++---------- src/Analyser/TypeSpecifier.php | 2 +- .../Comparison/ImpossibleCheckTypeHelper.php | 5 +-- ...yExistsFunctionTypeSpecifyingExtension.php | 2 +- .../StrContainingTypeSpecifyingExtension.php | 2 +- 5 files changed, 23 insertions(+), 27 deletions(-) diff --git a/src/Analyser/SpecifiedTypes.php b/src/Analyser/SpecifiedTypes.php index dde5a235b0..d2713eedee 100644 --- a/src/Analyser/SpecifiedTypes.php +++ b/src/Analyser/SpecifiedTypes.php @@ -13,7 +13,7 @@ final class SpecifiedTypes private bool $overwrite = false; - private bool $shouldNotDetermineCheckResult = false; + private bool $specifyOnly = false; /** @var array */ private array $newConditionalExpressionHolders = []; @@ -53,7 +53,7 @@ public function setAlwaysOverwriteTypes(): self { $self = new self($this->sureTypes, $this->sureNotTypes); $self->overwrite = true; - $self->shouldNotDetermineCheckResult = $this->shouldNotDetermineCheckResult; + $self->specifyOnly = $this->specifyOnly; $self->newConditionalExpressionHolders = $this->newConditionalExpressionHolders; $self->rootExpr = $this->rootExpr; @@ -61,21 +61,20 @@ public function setAlwaysOverwriteTypes(): self } /** - * Normally, when a type-specifying extension returns SpecifiedTypes with sureTypes, - * ImpossibleCheckTypeHelper will analyze whether those types are already satisfied - * and conclude the check is always-true/always-false. + * When set, the sureTypes are only used for narrowing — ImpossibleCheckTypeHelper + * will not use them to determine whether the check is always-true/always-false. * - * When this flag is set, that analysis is skipped. Use this when the sureTypes - * are a side effect of the check (e.g. str_contains narrowing haystack to non-empty-string) + * Use this when the sureTypes are a side effect of the check + * (e.g. str_contains narrowing haystack to non-empty-string) * rather than the determining condition. * * @api */ - public function setShouldNotDetermineCheckResult(): self + public function setSpecifyOnly(): self { $self = new self($this->sureTypes, $this->sureNotTypes); $self->overwrite = $this->overwrite; - $self->shouldNotDetermineCheckResult = true; + $self->specifyOnly = true; $self->newConditionalExpressionHolders = $this->newConditionalExpressionHolders; $self->rootExpr = $this->rootExpr; @@ -89,7 +88,7 @@ public function setRootExpr(?Expr $rootExpr): self { $self = new self($this->sureTypes, $this->sureNotTypes); $self->overwrite = $this->overwrite; - $self->shouldNotDetermineCheckResult = $this->shouldNotDetermineCheckResult; + $self->specifyOnly = $this->specifyOnly; $self->newConditionalExpressionHolders = $this->newConditionalExpressionHolders; $self->rootExpr = $rootExpr; @@ -103,7 +102,7 @@ public function setNewConditionalExpressionHolders(array $newConditionalExpressi { $self = new self($this->sureTypes, $this->sureNotTypes); $self->overwrite = $this->overwrite; - $self->shouldNotDetermineCheckResult = $this->shouldNotDetermineCheckResult; + $self->specifyOnly = $this->specifyOnly; $self->newConditionalExpressionHolders = $newConditionalExpressionHolders; $self->rootExpr = $this->rootExpr; @@ -133,9 +132,9 @@ public function shouldOverwrite(): bool return $this->overwrite; } - public function shouldNotDetermineCheckResult(): bool + public function isSpecifyOnly(): bool { - return $this->shouldNotDetermineCheckResult; + return $this->specifyOnly; } /** @@ -160,7 +159,7 @@ public function removeExpr(string $exprString): self $self = new self($sureTypes, $sureNotTypes); $self->overwrite = $this->overwrite; - $self->shouldNotDetermineCheckResult = $this->shouldNotDetermineCheckResult; + $self->specifyOnly = $this->specifyOnly; $self->newConditionalExpressionHolders = $this->newConditionalExpressionHolders; $self->rootExpr = $this->rootExpr; @@ -200,8 +199,8 @@ public function intersectWith(SpecifiedTypes $other): self if ($this->overwrite && $other->overwrite) { $result = $result->setAlwaysOverwriteTypes(); } - if ($this->shouldNotDetermineCheckResult || $other->shouldNotDetermineCheckResult) { - $result = $result->setShouldNotDetermineCheckResult(); + if ($this->specifyOnly || $other->specifyOnly) { + $result = $result->setSpecifyOnly(); } return $result->setRootExpr($rootExpr); @@ -240,8 +239,8 @@ public function unionWith(SpecifiedTypes $other): self if ($this->overwrite || $other->overwrite) { $result = $result->setAlwaysOverwriteTypes(); } - if ($this->shouldNotDetermineCheckResult || $other->shouldNotDetermineCheckResult) { - $result = $result->setShouldNotDetermineCheckResult(); + if ($this->specifyOnly || $other->specifyOnly) { + $result = $result->setSpecifyOnly(); } $conditionalExpressionHolders = $this->newConditionalExpressionHolders; @@ -274,8 +273,8 @@ public function normalize(Scope $scope): self if ($this->overwrite) { $result = $result->setAlwaysOverwriteTypes(); } - if ($this->shouldNotDetermineCheckResult) { - $result = $result->setShouldNotDetermineCheckResult(); + if ($this->specifyOnly) { + $result = $result->setSpecifyOnly(); } return $result->setRootExpr($this->rootExpr); diff --git a/src/Analyser/TypeSpecifier.php b/src/Analyser/TypeSpecifier.php index f4b1cf08e8..12d9cccc91 100644 --- a/src/Analyser/TypeSpecifier.php +++ b/src/Analyser/TypeSpecifier.php @@ -475,7 +475,7 @@ static function (Type $type, callable $traverse) use ($templateTypeMap, &$contai $scope, )->setRootExpr($containsUnresolvedTemplate ? $call : null); if ($assert->isEquality()) { - $newTypes = $newTypes->setShouldNotDetermineCheckResult(); + $newTypes = $newTypes->setSpecifyOnly(); } $types = $types !== null ? $types->unionWith($newTypes) : $newTypes; diff --git a/src/Rules/Comparison/ImpossibleCheckTypeHelper.php b/src/Rules/Comparison/ImpossibleCheckTypeHelper.php index 56e3182ec7..bff20de840 100644 --- a/src/Rules/Comparison/ImpossibleCheckTypeHelper.php +++ b/src/Rules/Comparison/ImpossibleCheckTypeHelper.php @@ -309,10 +309,7 @@ private function getSpecifiedType( return null; } - // sureTypes are side effects of the check (e.g. str_contains narrowing - // haystack to non-empty-string), not the determining condition — they - // can't tell us whether the check is always-true or always-false. - if ($specifiedTypes->shouldNotDetermineCheckResult()) { + if ($specifiedTypes->isSpecifyOnly()) { return null; } diff --git a/src/Type/Php/ArrayKeyExistsFunctionTypeSpecifyingExtension.php b/src/Type/Php/ArrayKeyExistsFunctionTypeSpecifyingExtension.php index af17a5e31a..8dce9bb2ac 100644 --- a/src/Type/Php/ArrayKeyExistsFunctionTypeSpecifyingExtension.php +++ b/src/Type/Php/ArrayKeyExistsFunctionTypeSpecifyingExtension.php @@ -112,7 +112,7 @@ public function specifyTypes( $arrayType->getIterableValueType(), $context, $scope, - ))->setShouldNotDetermineCheckResult(); + ))->setSpecifyOnly(); } return new SpecifiedTypes(); diff --git a/src/Type/Php/StrContainingTypeSpecifyingExtension.php b/src/Type/Php/StrContainingTypeSpecifyingExtension.php index 59f03b423e..6223f295ad 100644 --- a/src/Type/Php/StrContainingTypeSpecifyingExtension.php +++ b/src/Type/Php/StrContainingTypeSpecifyingExtension.php @@ -84,7 +84,7 @@ public function specifyTypes(FunctionReflection $functionReflection, FuncCall $n new IntersectionType($accessories), $context, $scope, - )->setShouldNotDetermineCheckResult(); + )->setSpecifyOnly(); } } From 458cf86400ade01101638750a84e97046a825645 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Wed, 27 May 2026 09:23:33 +0000 Subject: [PATCH 05/28] Keep rootExpr for equality assertions, move specifyOnly after rootExpr check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Revert equality assertions (`@phpstan-assert =`) back to using `rootExpr = $call` instead of `specifyOnly`. The rootExpr mechanism in ImpossibleCheckTypeHelper provides more nuanced detection (constant boolean evaluation via scope) and is the established path for these. `specifyOnly` is reserved for the FAUX replacement cases (str_contains, array_key_exists) where sureTypes are pure side effects. - Move the `specifyOnly` check after the `rootExpr` check in ImpossibleCheckTypeHelper so that rootExpr takes precedence when both flags are set (e.g. via unionWith/intersectWith propagation). - Add duplicate call test cases (str_ends_with, str_contains) to document that nested identical calls are not reported as always-true. This was never detected before — the old FAUX mechanism also returned null for these — and would require a separate mechanism (tracking function call results in scope). Co-Authored-By: Claude Opus 4.6 --- src/Analyser/TypeSpecifier.php | 5 +--- .../Comparison/ImpossibleCheckTypeHelper.php | 8 +++---- .../Rules/Comparison/data/bug-14705.php | 24 +++++++++++++++++++ 3 files changed, 29 insertions(+), 8 deletions(-) diff --git a/src/Analyser/TypeSpecifier.php b/src/Analyser/TypeSpecifier.php index 12d9cccc91..27156a8b3f 100644 --- a/src/Analyser/TypeSpecifier.php +++ b/src/Analyser/TypeSpecifier.php @@ -473,10 +473,7 @@ static function (Type $type, callable $traverse) use ($templateTypeMap, &$contai $assertedType, $assert->isNegated() ? TypeSpecifierContext::createFalse() : TypeSpecifierContext::createTrue(), $scope, - )->setRootExpr($containsUnresolvedTemplate ? $call : null); - if ($assert->isEquality()) { - $newTypes = $newTypes->setSpecifyOnly(); - } + )->setRootExpr($containsUnresolvedTemplate || $assert->isEquality() ? $call : null); $types = $types !== null ? $types->unionWith($newTypes) : $newTypes; if (!$context->null() || !$assertedType instanceof ConstantBooleanType) { diff --git a/src/Rules/Comparison/ImpossibleCheckTypeHelper.php b/src/Rules/Comparison/ImpossibleCheckTypeHelper.php index bff20de840..40ddcff468 100644 --- a/src/Rules/Comparison/ImpossibleCheckTypeHelper.php +++ b/src/Rules/Comparison/ImpossibleCheckTypeHelper.php @@ -309,10 +309,6 @@ private function getSpecifiedType( return null; } - if ($specifiedTypes->isSpecifyOnly()) { - return null; - } - $sureTypes = $specifiedTypes->getSureTypes(); $sureNotTypes = $specifiedTypes->getSureNotTypes(); @@ -330,6 +326,10 @@ private function getSpecifiedType( return null; } + if ($specifiedTypes->isSpecifyOnly()) { + return null; + } + $results = []; $assignedInCallVars = []; diff --git a/tests/PHPStan/Rules/Comparison/data/bug-14705.php b/tests/PHPStan/Rules/Comparison/data/bug-14705.php index fb66358806..6a21c6efb7 100644 --- a/tests/PHPStan/Rules/Comparison/data/bug-14705.php +++ b/tests/PHPStan/Rules/Comparison/data/bug-14705.php @@ -66,4 +66,28 @@ public function arrayKeyExistsNonEmpty(array $array, string $key): void } } + /** + * @param non-empty-string $needle + */ + public function strEndsWithDuplicate(string $haystack, string $needle): void + { + if (str_ends_with($haystack, $needle)) { + if (str_ends_with($haystack, $needle)) { + + } + } + } + + /** + * @param non-empty-string $needle + */ + public function strContainsDuplicate(string $haystack, string $needle): void + { + if (str_contains($haystack, $needle)) { + if (str_contains($haystack, $needle)) { + + } + } + } + } From 53505ba9c3164e2b07b69c312b04d1a91d8eb114 Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Wed, 27 May 2026 20:01:07 +0200 Subject: [PATCH 06/28] Rework --- ...yExistsFunctionTypeSpecifyingExtension.php | 2 +- .../StrContainingTypeSpecifyingExtension.php | 2 +- .../Rules/Comparison/data/bug-14705.php | 21 +++++++++++++++++-- 3 files changed, 21 insertions(+), 4 deletions(-) diff --git a/src/Type/Php/ArrayKeyExistsFunctionTypeSpecifyingExtension.php b/src/Type/Php/ArrayKeyExistsFunctionTypeSpecifyingExtension.php index 8dce9bb2ac..a20e3641ed 100644 --- a/src/Type/Php/ArrayKeyExistsFunctionTypeSpecifyingExtension.php +++ b/src/Type/Php/ArrayKeyExistsFunctionTypeSpecifyingExtension.php @@ -112,7 +112,7 @@ public function specifyTypes( $arrayType->getIterableValueType(), $context, $scope, - ))->setSpecifyOnly(); + ))->setRootExpr($node); } return new SpecifiedTypes(); diff --git a/src/Type/Php/StrContainingTypeSpecifyingExtension.php b/src/Type/Php/StrContainingTypeSpecifyingExtension.php index 6223f295ad..98adef77b3 100644 --- a/src/Type/Php/StrContainingTypeSpecifyingExtension.php +++ b/src/Type/Php/StrContainingTypeSpecifyingExtension.php @@ -84,7 +84,7 @@ public function specifyTypes(FunctionReflection $functionReflection, FuncCall $n new IntersectionType($accessories), $context, $scope, - )->setSpecifyOnly(); + )->setRootExpr($node); } } diff --git a/tests/PHPStan/Rules/Comparison/data/bug-14705.php b/tests/PHPStan/Rules/Comparison/data/bug-14705.php index 6a21c6efb7..d783369d27 100644 --- a/tests/PHPStan/Rules/Comparison/data/bug-14705.php +++ b/tests/PHPStan/Rules/Comparison/data/bug-14705.php @@ -72,7 +72,7 @@ public function arrayKeyExistsNonEmpty(array $array, string $key): void public function strEndsWithDuplicate(string $haystack, string $needle): void { if (str_ends_with($haystack, $needle)) { - if (str_ends_with($haystack, $needle)) { + if (str_ends_with($haystack, $needle)) { // could be reported as always-true } } @@ -84,7 +84,24 @@ public function strEndsWithDuplicate(string $haystack, string $needle): void public function strContainsDuplicate(string $haystack, string $needle): void { if (str_contains($haystack, $needle)) { - if (str_contains($haystack, $needle)) { + if (str_contains($haystack, $needle)) { // could be reported as always-true + + } + } + } + + /** + * @phpstan-assert-if-true =non-empty-string $foo + */ + public function isValid(string $foo): bool + { + return $foo !== ''; + } + + public function equalityAssertDuplicate(string $task): void + { + if ($this->isValid($task)) { + if ($this->isValid($task)) { // could be reported as always-true } } From 643309d86e7da764be35df490037e93ff7fe7f6c Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Wed, 27 May 2026 18:25:38 +0000 Subject: [PATCH 07/28] Remove unused specifyOnly flag, document setRootExpr After the Rework commit unified both FAUX extensions and equality assertions through setRootExpr($node), the specifyOnly flag became dead code. Remove it entirely. Add PHPDoc to setRootExpr() explaining the mechanism: when rootExpr is set, ImpossibleCheckTypeHelper evaluates it in scope instead of analysing sureTypes/sureNotTypes. When rootExpr === the check node (as used by str_contains, array_key_exists, and equality assertions), isSpecified() bails out immediately, preventing false always-true reports from side-effect narrowing. Co-Authored-By: Claude Opus 4.6 --- src/Analyser/SpecifiedTypes.php | 42 +++---------------- .../Comparison/ImpossibleCheckTypeHelper.php | 4 -- 2 files changed, 5 insertions(+), 41 deletions(-) diff --git a/src/Analyser/SpecifiedTypes.php b/src/Analyser/SpecifiedTypes.php index d2713eedee..e7ffc940b3 100644 --- a/src/Analyser/SpecifiedTypes.php +++ b/src/Analyser/SpecifiedTypes.php @@ -13,8 +13,6 @@ final class SpecifiedTypes private bool $overwrite = false; - private bool $specifyOnly = false; - /** @var array */ private array $newConditionalExpressionHolders = []; @@ -53,7 +51,6 @@ public function setAlwaysOverwriteTypes(): self { $self = new self($this->sureTypes, $this->sureNotTypes); $self->overwrite = true; - $self->specifyOnly = $this->specifyOnly; $self->newConditionalExpressionHolders = $this->newConditionalExpressionHolders; $self->rootExpr = $this->rootExpr; @@ -61,34 +58,21 @@ public function setAlwaysOverwriteTypes(): self } /** - * When set, the sureTypes are only used for narrowing — ImpossibleCheckTypeHelper - * will not use them to determine whether the check is always-true/always-false. + * When set, ImpossibleCheckTypeHelper evaluates rootExpr in scope + * instead of analysing sureTypes/sureNotTypes. * - * Use this when the sureTypes are a side effect of the check + * If rootExpr === the check node itself, isSpecified() bails out + * immediately and ImpossibleCheckTypeHelper returns null. + * This is used when sureTypes are a side effect of the check * (e.g. str_contains narrowing haystack to non-empty-string) * rather than the determining condition. * * @api */ - public function setSpecifyOnly(): self - { - $self = new self($this->sureTypes, $this->sureNotTypes); - $self->overwrite = $this->overwrite; - $self->specifyOnly = true; - $self->newConditionalExpressionHolders = $this->newConditionalExpressionHolders; - $self->rootExpr = $this->rootExpr; - - return $self; - } - - /** - * @api - */ public function setRootExpr(?Expr $rootExpr): self { $self = new self($this->sureTypes, $this->sureNotTypes); $self->overwrite = $this->overwrite; - $self->specifyOnly = $this->specifyOnly; $self->newConditionalExpressionHolders = $this->newConditionalExpressionHolders; $self->rootExpr = $rootExpr; @@ -102,7 +86,6 @@ public function setNewConditionalExpressionHolders(array $newConditionalExpressi { $self = new self($this->sureTypes, $this->sureNotTypes); $self->overwrite = $this->overwrite; - $self->specifyOnly = $this->specifyOnly; $self->newConditionalExpressionHolders = $newConditionalExpressionHolders; $self->rootExpr = $this->rootExpr; @@ -132,11 +115,6 @@ public function shouldOverwrite(): bool return $this->overwrite; } - public function isSpecifyOnly(): bool - { - return $this->specifyOnly; - } - /** * @return array */ @@ -159,7 +137,6 @@ public function removeExpr(string $exprString): self $self = new self($sureTypes, $sureNotTypes); $self->overwrite = $this->overwrite; - $self->specifyOnly = $this->specifyOnly; $self->newConditionalExpressionHolders = $this->newConditionalExpressionHolders; $self->rootExpr = $this->rootExpr; @@ -199,9 +176,6 @@ public function intersectWith(SpecifiedTypes $other): self if ($this->overwrite && $other->overwrite) { $result = $result->setAlwaysOverwriteTypes(); } - if ($this->specifyOnly || $other->specifyOnly) { - $result = $result->setSpecifyOnly(); - } return $result->setRootExpr($rootExpr); } @@ -239,9 +213,6 @@ public function unionWith(SpecifiedTypes $other): self if ($this->overwrite || $other->overwrite) { $result = $result->setAlwaysOverwriteTypes(); } - if ($this->specifyOnly || $other->specifyOnly) { - $result = $result->setSpecifyOnly(); - } $conditionalExpressionHolders = $this->newConditionalExpressionHolders; foreach ($other->newConditionalExpressionHolders as $exprString => $holders) { @@ -273,9 +244,6 @@ public function normalize(Scope $scope): self if ($this->overwrite) { $result = $result->setAlwaysOverwriteTypes(); } - if ($this->specifyOnly) { - $result = $result->setSpecifyOnly(); - } return $result->setRootExpr($this->rootExpr); } diff --git a/src/Rules/Comparison/ImpossibleCheckTypeHelper.php b/src/Rules/Comparison/ImpossibleCheckTypeHelper.php index 40ddcff468..f5599c6394 100644 --- a/src/Rules/Comparison/ImpossibleCheckTypeHelper.php +++ b/src/Rules/Comparison/ImpossibleCheckTypeHelper.php @@ -326,10 +326,6 @@ private function getSpecifiedType( return null; } - if ($specifiedTypes->isSpecifyOnly()) { - return null; - } - $results = []; $assignedInCallVars = []; From 9e103a4fea2fbc056dd3009fafedcbee467566a9 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Wed, 27 May 2026 22:33:16 +0000 Subject: [PATCH 08/28] Add duplicate call detection for rootExpr-based type specifying When setRootExpr($node) is used, also add a sureType for the call expression with ConstantBooleanType(true). This stores the expression result in scope via filterByTruthyValue, enabling ImpossibleCheckTypeHelper to detect duplicate calls (e.g. nested identical str_ends_with inside if(str_ends_with(...))). ImpossibleCheckTypeHelper now checks scope for the expression type before the isSpecified early return. If the call result is already known (stored from a previous identical check), it reports always-true/false. Co-Authored-By: Claude Opus 4.6 --- src/Analyser/SpecifiedTypes.php | 12 ++++++------ src/Analyser/TypeSpecifier.php | 15 ++++++++++++++- .../Comparison/ImpossibleCheckTypeHelper.php | 10 ++++++++++ ...ayKeyExistsFunctionTypeSpecifyingExtension.php | 10 +++++++++- .../Php/StrContainingTypeSpecifyingExtension.php | 8 ++++++++ .../ImpossibleCheckTypeFunctionCallRuleTest.php | 13 ++++++++++++- .../ImpossibleCheckTypeMethodCallRuleTest.php | 12 ++++++++++++ tests/PHPStan/Rules/Comparison/data/bug-14705.php | 6 +++--- 8 files changed, 74 insertions(+), 12 deletions(-) diff --git a/src/Analyser/SpecifiedTypes.php b/src/Analyser/SpecifiedTypes.php index e7ffc940b3..f8367e523e 100644 --- a/src/Analyser/SpecifiedTypes.php +++ b/src/Analyser/SpecifiedTypes.php @@ -59,13 +59,13 @@ public function setAlwaysOverwriteTypes(): self /** * When set, ImpossibleCheckTypeHelper evaluates rootExpr in scope - * instead of analysing sureTypes/sureNotTypes. + * instead of analysing sureTypes/sureNotTypes. This is used when + * sureTypes are a side effect of the check (e.g. str_contains + * narrowing haystack to non-empty-string) rather than the + * determining condition. * - * If rootExpr === the check node itself, isSpecified() bails out - * immediately and ImpossibleCheckTypeHelper returns null. - * This is used when sureTypes are a side effect of the check - * (e.g. str_contains narrowing haystack to non-empty-string) - * rather than the determining condition. + * To enable duplicate call detection, callers should also add a + * sureType for the rootExpr expression with ConstantBooleanType. * * @api */ diff --git a/src/Analyser/TypeSpecifier.php b/src/Analyser/TypeSpecifier.php index 27156a8b3f..2a5f732067 100644 --- a/src/Analyser/TypeSpecifier.php +++ b/src/Analyser/TypeSpecifier.php @@ -473,7 +473,20 @@ static function (Type $type, callable $traverse) use ($templateTypeMap, &$contai $assertedType, $assert->isNegated() ? TypeSpecifierContext::createFalse() : TypeSpecifierContext::createTrue(), $scope, - )->setRootExpr($containsUnresolvedTemplate || $assert->isEquality() ? $call : null); + ); + if ($containsUnresolvedTemplate || $assert->isEquality()) { + if (!$context->null()) { + $newTypes = $newTypes->unionWith( + $this->create( + $call, + new ConstantBooleanType($context->true()), + TypeSpecifierContext::createTrue(), + $scope, + ), + ); + } + $newTypes = $newTypes->setRootExpr($call); + } $types = $types !== null ? $types->unionWith($newTypes) : $newTypes; if (!$context->null() || !$assertedType instanceof ConstantBooleanType) { diff --git a/src/Rules/Comparison/ImpossibleCheckTypeHelper.php b/src/Rules/Comparison/ImpossibleCheckTypeHelper.php index f5599c6394..0e8b774317 100644 --- a/src/Rules/Comparison/ImpossibleCheckTypeHelper.php +++ b/src/Rules/Comparison/ImpossibleCheckTypeHelper.php @@ -314,6 +314,16 @@ private function getSpecifiedType( $rootExpr = $specifiedTypes->getRootExpr(); if ($rootExpr !== null) { + if ($scope->hasExpressionType($node)->yes()) { + $nodeType = $this->treatPhpDocTypesAsCertain ? $scope->getType($node) : $scope->getNativeType($node); + if ($nodeType->isTrue()->yes()) { + return true; + } + if ($nodeType->isFalse()->yes()) { + return false; + } + } + if (self::isSpecified($typeSpecifierScope, $node, $rootExpr)) { return null; } diff --git a/src/Type/Php/ArrayKeyExistsFunctionTypeSpecifyingExtension.php b/src/Type/Php/ArrayKeyExistsFunctionTypeSpecifyingExtension.php index a20e3641ed..05205b3c64 100644 --- a/src/Type/Php/ArrayKeyExistsFunctionTypeSpecifyingExtension.php +++ b/src/Type/Php/ArrayKeyExistsFunctionTypeSpecifyingExtension.php @@ -15,6 +15,7 @@ use PHPStan\Type\Accessory\HasOffsetType; use PHPStan\Type\Accessory\NonEmptyArrayType; use PHPStan\Type\ArrayType; +use PHPStan\Type\Constant\ConstantBooleanType; use PHPStan\Type\Constant\ConstantIntegerType; use PHPStan\Type\Constant\ConstantStringType; use PHPStan\Type\FunctionTypeSpecifyingExtension; @@ -112,7 +113,14 @@ public function specifyTypes( $arrayType->getIterableValueType(), $context, $scope, - ))->setRootExpr($node); + ))->unionWith( + $this->typeSpecifier->create( + $node, + new ConstantBooleanType(true), + TypeSpecifierContext::createTrue(), + $scope, + ), + )->setRootExpr($node); } return new SpecifiedTypes(); diff --git a/src/Type/Php/StrContainingTypeSpecifyingExtension.php b/src/Type/Php/StrContainingTypeSpecifyingExtension.php index 98adef77b3..4b635a2f35 100644 --- a/src/Type/Php/StrContainingTypeSpecifyingExtension.php +++ b/src/Type/Php/StrContainingTypeSpecifyingExtension.php @@ -13,6 +13,7 @@ use PHPStan\ShouldNotHappenException; use PHPStan\Type\Accessory\AccessoryNonEmptyStringType; use PHPStan\Type\Accessory\AccessoryNonFalsyStringType; +use PHPStan\Type\Constant\ConstantBooleanType; use PHPStan\Type\FunctionTypeSpecifyingExtension; use PHPStan\Type\IntersectionType; use PHPStan\Type\StringType; @@ -84,6 +85,13 @@ public function specifyTypes(FunctionReflection $functionReflection, FuncCall $n new IntersectionType($accessories), $context, $scope, + )->unionWith( + $this->typeSpecifier->create( + $node, + new ConstantBooleanType(true), + TypeSpecifierContext::createTrue(), + $scope, + ), )->setRootExpr($node); } } diff --git a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php index bced3b1aac..5ae79ad253 100644 --- a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php @@ -546,7 +546,18 @@ public function testNonEmptySpecifiedString(): void public function testBug14705(): void { $this->treatPhpDocTypesAsCertain = true; - $this->analyse([__DIR__ . '/data/bug-14705.php'], []); + $this->analyse([__DIR__ . '/data/bug-14705.php'], [ + [ + 'Call to function str_ends_with() with non-empty-string and non-empty-string will always evaluate to true.', + 75, + 'Because the type is coming from a PHPDoc, you can turn off this check by setting treatPhpDocTypesAsCertain: false in your %configurationFile%.', + ], + [ + 'Call to function str_contains() with non-empty-string and non-empty-string will always evaluate to true.', + 87, + 'Because the type is coming from a PHPDoc, you can turn off this check by setting treatPhpDocTypesAsCertain: false in your %configurationFile%.', + ], + ]); } public function testBug2755(): void diff --git a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeMethodCallRuleTest.php b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeMethodCallRuleTest.php index 9dde818f42..f3da25c066 100644 --- a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeMethodCallRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeMethodCallRuleTest.php @@ -308,6 +308,18 @@ public function testBug10337(): void $this->analyse([__DIR__ . '/data/bug-10337.php'], []); } + public function testBug14705(): void + { + $this->treatPhpDocTypesAsCertain = true; + $this->analyse([__DIR__ . '/data/bug-14705.php'], [ + [ + 'Call to method Bug14705\Foo::isValid() with non-empty-string will always evaluate to true.', + 104, + 'If Bug14705\Foo::isValid() is impure, add @phpstan-impure PHPDoc tag above its declaration. Learn more: https://phpstan.org/blog/remembering-and-forgetting-returned-values', + ], + ]); + } + public function testInTrait(): void { $this->treatPhpDocTypesAsCertain = true; diff --git a/tests/PHPStan/Rules/Comparison/data/bug-14705.php b/tests/PHPStan/Rules/Comparison/data/bug-14705.php index d783369d27..f6562237b8 100644 --- a/tests/PHPStan/Rules/Comparison/data/bug-14705.php +++ b/tests/PHPStan/Rules/Comparison/data/bug-14705.php @@ -72,7 +72,7 @@ public function arrayKeyExistsNonEmpty(array $array, string $key): void public function strEndsWithDuplicate(string $haystack, string $needle): void { if (str_ends_with($haystack, $needle)) { - if (str_ends_with($haystack, $needle)) { // could be reported as always-true + if (str_ends_with($haystack, $needle)) { // reported as always-true } } @@ -84,7 +84,7 @@ public function strEndsWithDuplicate(string $haystack, string $needle): void public function strContainsDuplicate(string $haystack, string $needle): void { if (str_contains($haystack, $needle)) { - if (str_contains($haystack, $needle)) { // could be reported as always-true + if (str_contains($haystack, $needle)) { // reported as always-true } } @@ -101,7 +101,7 @@ public function isValid(string $foo): bool public function equalityAssertDuplicate(string $task): void { if ($this->isValid($task)) { - if ($this->isValid($task)) { // could be reported as always-true + if ($this->isValid($task)) { // reported as always-true } } From 7bee117ab282832c587994b7af9b66f12403a4c5 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Wed, 27 May 2026 22:33:21 +0000 Subject: [PATCH 09/28] Remove duplicate array_key_exists check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The same array_key_exists($prototypeParameterName, $prototypeMethodCalls) check was performed twice — the second was dead code. Detected by the new duplicate call detection for rootExpr-based type specifying. Co-Authored-By: Claude Opus 4.6 --- .../MethodCallWithPossiblyRenamedNamedArgumentRule.php | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/Rules/Methods/MethodCallWithPossiblyRenamedNamedArgumentRule.php b/src/Rules/Methods/MethodCallWithPossiblyRenamedNamedArgumentRule.php index 55d7e9d8ad..b6d9790ef7 100644 --- a/src/Rules/Methods/MethodCallWithPossiblyRenamedNamedArgumentRule.php +++ b/src/Rules/Methods/MethodCallWithPossiblyRenamedNamedArgumentRule.php @@ -51,10 +51,6 @@ public function processNode(Node $node, NodeCallbackInvoker&Scope&CollectedDataE continue; } - if (!array_key_exists($prototypeParameterName, $prototypeMethodCalls)) { - continue; - } - $callsWithParameter = $prototypeMethodCalls[$prototypeParameterName]; foreach ($callsWithParameter as [$file, $line]) { $errors[] = RuleErrorBuilder::message(sprintf( From cd00997ea39779344ae10d3881fdd6b712aa719e Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Thu, 28 May 2026 09:16:53 +0000 Subject: [PATCH 10/28] Add setSideEffectOnly() flag on SpecifiedTypes, replace rootExpr workaround MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Type-specifying extensions that narrow argument types as a side effect (e.g. str_contains narrowing haystack to non-empty-string) previously used setRootExpr($node) plus a manually-added ConstantBooleanType sureType to suppress false always-true/false reports and enable duplicate call detection. Equality assertions used the same workaround. This commit replaces that pattern with a dedicated setSideEffectOnly() flag on SpecifiedTypes: - SpecifiedTypes: new @api setSideEffectOnly() setter, isSideEffectOnly() getter, propagated through all immutable-copy methods (OR semantics in unionWith/intersectWith, preserved through normalize and setRootExpr) - ImpossibleCheckTypeHelper: dedicated sideEffectOnly block before the rootExpr block — checks hasExpressionType for duplicate detection, otherwise returns null (no always-true/false report) - MutatingScope: filterByTruthyValue/filterByFalseyValue automatically store the call's boolean result via TypeSpecifier::create when sideEffectOnly is set, enabling duplicate detection without manual ConstantBooleanType sureTypes in each extension - Extensions simplified: StrContainingTypeSpecifyingExtension: ->setSideEffectOnly() ArrayKeyExistsFunctionTypeSpecifyingExtension: ->setSideEffectOnly() PregMatchTypeSpecifyingExtension: ->setSideEffectOnly() TypeSpecifier::specifyTypesFromAsserts: ->setSideEffectOnly() All 12540 tests pass. PHPStan self-analysis reports no errors. Co-Authored-By: Claude Opus 4.6 --- src/Analyser/MutatingScope.php | 10 +++++ src/Analyser/SpecifiedTypes.php | 41 +++++++++++++++---- src/Analyser/TypeSpecifier.php | 12 +----- .../Comparison/ImpossibleCheckTypeHelper.php | 14 ++++--- ...yExistsFunctionTypeSpecifyingExtension.php | 10 +---- .../Php/PregMatchTypeSpecifyingExtension.php | 2 +- .../StrContainingTypeSpecifyingExtension.php | 10 +---- 7 files changed, 55 insertions(+), 44 deletions(-) diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index c81d35e98f..be2aa47079 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -3302,6 +3302,11 @@ public function filterByTruthyValue(Expr $expr): self } $specifiedTypes = $this->typeSpecifier->specifyTypesInCondition($this, $expr, TypeSpecifierContext::createTruthy()); + if ($specifiedTypes->isSideEffectOnly()) { + $specifiedTypes = $specifiedTypes->unionWith( + $this->typeSpecifier->create($expr, new ConstantBooleanType(true), TypeSpecifierContext::createTrue(), $this), + ); + } $scope = $this->filterBySpecifiedTypes($specifiedTypes); $this->truthyScopes[$exprString] = $scope; @@ -3319,6 +3324,11 @@ public function filterByFalseyValue(Expr $expr): self } $specifiedTypes = $this->typeSpecifier->specifyTypesInCondition($this, $expr, TypeSpecifierContext::createFalsey()); + if ($specifiedTypes->isSideEffectOnly()) { + $specifiedTypes = $specifiedTypes->unionWith( + $this->typeSpecifier->create($expr, new ConstantBooleanType(false), TypeSpecifierContext::createTrue(), $this), + ); + } $scope = $this->filterBySpecifiedTypes($specifiedTypes); $this->falseyScopes[$exprString] = $scope; diff --git a/src/Analyser/SpecifiedTypes.php b/src/Analyser/SpecifiedTypes.php index f8367e523e..134b8c719f 100644 --- a/src/Analyser/SpecifiedTypes.php +++ b/src/Analyser/SpecifiedTypes.php @@ -13,6 +13,8 @@ final class SpecifiedTypes private bool $overwrite = false; + private bool $sideEffectOnly = false; + /** @var array */ private array $newConditionalExpressionHolders = []; @@ -51,6 +53,7 @@ public function setAlwaysOverwriteTypes(): self { $self = new self($this->sureTypes, $this->sureNotTypes); $self->overwrite = true; + $self->sideEffectOnly = $this->sideEffectOnly; $self->newConditionalExpressionHolders = $this->newConditionalExpressionHolders; $self->rootExpr = $this->rootExpr; @@ -58,21 +61,32 @@ public function setAlwaysOverwriteTypes(): self } /** - * When set, ImpossibleCheckTypeHelper evaluates rootExpr in scope - * instead of analysing sureTypes/sureNotTypes. This is used when - * sureTypes are a side effect of the check (e.g. str_contains - * narrowing haystack to non-empty-string) rather than the - * determining condition. - * - * To enable duplicate call detection, callers should also add a - * sureType for the rootExpr expression with ConstantBooleanType. - * + * @api + */ + public function setSideEffectOnly(): self + { + $self = new self($this->sureTypes, $this->sureNotTypes); + $self->overwrite = $this->overwrite; + $self->sideEffectOnly = true; + $self->newConditionalExpressionHolders = $this->newConditionalExpressionHolders; + $self->rootExpr = $this->rootExpr; + + return $self; + } + + public function isSideEffectOnly(): bool + { + return $this->sideEffectOnly; + } + + /** * @api */ public function setRootExpr(?Expr $rootExpr): self { $self = new self($this->sureTypes, $this->sureNotTypes); $self->overwrite = $this->overwrite; + $self->sideEffectOnly = $this->sideEffectOnly; $self->newConditionalExpressionHolders = $this->newConditionalExpressionHolders; $self->rootExpr = $rootExpr; @@ -86,6 +100,7 @@ public function setNewConditionalExpressionHolders(array $newConditionalExpressi { $self = new self($this->sureTypes, $this->sureNotTypes); $self->overwrite = $this->overwrite; + $self->sideEffectOnly = $this->sideEffectOnly; $self->newConditionalExpressionHolders = $newConditionalExpressionHolders; $self->rootExpr = $this->rootExpr; @@ -137,6 +152,7 @@ public function removeExpr(string $exprString): self $self = new self($sureTypes, $sureNotTypes); $self->overwrite = $this->overwrite; + $self->sideEffectOnly = $this->sideEffectOnly; $self->newConditionalExpressionHolders = $this->newConditionalExpressionHolders; $self->rootExpr = $this->rootExpr; @@ -176,6 +192,9 @@ public function intersectWith(SpecifiedTypes $other): self if ($this->overwrite && $other->overwrite) { $result = $result->setAlwaysOverwriteTypes(); } + if ($this->sideEffectOnly || $other->sideEffectOnly) { + $result->sideEffectOnly = true; + } return $result->setRootExpr($rootExpr); } @@ -213,6 +232,9 @@ public function unionWith(SpecifiedTypes $other): self if ($this->overwrite || $other->overwrite) { $result = $result->setAlwaysOverwriteTypes(); } + if ($this->sideEffectOnly || $other->sideEffectOnly) { + $result->sideEffectOnly = true; + } $conditionalExpressionHolders = $this->newConditionalExpressionHolders; foreach ($other->newConditionalExpressionHolders as $exprString => $holders) { @@ -244,6 +266,7 @@ public function normalize(Scope $scope): self if ($this->overwrite) { $result = $result->setAlwaysOverwriteTypes(); } + $result->sideEffectOnly = $this->sideEffectOnly; return $result->setRootExpr($this->rootExpr); } diff --git a/src/Analyser/TypeSpecifier.php b/src/Analyser/TypeSpecifier.php index 2a5f732067..ec206ed6d1 100644 --- a/src/Analyser/TypeSpecifier.php +++ b/src/Analyser/TypeSpecifier.php @@ -475,17 +475,7 @@ static function (Type $type, callable $traverse) use ($templateTypeMap, &$contai $scope, ); if ($containsUnresolvedTemplate || $assert->isEquality()) { - if (!$context->null()) { - $newTypes = $newTypes->unionWith( - $this->create( - $call, - new ConstantBooleanType($context->true()), - TypeSpecifierContext::createTrue(), - $scope, - ), - ); - } - $newTypes = $newTypes->setRootExpr($call); + $newTypes = $newTypes->setSideEffectOnly(); } $types = $types !== null ? $types->unionWith($newTypes) : $newTypes; diff --git a/src/Rules/Comparison/ImpossibleCheckTypeHelper.php b/src/Rules/Comparison/ImpossibleCheckTypeHelper.php index 0e8b774317..cc5f9321e9 100644 --- a/src/Rules/Comparison/ImpossibleCheckTypeHelper.php +++ b/src/Rules/Comparison/ImpossibleCheckTypeHelper.php @@ -309,11 +309,7 @@ private function getSpecifiedType( return null; } - $sureTypes = $specifiedTypes->getSureTypes(); - $sureNotTypes = $specifiedTypes->getSureNotTypes(); - - $rootExpr = $specifiedTypes->getRootExpr(); - if ($rootExpr !== null) { + if ($specifiedTypes->isSideEffectOnly()) { if ($scope->hasExpressionType($node)->yes()) { $nodeType = $this->treatPhpDocTypesAsCertain ? $scope->getType($node) : $scope->getNativeType($node); if ($nodeType->isTrue()->yes()) { @@ -324,6 +320,14 @@ private function getSpecifiedType( } } + return null; + } + + $sureTypes = $specifiedTypes->getSureTypes(); + $sureNotTypes = $specifiedTypes->getSureNotTypes(); + + $rootExpr = $specifiedTypes->getRootExpr(); + if ($rootExpr !== null) { if (self::isSpecified($typeSpecifierScope, $node, $rootExpr)) { return null; } diff --git a/src/Type/Php/ArrayKeyExistsFunctionTypeSpecifyingExtension.php b/src/Type/Php/ArrayKeyExistsFunctionTypeSpecifyingExtension.php index 05205b3c64..a1800a80cc 100644 --- a/src/Type/Php/ArrayKeyExistsFunctionTypeSpecifyingExtension.php +++ b/src/Type/Php/ArrayKeyExistsFunctionTypeSpecifyingExtension.php @@ -15,7 +15,6 @@ use PHPStan\Type\Accessory\HasOffsetType; use PHPStan\Type\Accessory\NonEmptyArrayType; use PHPStan\Type\ArrayType; -use PHPStan\Type\Constant\ConstantBooleanType; use PHPStan\Type\Constant\ConstantIntegerType; use PHPStan\Type\Constant\ConstantStringType; use PHPStan\Type\FunctionTypeSpecifyingExtension; @@ -113,14 +112,7 @@ public function specifyTypes( $arrayType->getIterableValueType(), $context, $scope, - ))->unionWith( - $this->typeSpecifier->create( - $node, - new ConstantBooleanType(true), - TypeSpecifierContext::createTrue(), - $scope, - ), - )->setRootExpr($node); + ))->setSideEffectOnly(); } return new SpecifiedTypes(); diff --git a/src/Type/Php/PregMatchTypeSpecifyingExtension.php b/src/Type/Php/PregMatchTypeSpecifyingExtension.php index 2082dd0048..f544b87a7c 100644 --- a/src/Type/Php/PregMatchTypeSpecifyingExtension.php +++ b/src/Type/Php/PregMatchTypeSpecifyingExtension.php @@ -106,7 +106,7 @@ public function specifyTypes(FunctionReflection $functionReflection, FuncCall $n $matchedType, $context, $scope, - )->setRootExpr($node); + )->setSideEffectOnly(); if ($overwrite) { $types = $types->setAlwaysOverwriteTypes(); } diff --git a/src/Type/Php/StrContainingTypeSpecifyingExtension.php b/src/Type/Php/StrContainingTypeSpecifyingExtension.php index 4b635a2f35..470dd0bbcb 100644 --- a/src/Type/Php/StrContainingTypeSpecifyingExtension.php +++ b/src/Type/Php/StrContainingTypeSpecifyingExtension.php @@ -13,7 +13,6 @@ use PHPStan\ShouldNotHappenException; use PHPStan\Type\Accessory\AccessoryNonEmptyStringType; use PHPStan\Type\Accessory\AccessoryNonFalsyStringType; -use PHPStan\Type\Constant\ConstantBooleanType; use PHPStan\Type\FunctionTypeSpecifyingExtension; use PHPStan\Type\IntersectionType; use PHPStan\Type\StringType; @@ -85,14 +84,7 @@ public function specifyTypes(FunctionReflection $functionReflection, FuncCall $n new IntersectionType($accessories), $context, $scope, - )->unionWith( - $this->typeSpecifier->create( - $node, - new ConstantBooleanType(true), - TypeSpecifierContext::createTrue(), - $scope, - ), - )->setRootExpr($node); + )->setSideEffectOnly(); } } From 6f5242887a7bb9d4e51f74fb9a5062caaa59863d Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Thu, 28 May 2026 09:33:59 +0000 Subject: [PATCH 11/28] Split bug-14705 test into PHP 7.4-compatible and PHP 8.0+ parts str_contains, str_starts_with, and str_ends_with don't exist in PHP 7.4, so the test data is split into bug-14705.php (strpos, array_key_exists, equality assertions) and bug-14705-php8.php (str_* functions). The PHP 8+ test method uses #[RequiresPhp('>= 8.0')] attribute. Co-Authored-By: Claude Opus 4.6 --- ...mpossibleCheckTypeFunctionCallRuleTest.php | 13 +++- .../ImpossibleCheckTypeMethodCallRuleTest.php | 2 +- .../Rules/Comparison/data/bug-14705-php8.php | 68 +++++++++++++++++++ .../Rules/Comparison/data/bug-14705.php | 60 ---------------- 4 files changed, 79 insertions(+), 64 deletions(-) create mode 100644 tests/PHPStan/Rules/Comparison/data/bug-14705-php8.php diff --git a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php index 5ae79ad253..ec4272f8b2 100644 --- a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php @@ -546,15 +546,22 @@ public function testNonEmptySpecifiedString(): void public function testBug14705(): void { $this->treatPhpDocTypesAsCertain = true; - $this->analyse([__DIR__ . '/data/bug-14705.php'], [ + $this->analyse([__DIR__ . '/data/bug-14705.php'], []); + } + + #[RequiresPhp('>= 8.0')] + public function testBug14705Php8(): void + { + $this->treatPhpDocTypesAsCertain = true; + $this->analyse([__DIR__ . '/data/bug-14705-php8.php'], [ [ 'Call to function str_ends_with() with non-empty-string and non-empty-string will always evaluate to true.', - 75, + 50, 'Because the type is coming from a PHPDoc, you can turn off this check by setting treatPhpDocTypesAsCertain: false in your %configurationFile%.', ], [ 'Call to function str_contains() with non-empty-string and non-empty-string will always evaluate to true.', - 87, + 62, 'Because the type is coming from a PHPDoc, you can turn off this check by setting treatPhpDocTypesAsCertain: false in your %configurationFile%.', ], ]); diff --git a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeMethodCallRuleTest.php b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeMethodCallRuleTest.php index f3da25c066..5d7e93016e 100644 --- a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeMethodCallRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeMethodCallRuleTest.php @@ -314,7 +314,7 @@ public function testBug14705(): void $this->analyse([__DIR__ . '/data/bug-14705.php'], [ [ 'Call to method Bug14705\Foo::isValid() with non-empty-string will always evaluate to true.', - 104, + 44, 'If Bug14705\Foo::isValid() is impure, add @phpstan-impure PHPDoc tag above its declaration. Learn more: https://phpstan.org/blog/remembering-and-forgetting-returned-values', ], ]); diff --git a/tests/PHPStan/Rules/Comparison/data/bug-14705-php8.php b/tests/PHPStan/Rules/Comparison/data/bug-14705-php8.php new file mode 100644 index 0000000000..dcec6d3291 --- /dev/null +++ b/tests/PHPStan/Rules/Comparison/data/bug-14705-php8.php @@ -0,0 +1,68 @@ += 8.0 + +namespace Bug14705Php8; + +class Foo +{ + + /** + * str_contains with non-empty-string haystack should not report always-true. + * + * @param non-empty-string $haystack + */ + public function strContainsNonEmpty(string $haystack, string $needle): void + { + if (str_contains($haystack, $needle)) { + + } + } + + /** + * str_starts_with with non-empty-string haystack should not report always-true. + * + * @param non-empty-string $haystack + */ + public function strStartsWithNonEmpty(string $haystack, string $needle): void + { + if (str_starts_with($haystack, $needle)) { + + } + } + + /** + * str_ends_with with non-empty-string haystack should not report always-true. + * + * @param non-empty-string $haystack + */ + public function strEndsWithNonEmpty(string $haystack, string $needle): void + { + if (str_ends_with($haystack, $needle)) { + + } + } + + /** + * @param non-empty-string $needle + */ + public function strEndsWithDuplicate(string $haystack, string $needle): void + { + if (str_ends_with($haystack, $needle)) { + if (str_ends_with($haystack, $needle)) { // reported as always-true + + } + } + } + + /** + * @param non-empty-string $needle + */ + public function strContainsDuplicate(string $haystack, string $needle): void + { + if (str_contains($haystack, $needle)) { + if (str_contains($haystack, $needle)) { // reported as always-true + + } + } + } + +} diff --git a/tests/PHPStan/Rules/Comparison/data/bug-14705.php b/tests/PHPStan/Rules/Comparison/data/bug-14705.php index f6562237b8..458073d5fb 100644 --- a/tests/PHPStan/Rules/Comparison/data/bug-14705.php +++ b/tests/PHPStan/Rules/Comparison/data/bug-14705.php @@ -5,42 +5,6 @@ class Foo { - /** - * str_contains with non-empty-string haystack should not report always-true. - * - * @param non-empty-string $haystack - */ - public function strContainsNonEmpty(string $haystack, string $needle): void - { - if (str_contains($haystack, $needle)) { - - } - } - - /** - * str_starts_with with non-empty-string haystack should not report always-true. - * - * @param non-empty-string $haystack - */ - public function strStartsWithNonEmpty(string $haystack, string $needle): void - { - if (str_starts_with($haystack, $needle)) { - - } - } - - /** - * str_ends_with with non-empty-string haystack should not report always-true. - * - * @param non-empty-string $haystack - */ - public function strEndsWithNonEmpty(string $haystack, string $needle): void - { - if (str_ends_with($haystack, $needle)) { - - } - } - /** * strpos with non-empty-string haystack should not report always-true. * @@ -66,30 +30,6 @@ public function arrayKeyExistsNonEmpty(array $array, string $key): void } } - /** - * @param non-empty-string $needle - */ - public function strEndsWithDuplicate(string $haystack, string $needle): void - { - if (str_ends_with($haystack, $needle)) { - if (str_ends_with($haystack, $needle)) { // reported as always-true - - } - } - } - - /** - * @param non-empty-string $needle - */ - public function strContainsDuplicate(string $haystack, string $needle): void - { - if (str_contains($haystack, $needle)) { - if (str_contains($haystack, $needle)) { // reported as always-true - - } - } - } - /** * @phpstan-assert-if-true =non-empty-string $foo */ From 65defc2bfe5c61ca92969380c14d9066528196ed Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Thu, 28 May 2026 15:52:38 +0000 Subject: [PATCH 12/28] Rename sideEffectOnly to specifyOnly on SpecifiedTypes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The term "side effect" is overloaded — in programming it refers to function impurity, while here it meant "type narrowings that are a consequence of the check." Rename to specifyOnly which fits the SpecifiedTypes domain: these types only specify (narrow) types, they don't determine the check outcome in ImpossibleCheckTypeHelper. Co-Authored-By: Claude Opus 4.6 --- src/Analyser/MutatingScope.php | 4 +-- src/Analyser/SpecifiedTypes.php | 28 +++++++++---------- src/Analyser/TypeSpecifier.php | 2 +- .../Comparison/ImpossibleCheckTypeHelper.php | 2 +- ...yExistsFunctionTypeSpecifyingExtension.php | 2 +- .../Php/PregMatchTypeSpecifyingExtension.php | 2 +- .../StrContainingTypeSpecifyingExtension.php | 2 +- 7 files changed, 21 insertions(+), 21 deletions(-) diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index be2aa47079..079ff4b81f 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -3302,7 +3302,7 @@ public function filterByTruthyValue(Expr $expr): self } $specifiedTypes = $this->typeSpecifier->specifyTypesInCondition($this, $expr, TypeSpecifierContext::createTruthy()); - if ($specifiedTypes->isSideEffectOnly()) { + if ($specifiedTypes->isSpecifyOnly()) { $specifiedTypes = $specifiedTypes->unionWith( $this->typeSpecifier->create($expr, new ConstantBooleanType(true), TypeSpecifierContext::createTrue(), $this), ); @@ -3324,7 +3324,7 @@ public function filterByFalseyValue(Expr $expr): self } $specifiedTypes = $this->typeSpecifier->specifyTypesInCondition($this, $expr, TypeSpecifierContext::createFalsey()); - if ($specifiedTypes->isSideEffectOnly()) { + if ($specifiedTypes->isSpecifyOnly()) { $specifiedTypes = $specifiedTypes->unionWith( $this->typeSpecifier->create($expr, new ConstantBooleanType(false), TypeSpecifierContext::createTrue(), $this), ); diff --git a/src/Analyser/SpecifiedTypes.php b/src/Analyser/SpecifiedTypes.php index 134b8c719f..c28172946d 100644 --- a/src/Analyser/SpecifiedTypes.php +++ b/src/Analyser/SpecifiedTypes.php @@ -13,7 +13,7 @@ final class SpecifiedTypes private bool $overwrite = false; - private bool $sideEffectOnly = false; + private bool $specifyOnly = false; /** @var array */ private array $newConditionalExpressionHolders = []; @@ -53,7 +53,7 @@ public function setAlwaysOverwriteTypes(): self { $self = new self($this->sureTypes, $this->sureNotTypes); $self->overwrite = true; - $self->sideEffectOnly = $this->sideEffectOnly; + $self->specifyOnly = $this->specifyOnly; $self->newConditionalExpressionHolders = $this->newConditionalExpressionHolders; $self->rootExpr = $this->rootExpr; @@ -63,20 +63,20 @@ public function setAlwaysOverwriteTypes(): self /** * @api */ - public function setSideEffectOnly(): self + public function setSpecifyOnly(): self { $self = new self($this->sureTypes, $this->sureNotTypes); $self->overwrite = $this->overwrite; - $self->sideEffectOnly = true; + $self->specifyOnly = true; $self->newConditionalExpressionHolders = $this->newConditionalExpressionHolders; $self->rootExpr = $this->rootExpr; return $self; } - public function isSideEffectOnly(): bool + public function isSpecifyOnly(): bool { - return $this->sideEffectOnly; + return $this->specifyOnly; } /** @@ -86,7 +86,7 @@ public function setRootExpr(?Expr $rootExpr): self { $self = new self($this->sureTypes, $this->sureNotTypes); $self->overwrite = $this->overwrite; - $self->sideEffectOnly = $this->sideEffectOnly; + $self->specifyOnly = $this->specifyOnly; $self->newConditionalExpressionHolders = $this->newConditionalExpressionHolders; $self->rootExpr = $rootExpr; @@ -100,7 +100,7 @@ public function setNewConditionalExpressionHolders(array $newConditionalExpressi { $self = new self($this->sureTypes, $this->sureNotTypes); $self->overwrite = $this->overwrite; - $self->sideEffectOnly = $this->sideEffectOnly; + $self->specifyOnly = $this->specifyOnly; $self->newConditionalExpressionHolders = $newConditionalExpressionHolders; $self->rootExpr = $this->rootExpr; @@ -152,7 +152,7 @@ public function removeExpr(string $exprString): self $self = new self($sureTypes, $sureNotTypes); $self->overwrite = $this->overwrite; - $self->sideEffectOnly = $this->sideEffectOnly; + $self->specifyOnly = $this->specifyOnly; $self->newConditionalExpressionHolders = $this->newConditionalExpressionHolders; $self->rootExpr = $this->rootExpr; @@ -192,8 +192,8 @@ public function intersectWith(SpecifiedTypes $other): self if ($this->overwrite && $other->overwrite) { $result = $result->setAlwaysOverwriteTypes(); } - if ($this->sideEffectOnly || $other->sideEffectOnly) { - $result->sideEffectOnly = true; + if ($this->specifyOnly || $other->specifyOnly) { + $result->specifyOnly = true; } return $result->setRootExpr($rootExpr); @@ -232,8 +232,8 @@ public function unionWith(SpecifiedTypes $other): self if ($this->overwrite || $other->overwrite) { $result = $result->setAlwaysOverwriteTypes(); } - if ($this->sideEffectOnly || $other->sideEffectOnly) { - $result->sideEffectOnly = true; + if ($this->specifyOnly || $other->specifyOnly) { + $result->specifyOnly = true; } $conditionalExpressionHolders = $this->newConditionalExpressionHolders; @@ -266,7 +266,7 @@ public function normalize(Scope $scope): self if ($this->overwrite) { $result = $result->setAlwaysOverwriteTypes(); } - $result->sideEffectOnly = $this->sideEffectOnly; + $result->specifyOnly = $this->specifyOnly; return $result->setRootExpr($this->rootExpr); } diff --git a/src/Analyser/TypeSpecifier.php b/src/Analyser/TypeSpecifier.php index ec206ed6d1..eb0d549d24 100644 --- a/src/Analyser/TypeSpecifier.php +++ b/src/Analyser/TypeSpecifier.php @@ -475,7 +475,7 @@ static function (Type $type, callable $traverse) use ($templateTypeMap, &$contai $scope, ); if ($containsUnresolvedTemplate || $assert->isEquality()) { - $newTypes = $newTypes->setSideEffectOnly(); + $newTypes = $newTypes->setSpecifyOnly(); } $types = $types !== null ? $types->unionWith($newTypes) : $newTypes; diff --git a/src/Rules/Comparison/ImpossibleCheckTypeHelper.php b/src/Rules/Comparison/ImpossibleCheckTypeHelper.php index cc5f9321e9..3c2b957cf9 100644 --- a/src/Rules/Comparison/ImpossibleCheckTypeHelper.php +++ b/src/Rules/Comparison/ImpossibleCheckTypeHelper.php @@ -309,7 +309,7 @@ private function getSpecifiedType( return null; } - if ($specifiedTypes->isSideEffectOnly()) { + if ($specifiedTypes->isSpecifyOnly()) { if ($scope->hasExpressionType($node)->yes()) { $nodeType = $this->treatPhpDocTypesAsCertain ? $scope->getType($node) : $scope->getNativeType($node); if ($nodeType->isTrue()->yes()) { diff --git a/src/Type/Php/ArrayKeyExistsFunctionTypeSpecifyingExtension.php b/src/Type/Php/ArrayKeyExistsFunctionTypeSpecifyingExtension.php index a1800a80cc..8dce9bb2ac 100644 --- a/src/Type/Php/ArrayKeyExistsFunctionTypeSpecifyingExtension.php +++ b/src/Type/Php/ArrayKeyExistsFunctionTypeSpecifyingExtension.php @@ -112,7 +112,7 @@ public function specifyTypes( $arrayType->getIterableValueType(), $context, $scope, - ))->setSideEffectOnly(); + ))->setSpecifyOnly(); } return new SpecifiedTypes(); diff --git a/src/Type/Php/PregMatchTypeSpecifyingExtension.php b/src/Type/Php/PregMatchTypeSpecifyingExtension.php index f544b87a7c..6ab80861bf 100644 --- a/src/Type/Php/PregMatchTypeSpecifyingExtension.php +++ b/src/Type/Php/PregMatchTypeSpecifyingExtension.php @@ -106,7 +106,7 @@ public function specifyTypes(FunctionReflection $functionReflection, FuncCall $n $matchedType, $context, $scope, - )->setSideEffectOnly(); + )->setSpecifyOnly(); if ($overwrite) { $types = $types->setAlwaysOverwriteTypes(); } diff --git a/src/Type/Php/StrContainingTypeSpecifyingExtension.php b/src/Type/Php/StrContainingTypeSpecifyingExtension.php index 470dd0bbcb..6223f295ad 100644 --- a/src/Type/Php/StrContainingTypeSpecifyingExtension.php +++ b/src/Type/Php/StrContainingTypeSpecifyingExtension.php @@ -84,7 +84,7 @@ public function specifyTypes(FunctionReflection $functionReflection, FuncCall $n new IntersectionType($accessories), $context, $scope, - )->setSideEffectOnly(); + )->setSpecifyOnly(); } } From a8a33988c09ca39336100728fa42aa3d6190fc41 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Thu, 28 May 2026 16:24:03 +0000 Subject: [PATCH 13/28] Revert unrelated duplicate array_key_exists removal The duplicate check in MethodCallWithPossiblyRenamedNamedArgumentRule is a pre-existing issue unrelated to this PR. Reverting to keep the PR focused on the specifyOnly mechanism. Co-Authored-By: Claude Opus 4.6 --- .../MethodCallWithPossiblyRenamedNamedArgumentRule.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/Rules/Methods/MethodCallWithPossiblyRenamedNamedArgumentRule.php b/src/Rules/Methods/MethodCallWithPossiblyRenamedNamedArgumentRule.php index b6d9790ef7..55d7e9d8ad 100644 --- a/src/Rules/Methods/MethodCallWithPossiblyRenamedNamedArgumentRule.php +++ b/src/Rules/Methods/MethodCallWithPossiblyRenamedNamedArgumentRule.php @@ -51,6 +51,10 @@ public function processNode(Node $node, NodeCallbackInvoker&Scope&CollectedDataE continue; } + if (!array_key_exists($prototypeParameterName, $prototypeMethodCalls)) { + continue; + } + $callsWithParameter = $prototypeMethodCalls[$prototypeParameterName]; foreach ($callsWithParameter as [$file, $line]) { $errors[] = RuleErrorBuilder::message(sprintf( From 334ba20f00a62523253b8d318f2a74a9f8c83728 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Thu, 28 May 2026 22:06:40 +0000 Subject: [PATCH 14/28] Document setSpecifyOnly() for third-party extension migration Add PHPDoc explaining that setSpecifyOnly() works correctly with extensions that synthesize expressions for specifyTypesInCondition() (like phpstan-webmozart-assert), because filterByTruthyValue() stores the boolean result for the original call node, not the synthesized one. Co-Authored-By: Claude Opus 4.6 --- src/Analyser/SpecifiedTypes.php | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/Analyser/SpecifiedTypes.php b/src/Analyser/SpecifiedTypes.php index c28172946d..ef4657ef12 100644 --- a/src/Analyser/SpecifiedTypes.php +++ b/src/Analyser/SpecifiedTypes.php @@ -61,6 +61,23 @@ public function setAlwaysOverwriteTypes(): self } /** + * Marks these SpecifiedTypes as only narrowing types, not determining + * the check outcome. ImpossibleCheckTypeHelper will not use sureTypes + * to report always-true/false for the check expression. + * + * Duplicate detection still works: MutatingScope::filterByTruthyValue() + * stores the call's boolean result for the original node when this flag + * is set, so a nested identical check is reported as always-true. + * + * Extensions that synthesize an expression for specifyTypesInCondition() + * (e.g. decomposing Assert::string($x) into is_string($x)) can use this + * flag — filterByTruthyValue() operates on the original call node, not + * the synthesized expression. + * + * Replaces the former FAUX_FUNCTION / __PHPSTAN_FAUX_CONSTANT rootExpr + * workaround used by StrContainingTypeSpecifyingExtension, + * ArrayKeyExistsFunctionTypeSpecifyingExtension, and others. + * * @api */ public function setSpecifyOnly(): self From aca0f8e6dcc3659623b325595ede4de16c5841d2 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Thu, 28 May 2026 22:38:04 +0000 Subject: [PATCH 15/28] Correct setSpecifyOnly() PHPDoc: clarify filterByTruthyValue scope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous PHPDoc incorrectly claimed that extensions synthesizing expressions (e.g. decomposing Assert::string($x) into is_string($x)) can rely on filterByTruthyValue() for duplicate detection. This is only true for boolean-returning calls used in conditions (if/while). For void assertion methods used as statements, filterByTruthyValue() is never called — NodeScopeResolver processes them via filterBySpecifiedTypes with null context. ImpossibleCheckTypeHelper::determineContext() also returns null context for void methods. The specifyOnly mechanism does not provide duplicate detection in that path (and neither did the former setRootExpr/FAUX workaround). Co-Authored-By: Claude Opus 4.6 --- src/Analyser/SpecifiedTypes.php | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/src/Analyser/SpecifiedTypes.php b/src/Analyser/SpecifiedTypes.php index ef4657ef12..3e9df3531a 100644 --- a/src/Analyser/SpecifiedTypes.php +++ b/src/Analyser/SpecifiedTypes.php @@ -65,18 +65,15 @@ public function setAlwaysOverwriteTypes(): self * the check outcome. ImpossibleCheckTypeHelper will not use sureTypes * to report always-true/false for the check expression. * - * Duplicate detection still works: MutatingScope::filterByTruthyValue() - * stores the call's boolean result for the original node when this flag - * is set, so a nested identical check is reported as always-true. + * Duplicate detection works only for boolean-returning calls used in + * conditions (if/while): MutatingScope::filterByTruthyValue() stores + * the call's boolean result when this flag is set, so a nested + * identical check is reported as always-true. * - * Extensions that synthesize an expression for specifyTypesInCondition() - * (e.g. decomposing Assert::string($x) into is_string($x)) can use this - * flag — filterByTruthyValue() operates on the original call node, not - * the synthesized expression. - * - * Replaces the former FAUX_FUNCTION / __PHPSTAN_FAUX_CONSTANT rootExpr - * workaround used by StrContainingTypeSpecifyingExtension, - * ArrayKeyExistsFunctionTypeSpecifyingExtension, and others. + * For void assertion methods used as statements (e.g. Assert::string($x)), + * filterByTruthyValue() is not called — type narrowing goes through + * NodeScopeResolver's Expression statement handling with null context. + * Duplicate detection is not supported in that path. * * @api */ From f4e6bdbf4250711b5839ed689465a8acd7afc766 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Fri, 29 May 2026 06:44:30 +0000 Subject: [PATCH 16/28] Store specifyOnly boolean marker via overwrite to fix duplicate detection The previous approach used TypeSpecifier::create() which has a purity check that returns empty SpecifiedTypes for impure/void method calls. This prevented the boolean marker from being stored in scope, breaking duplicate detection for void assertion methods. Additionally, the marker was added via unionWith before filterBySpecifiedTypes, which caused addTypeToExpression to intersect ConstantBooleanType(true) with the existing void return type, producing NeverType instead of the expected true marker. Fix: store the boolean marker AFTER filterBySpecifiedTypes using a separate filterBySpecifiedTypes call with setAlwaysOverwriteTypes(). This bypasses both the purity check and the type intersection issue. Also adds specifyOnly handling to NodeScopeResolver's expression statement path, enabling duplicate detection for void assertion methods used as statements (e.g. Assert::string($x) called twice). Co-Authored-By: Claude Opus 4.6 --- src/Analyser/MutatingScope.php | 18 ++++++++++++------ src/Analyser/NodeScopeResolver.php | 14 ++++++++++++-- src/Analyser/SpecifiedTypes.php | 14 +++++--------- .../ImpossibleCheckTypeMethodCallRuleTest.php | 4 ++++ .../Rules/Comparison/data/bug-14705.php | 16 ++++++++++++++++ 5 files changed, 49 insertions(+), 17 deletions(-) diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 079ff4b81f..89b01e20e8 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -3302,12 +3302,15 @@ public function filterByTruthyValue(Expr $expr): self } $specifiedTypes = $this->typeSpecifier->specifyTypesInCondition($this, $expr, TypeSpecifierContext::createTruthy()); + $scope = $this->filterBySpecifiedTypes($specifiedTypes); if ($specifiedTypes->isSpecifyOnly()) { - $specifiedTypes = $specifiedTypes->unionWith( - $this->typeSpecifier->create($expr, new ConstantBooleanType(true), TypeSpecifierContext::createTrue(), $this), + $scope = $scope->filterBySpecifiedTypes( + (new SpecifiedTypes( + [$this->getNodeKey($expr) => [$expr, new ConstantBooleanType(true)]], + [], + ))->setAlwaysOverwriteTypes(), ); } - $scope = $this->filterBySpecifiedTypes($specifiedTypes); $this->truthyScopes[$exprString] = $scope; return $scope; @@ -3324,12 +3327,15 @@ public function filterByFalseyValue(Expr $expr): self } $specifiedTypes = $this->typeSpecifier->specifyTypesInCondition($this, $expr, TypeSpecifierContext::createFalsey()); + $scope = $this->filterBySpecifiedTypes($specifiedTypes); if ($specifiedTypes->isSpecifyOnly()) { - $specifiedTypes = $specifiedTypes->unionWith( - $this->typeSpecifier->create($expr, new ConstantBooleanType(false), TypeSpecifierContext::createTrue(), $this), + $scope = $scope->filterBySpecifiedTypes( + (new SpecifiedTypes( + [$this->getNodeKey($expr) => [$expr, new ConstantBooleanType(false)]], + [], + ))->setAlwaysOverwriteTypes(), ); } - $scope = $this->filterBySpecifiedTypes($specifiedTypes); $this->falseyScopes[$exprString] = $scope; return $scope; diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 3f63434d03..af4505e680 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -143,6 +143,7 @@ use PHPStan\ShouldNotHappenException; use PHPStan\TrinaryLogic; use PHPStan\Type\ClosureType; +use PHPStan\Type\Constant\ConstantBooleanType; use PHPStan\Type\Constant\ConstantIntegerType; use PHPStan\Type\Constant\ConstantStringType; use PHPStan\Type\FileTypeMapper; @@ -1142,11 +1143,20 @@ public function processStmtNode( $this->callNodeCallback($nodeCallback, new NoopExpressionNode($stmt->expr, $hasAssign), $scope, $storage); } $scope = $result->getScope(); - $scope = $scope->filterBySpecifiedTypes($this->typeSpecifier->specifyTypesInCondition( + $specifiedTypes = $this->typeSpecifier->specifyTypesInCondition( $scope, $stmt->expr, TypeSpecifierContext::createNull(), - )); + ); + $scope = $scope->filterBySpecifiedTypes($specifiedTypes); + if ($specifiedTypes->isSpecifyOnly()) { + $scope = $scope->filterBySpecifiedTypes( + (new SpecifiedTypes( + [$scope->getNodeKey($stmt->expr) => [$stmt->expr, new ConstantBooleanType(true)]], + [], + ))->setAlwaysOverwriteTypes(), + ); + } $hasYield = $result->hasYield(); $throwPoints = $result->getThrowPoints(); $impurePoints = $result->getImpurePoints(); diff --git a/src/Analyser/SpecifiedTypes.php b/src/Analyser/SpecifiedTypes.php index 3e9df3531a..7b2a946ee8 100644 --- a/src/Analyser/SpecifiedTypes.php +++ b/src/Analyser/SpecifiedTypes.php @@ -65,15 +65,11 @@ public function setAlwaysOverwriteTypes(): self * the check outcome. ImpossibleCheckTypeHelper will not use sureTypes * to report always-true/false for the check expression. * - * Duplicate detection works only for boolean-returning calls used in - * conditions (if/while): MutatingScope::filterByTruthyValue() stores - * the call's boolean result when this flag is set, so a nested - * identical check is reported as always-true. - * - * For void assertion methods used as statements (e.g. Assert::string($x)), - * filterByTruthyValue() is not called — type narrowing goes through - * NodeScopeResolver's Expression statement handling with null context. - * Duplicate detection is not supported in that path. + * Duplicate detection is handled automatically: the call expression's + * boolean result is stored in scope (bypassing purity checks), so a + * nested identical check is reported as always-true. This works both + * for boolean-returning calls in conditions (via filterByTruthyValue) + * and void assertion methods used as statements (via NodeScopeResolver). * * @api */ diff --git a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeMethodCallRuleTest.php b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeMethodCallRuleTest.php index 5d7e93016e..f1518632f8 100644 --- a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeMethodCallRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeMethodCallRuleTest.php @@ -317,6 +317,10 @@ public function testBug14705(): void 44, 'If Bug14705\Foo::isValid() is impure, add @phpstan-impure PHPDoc tag above its declaration. Learn more: https://phpstan.org/blog/remembering-and-forgetting-returned-values', ], + [ + 'Call to method Bug14705\Foo::assertValid() with non-empty-string will always evaluate to true.', + 63, + ], ]); } diff --git a/tests/PHPStan/Rules/Comparison/data/bug-14705.php b/tests/PHPStan/Rules/Comparison/data/bug-14705.php index 458073d5fb..88029fd15c 100644 --- a/tests/PHPStan/Rules/Comparison/data/bug-14705.php +++ b/tests/PHPStan/Rules/Comparison/data/bug-14705.php @@ -47,4 +47,20 @@ public function equalityAssertDuplicate(string $task): void } } + /** + * @phpstan-assert =non-empty-string $foo + */ + public function assertValid(string $foo): void + { + if ($foo === '') { + throw new \Exception(); + } + } + + public function voidAssertDuplicate(string $task): void + { + $this->assertValid($task); + $this->assertValid($task); // reported as always-true + } + } From db5c57dc2720b95ef5cde8e3f67f094cfe3475ca Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Fri, 29 May 2026 07:51:50 +0000 Subject: [PATCH 17/28] Fix specifyOnly boolean marker overwriting function return types The previous commit stored ConstantBooleanType(true) for the call expression using setAlwaysOverwriteTypes(), bypassing TypeSpecifier's purity check. For impure functions like realpath() (which has @phpstan-assert-if-true =non-empty-string), this overwrote the function's real return type in the truthy scope: getType(realpath($x)) returned true instead of non-empty-string, causing the Elvis operator realpath($x) ?: $x to produce string|true instead of string. Fix: use TypeSpecifier::create() with its purity check, as in the approach before commit 03924874d. For impure calls (hasSideEffects=yes), create() returns empty SpecifiedTypes, preventing the boolean marker from overwriting the return type. Duplicate detection still works for pure and possibly-impure calls. The NodeScopeResolver expression statement path (for void assertions) retains the overwrite approach since void calls are not used for their return value. Co-Authored-By: Claude Opus 4.6 --- src/Analyser/MutatingScope.php | 18 ++++-------- src/Analyser/SpecifiedTypes.php | 13 +++++---- .../nsrt/specifyOnly-elvis-realpath.php | 29 +++++++++++++++++++ 3 files changed, 43 insertions(+), 17 deletions(-) create mode 100644 tests/PHPStan/Analyser/nsrt/specifyOnly-elvis-realpath.php diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 89b01e20e8..079ff4b81f 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -3302,15 +3302,12 @@ public function filterByTruthyValue(Expr $expr): self } $specifiedTypes = $this->typeSpecifier->specifyTypesInCondition($this, $expr, TypeSpecifierContext::createTruthy()); - $scope = $this->filterBySpecifiedTypes($specifiedTypes); if ($specifiedTypes->isSpecifyOnly()) { - $scope = $scope->filterBySpecifiedTypes( - (new SpecifiedTypes( - [$this->getNodeKey($expr) => [$expr, new ConstantBooleanType(true)]], - [], - ))->setAlwaysOverwriteTypes(), + $specifiedTypes = $specifiedTypes->unionWith( + $this->typeSpecifier->create($expr, new ConstantBooleanType(true), TypeSpecifierContext::createTrue(), $this), ); } + $scope = $this->filterBySpecifiedTypes($specifiedTypes); $this->truthyScopes[$exprString] = $scope; return $scope; @@ -3327,15 +3324,12 @@ public function filterByFalseyValue(Expr $expr): self } $specifiedTypes = $this->typeSpecifier->specifyTypesInCondition($this, $expr, TypeSpecifierContext::createFalsey()); - $scope = $this->filterBySpecifiedTypes($specifiedTypes); if ($specifiedTypes->isSpecifyOnly()) { - $scope = $scope->filterBySpecifiedTypes( - (new SpecifiedTypes( - [$this->getNodeKey($expr) => [$expr, new ConstantBooleanType(false)]], - [], - ))->setAlwaysOverwriteTypes(), + $specifiedTypes = $specifiedTypes->unionWith( + $this->typeSpecifier->create($expr, new ConstantBooleanType(false), TypeSpecifierContext::createTrue(), $this), ); } + $scope = $this->filterBySpecifiedTypes($specifiedTypes); $this->falseyScopes[$exprString] = $scope; return $scope; diff --git a/src/Analyser/SpecifiedTypes.php b/src/Analyser/SpecifiedTypes.php index 7b2a946ee8..0c699eed5b 100644 --- a/src/Analyser/SpecifiedTypes.php +++ b/src/Analyser/SpecifiedTypes.php @@ -65,11 +65,14 @@ public function setAlwaysOverwriteTypes(): self * the check outcome. ImpossibleCheckTypeHelper will not use sureTypes * to report always-true/false for the check expression. * - * Duplicate detection is handled automatically: the call expression's - * boolean result is stored in scope (bypassing purity checks), so a - * nested identical check is reported as always-true. This works both - * for boolean-returning calls in conditions (via filterByTruthyValue) - * and void assertion methods used as statements (via NodeScopeResolver). + * Duplicate detection: filterByTruthyValue stores the call's boolean + * result via TypeSpecifier::create(), which respects purity checks. + * Pure/possibly-impure calls get duplicate detection; impure calls + * (hasSideEffects=yes) do not, to avoid overwriting the expression's + * real return type in scope. Void assertion methods used as statements + * get duplicate detection via NodeScopeResolver's expression statement + * path, which bypasses purity checks since void calls are not used + * for their return value. * * @api */ diff --git a/tests/PHPStan/Analyser/nsrt/specifyOnly-elvis-realpath.php b/tests/PHPStan/Analyser/nsrt/specifyOnly-elvis-realpath.php new file mode 100644 index 0000000000..881f0c9ed6 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/specifyOnly-elvis-realpath.php @@ -0,0 +1,29 @@ + $paths */ + public function realpathElvisWithLoop(string $fileName, array $paths): void + { + $fileName = realpath($fileName) ?: $fileName; + assertType('string', $fileName); + + foreach ($paths as $path) { + if (str_starts_with($fileName, $path)) { + assertType('string', $fileName); + } + } + } + +} From 3ac709a0baa895074d3dc1e3f154ff88f5adb9af Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Fri, 29 May 2026 08:01:27 +0000 Subject: [PATCH 18/28] Explain specifyOnly expression-statement handling in NodeScopeResolver Co-Authored-By: Claude Opus 4.8 --- src/Analyser/NodeScopeResolver.php | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index af4505e680..7ca4d3c099 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -1150,6 +1150,17 @@ public function processStmtNode( ); $scope = $scope->filterBySpecifiedTypes($specifiedTypes); if ($specifiedTypes->isSpecifyOnly()) { + // This is the expression-statement counterpart of the specifyOnly handling + // in MutatingScope::filterByTruthyValue(). A void assertion method used as a + // statement (e.g. `$this->assertValid($x);` with `@phpstan-assert =non-empty-string $x`) + // only narrows types as a side effect and never determines a check outcome, so + // ImpossibleCheckTypeHelper would otherwise have nothing to detect a duplicate + // call against. Store ConstantBooleanType(true) for the call expression so a + // second identical call is reported as always-true. Unlike filterByTruthyValue, + // we overwrite directly instead of going through TypeSpecifier::create(): void + // calls are not used for their return value, so the purity check there would + // drop the marker for the impure calls that need it most, and intersecting + // `true` with the void return type would produce *NEVER*. $scope = $scope->filterBySpecifiedTypes( (new SpecifiedTypes( [$scope->getNodeKey($stmt->expr) => [$stmt->expr, new ConstantBooleanType(true)]], From 70497394bc1a590f31d175ff7e713d83bb0cd42d Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Fri, 29 May 2026 08:10:19 +0000 Subject: [PATCH 19/28] Move bug-14705 test into nsrt with assertType, merge realpath elvis cases Consolidates the rule data file and the specifyOnly-elvis-realpath nsrt file into a single nsrt/bug-14705.php that asserts the narrowed types inside the if branches. The ImpossibleCheckType rule tests now reference the moved file for always-true duplicate-call reporting. Co-Authored-By: Claude Opus 4.8 --- tests/PHPStan/Analyser/nsrt/bug-14705.php | 130 ++++++++++++++++++ .../nsrt/specifyOnly-elvis-realpath.php | 29 ---- ...mpossibleCheckTypeFunctionCallRuleTest.php | 2 +- .../ImpossibleCheckTypeMethodCallRuleTest.php | 6 +- .../Rules/Comparison/data/bug-14705.php | 66 --------- 5 files changed, 134 insertions(+), 99 deletions(-) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-14705.php delete mode 100644 tests/PHPStan/Analyser/nsrt/specifyOnly-elvis-realpath.php delete mode 100644 tests/PHPStan/Rules/Comparison/data/bug-14705.php diff --git a/tests/PHPStan/Analyser/nsrt/bug-14705.php b/tests/PHPStan/Analyser/nsrt/bug-14705.php new file mode 100644 index 0000000000..6e3c88c0fb --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-14705.php @@ -0,0 +1,130 @@ += 8.0 + +namespace Bug14705; + +use function PHPStan\Testing\assertType; + +class Foo +{ + + /** + * strpos with non-empty-string haystack should not report always-true. + * + * @param non-empty-string $haystack + * @param non-empty-string $needle + */ + public function strposNonEmpty(string $haystack, string $needle): void + { + if (strpos($haystack, $needle) !== false) { + assertType('non-empty-string', $haystack); + assertType('non-empty-string', $needle); + } + } + + /** + * str_contains with non-empty-string haystack should not report always-true. + * + * @param non-empty-string $haystack + */ + public function strContainsNonEmpty(string $haystack, string $needle): void + { + if (str_contains($haystack, $needle)) { + assertType('non-empty-string', $haystack); + assertType('string', $needle); + } + } + + /** + * str_starts_with with non-empty-string haystack should not report always-true. + * + * @param non-empty-string $haystack + */ + public function strStartsWithNonEmpty(string $haystack, string $needle): void + { + if (str_starts_with($haystack, $needle)) { + assertType('non-empty-string', $haystack); + assertType('string', $needle); + } + } + + /** + * str_ends_with with non-empty-string haystack should not report always-true. + * + * @param non-empty-string $haystack + */ + public function strEndsWithNonEmpty(string $haystack, string $needle): void + { + if (str_ends_with($haystack, $needle)) { + assertType('non-empty-string', $haystack); + assertType('string', $needle); + } + } + + /** + * array_key_exists with non-constant key on a non-empty-array should not report always-true. + * + * @param non-empty-array $array + */ + public function arrayKeyExistsNonEmpty(array $array, string $key): void + { + if (array_key_exists($key, $array)) { + assertType('non-empty-array', $array); + } + } + + /** + * @phpstan-assert-if-true =non-empty-string $foo + */ + public function isValid(string $foo): bool + { + return $foo !== ''; + } + + public function equalityAssertDuplicate(string $task): void + { + if ($this->isValid($task)) { + assertType('non-empty-string', $task); + if ($this->isValid($task)) { // reported as always-true + assertType('non-empty-string', $task); + } + } + } + + /** + * @phpstan-assert =non-empty-string $foo + */ + public function assertValid(string $foo): void + { + if ($foo === '') { + throw new \Exception(); + } + } + + public function voidAssertDuplicate(string $task): void + { + $this->assertValid($task); + assertType('non-empty-string', $task); + $this->assertValid($task); // reported as always-true + assertType('non-empty-string', $task); + } + + public function realpathElvis(string $fileName): void + { + $fileName = realpath($fileName) ?: $fileName; + assertType('string', $fileName); + } + + /** @param list $paths */ + public function realpathElvisWithLoop(string $fileName, array $paths): void + { + $fileName = realpath($fileName) ?: $fileName; + assertType('string', $fileName); + + foreach ($paths as $path) { + if (str_starts_with($fileName, $path)) { + assertType('string', $fileName); + } + } + } + +} diff --git a/tests/PHPStan/Analyser/nsrt/specifyOnly-elvis-realpath.php b/tests/PHPStan/Analyser/nsrt/specifyOnly-elvis-realpath.php deleted file mode 100644 index 881f0c9ed6..0000000000 --- a/tests/PHPStan/Analyser/nsrt/specifyOnly-elvis-realpath.php +++ /dev/null @@ -1,29 +0,0 @@ - $paths */ - public function realpathElvisWithLoop(string $fileName, array $paths): void - { - $fileName = realpath($fileName) ?: $fileName; - assertType('string', $fileName); - - foreach ($paths as $path) { - if (str_starts_with($fileName, $path)) { - assertType('string', $fileName); - } - } - } - -} diff --git a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php index ec4272f8b2..336cc5aceb 100644 --- a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php @@ -546,7 +546,7 @@ public function testNonEmptySpecifiedString(): void public function testBug14705(): void { $this->treatPhpDocTypesAsCertain = true; - $this->analyse([__DIR__ . '/data/bug-14705.php'], []); + $this->analyse([__DIR__ . '/../../Analyser/nsrt/bug-14705.php'], []); } #[RequiresPhp('>= 8.0')] diff --git a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeMethodCallRuleTest.php b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeMethodCallRuleTest.php index f1518632f8..ff1c9d403b 100644 --- a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeMethodCallRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeMethodCallRuleTest.php @@ -311,15 +311,15 @@ public function testBug10337(): void public function testBug14705(): void { $this->treatPhpDocTypesAsCertain = true; - $this->analyse([__DIR__ . '/data/bug-14705.php'], [ + $this->analyse([__DIR__ . '/../../Analyser/nsrt/bug-14705.php'], [ [ 'Call to method Bug14705\Foo::isValid() with non-empty-string will always evaluate to true.', - 44, + 87, 'If Bug14705\Foo::isValid() is impure, add @phpstan-impure PHPDoc tag above its declaration. Learn more: https://phpstan.org/blog/remembering-and-forgetting-returned-values', ], [ 'Call to method Bug14705\Foo::assertValid() with non-empty-string will always evaluate to true.', - 63, + 107, ], ]); } diff --git a/tests/PHPStan/Rules/Comparison/data/bug-14705.php b/tests/PHPStan/Rules/Comparison/data/bug-14705.php deleted file mode 100644 index 88029fd15c..0000000000 --- a/tests/PHPStan/Rules/Comparison/data/bug-14705.php +++ /dev/null @@ -1,66 +0,0 @@ - $array - */ - public function arrayKeyExistsNonEmpty(array $array, string $key): void - { - if (array_key_exists($key, $array)) { - - } - } - - /** - * @phpstan-assert-if-true =non-empty-string $foo - */ - public function isValid(string $foo): bool - { - return $foo !== ''; - } - - public function equalityAssertDuplicate(string $task): void - { - if ($this->isValid($task)) { - if ($this->isValid($task)) { // reported as always-true - - } - } - } - - /** - * @phpstan-assert =non-empty-string $foo - */ - public function assertValid(string $foo): void - { - if ($foo === '') { - throw new \Exception(); - } - } - - public function voidAssertDuplicate(string $task): void - { - $this->assertValid($task); - $this->assertValid($task); // reported as always-true - } - -} From 2d92654c3fa60cb7550354ce13d04f0dc047c0a9 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Fri, 29 May 2026 09:03:24 +0000 Subject: [PATCH 20/28] Rename SpecifiedTypes getter to shouldSpecifyOnly() for consistency Mirrors the existing setAlwaysOverwriteTypes()/shouldOverwrite() naming pair, so the specifyOnly setter/getter follows the same convention. Co-Authored-By: Claude Opus 4.8 --- src/Analyser/MutatingScope.php | 4 ++-- src/Analyser/NodeScopeResolver.php | 2 +- src/Analyser/SpecifiedTypes.php | 2 +- src/Rules/Comparison/ImpossibleCheckTypeHelper.php | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 079ff4b81f..0221a67569 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -3302,7 +3302,7 @@ public function filterByTruthyValue(Expr $expr): self } $specifiedTypes = $this->typeSpecifier->specifyTypesInCondition($this, $expr, TypeSpecifierContext::createTruthy()); - if ($specifiedTypes->isSpecifyOnly()) { + if ($specifiedTypes->shouldSpecifyOnly()) { $specifiedTypes = $specifiedTypes->unionWith( $this->typeSpecifier->create($expr, new ConstantBooleanType(true), TypeSpecifierContext::createTrue(), $this), ); @@ -3324,7 +3324,7 @@ public function filterByFalseyValue(Expr $expr): self } $specifiedTypes = $this->typeSpecifier->specifyTypesInCondition($this, $expr, TypeSpecifierContext::createFalsey()); - if ($specifiedTypes->isSpecifyOnly()) { + if ($specifiedTypes->shouldSpecifyOnly()) { $specifiedTypes = $specifiedTypes->unionWith( $this->typeSpecifier->create($expr, new ConstantBooleanType(false), TypeSpecifierContext::createTrue(), $this), ); diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 7ca4d3c099..8ce434356f 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -1149,7 +1149,7 @@ public function processStmtNode( TypeSpecifierContext::createNull(), ); $scope = $scope->filterBySpecifiedTypes($specifiedTypes); - if ($specifiedTypes->isSpecifyOnly()) { + if ($specifiedTypes->shouldSpecifyOnly()) { // This is the expression-statement counterpart of the specifyOnly handling // in MutatingScope::filterByTruthyValue(). A void assertion method used as a // statement (e.g. `$this->assertValid($x);` with `@phpstan-assert =non-empty-string $x`) diff --git a/src/Analyser/SpecifiedTypes.php b/src/Analyser/SpecifiedTypes.php index 0c699eed5b..10db7aac42 100644 --- a/src/Analyser/SpecifiedTypes.php +++ b/src/Analyser/SpecifiedTypes.php @@ -87,7 +87,7 @@ public function setSpecifyOnly(): self return $self; } - public function isSpecifyOnly(): bool + public function shouldSpecifyOnly(): bool { return $this->specifyOnly; } diff --git a/src/Rules/Comparison/ImpossibleCheckTypeHelper.php b/src/Rules/Comparison/ImpossibleCheckTypeHelper.php index 3c2b957cf9..68906ff60d 100644 --- a/src/Rules/Comparison/ImpossibleCheckTypeHelper.php +++ b/src/Rules/Comparison/ImpossibleCheckTypeHelper.php @@ -309,7 +309,7 @@ private function getSpecifiedType( return null; } - if ($specifiedTypes->isSpecifyOnly()) { + if ($specifiedTypes->shouldSpecifyOnly()) { if ($scope->hasExpressionType($node)->yes()) { $nodeType = $this->treatPhpDocTypesAsCertain ? $scope->getType($node) : $scope->getNativeType($node); if ($nodeType->isTrue()->yes()) { From b8ea1e9ba680ef56b6a419db5569722348ed7a0a Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Fri, 29 May 2026 09:17:21 +0000 Subject: [PATCH 21/28] Add array_key_exists duplicate-in-loop assertType case to bug-14705 Co-Authored-By: Claude Opus 4.8 --- tests/PHPStan/Analyser/nsrt/bug-14705.php | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/PHPStan/Analyser/nsrt/bug-14705.php b/tests/PHPStan/Analyser/nsrt/bug-14705.php index 6e3c88c0fb..50ac5ed048 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-14705.php +++ b/tests/PHPStan/Analyser/nsrt/bug-14705.php @@ -127,4 +127,23 @@ public function realpathElvisWithLoop(string $fileName, array $paths): void } } + /** + * Duplicate array_key_exists after an early-continue narrows the negated + * call to false, while the non-negated call stays bool. + * + * @param array> $theInput + * @phpstan-param array{'name':string,'owners':array} $theInput + * @param array $theTags + */ + public function arrayKeyExistsDuplicateInLoop(array $theInput, array $theTags): void + { + foreach ($theTags as $tag) { + if (!array_key_exists($tag, $theInput)) { + continue; + } + assertType('false', !array_key_exists($tag, $theInput)); + assertType('bool', array_key_exists($tag, $theInput)); + } + } + } From a6314d631e57735deb2f82e8db58d768ff7752f8 Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Sat, 30 May 2026 11:00:00 +0200 Subject: [PATCH 22/28] Improvement --- src/Analyser/MutatingScope.php | 24 +++++++++++++++++-- ...llWithPossiblyRenamedNamedArgumentRule.php | 4 ---- tests/PHPStan/Analyser/nsrt/bug-14705.php | 12 +++++++--- ...mpossibleCheckTypeFunctionCallRuleTest.php | 11 ++++++++- 4 files changed, 41 insertions(+), 10 deletions(-) diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 0221a67569..749ba82ac4 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -3303,8 +3303,9 @@ public function filterByTruthyValue(Expr $expr): self $specifiedTypes = $this->typeSpecifier->specifyTypesInCondition($this, $expr, TypeSpecifierContext::createTruthy()); if ($specifiedTypes->shouldSpecifyOnly()) { + [$markerExpr, $markerValue] = $this->unwrapSpecifyOnlyMarker($expr, true); $specifiedTypes = $specifiedTypes->unionWith( - $this->typeSpecifier->create($expr, new ConstantBooleanType(true), TypeSpecifierContext::createTrue(), $this), + $this->typeSpecifier->create($markerExpr, new ConstantBooleanType($markerValue), TypeSpecifierContext::createTrue(), $this), ); } $scope = $this->filterBySpecifiedTypes($specifiedTypes); @@ -3325,8 +3326,9 @@ public function filterByFalseyValue(Expr $expr): self $specifiedTypes = $this->typeSpecifier->specifyTypesInCondition($this, $expr, TypeSpecifierContext::createFalsey()); if ($specifiedTypes->shouldSpecifyOnly()) { + [$markerExpr, $markerValue] = $this->unwrapSpecifyOnlyMarker($expr, false); $specifiedTypes = $specifiedTypes->unionWith( - $this->typeSpecifier->create($expr, new ConstantBooleanType(false), TypeSpecifierContext::createTrue(), $this), + $this->typeSpecifier->create($markerExpr, new ConstantBooleanType($markerValue), TypeSpecifierContext::createTrue(), $this), ); } $scope = $this->filterBySpecifiedTypes($specifiedTypes); @@ -3335,6 +3337,24 @@ public function filterByFalseyValue(Expr $expr): self return $scope; } + /** + * Strips BooleanNot wrappers from a specifyOnly condition so the boolean + * result marker is stored for the underlying call (e.g. `array_key_exists(...)`) + * rather than for the negated form (`!array_key_exists(...)`). The negated form + * is then derived from the inner value instead of being capped at bool. + * + * @return array{Expr, bool} + */ + private function unwrapSpecifyOnlyMarker(Expr $expr, bool $value): array + { + while ($expr instanceof Expr\BooleanNot) { + $expr = $expr->expr; + $value = !$value; + } + + return [$expr, $value]; + } + /** * @return static */ diff --git a/src/Rules/Methods/MethodCallWithPossiblyRenamedNamedArgumentRule.php b/src/Rules/Methods/MethodCallWithPossiblyRenamedNamedArgumentRule.php index 55d7e9d8ad..b6d9790ef7 100644 --- a/src/Rules/Methods/MethodCallWithPossiblyRenamedNamedArgumentRule.php +++ b/src/Rules/Methods/MethodCallWithPossiblyRenamedNamedArgumentRule.php @@ -51,10 +51,6 @@ public function processNode(Node $node, NodeCallbackInvoker&Scope&CollectedDataE continue; } - if (!array_key_exists($prototypeParameterName, $prototypeMethodCalls)) { - continue; - } - $callsWithParameter = $prototypeMethodCalls[$prototypeParameterName]; foreach ($callsWithParameter as [$file, $line]) { $errors[] = RuleErrorBuilder::message(sprintf( diff --git a/tests/PHPStan/Analyser/nsrt/bug-14705.php b/tests/PHPStan/Analyser/nsrt/bug-14705.php index 50ac5ed048..2c1f108050 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-14705.php +++ b/tests/PHPStan/Analyser/nsrt/bug-14705.php @@ -128,8 +128,14 @@ public function realpathElvisWithLoop(string $fileName, array $paths): void } /** - * Duplicate array_key_exists after an early-continue narrows the negated - * call to false, while the non-negated call stays bool. + * Duplicate array_key_exists after an early-continue narrows both the negated + * and the bare positive call. + * + * The condition is the BooleanNot `!array_key_exists(...)`. When the specifyOnly + * duplicate-detection marker is stored, the BooleanNot wrapper is stripped so the + * marker records the underlying `array_key_exists(...)` call as true. The negated + * form is then derived from that inner value (false), and the bare positive call + * reads the stored true directly. * * @param array> $theInput * @phpstan-param array{'name':string,'owners':array} $theInput @@ -142,7 +148,7 @@ public function arrayKeyExistsDuplicateInLoop(array $theInput, array $theTags): continue; } assertType('false', !array_key_exists($tag, $theInput)); - assertType('bool', array_key_exists($tag, $theInput)); + assertType('true', array_key_exists($tag, $theInput)); } } diff --git a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php index 336cc5aceb..08d27a022c 100644 --- a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php @@ -546,7 +546,16 @@ public function testNonEmptySpecifiedString(): void public function testBug14705(): void { $this->treatPhpDocTypesAsCertain = true; - $this->analyse([__DIR__ . '/../../Analyser/nsrt/bug-14705.php'], []); + $this->analyse([__DIR__ . '/../../Analyser/nsrt/bug-14705.php'], [ + [ + 'Call to function array_key_exists() with \'name\'|\'owners\' and array{name: string, owners: array} will always evaluate to true.', + 150, + ], + [ + 'Call to function array_key_exists() with \'name\'|\'owners\' and array{name: string, owners: array} will always evaluate to true.', + 151, + ], + ]); } #[RequiresPhp('>= 8.0')] From 13f5bbde4fc7d0169b59488accb8b6bac7a4f654 Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Sat, 30 May 2026 11:00:10 +0200 Subject: [PATCH 23/28] Revert "Improvement" This reverts commit 6af082c671426d27528aaaaa1f094e4edf76cd78. --- src/Analyser/MutatingScope.php | 24 ++----------------- ...llWithPossiblyRenamedNamedArgumentRule.php | 4 ++++ tests/PHPStan/Analyser/nsrt/bug-14705.php | 12 +++------- ...mpossibleCheckTypeFunctionCallRuleTest.php | 11 +-------- 4 files changed, 10 insertions(+), 41 deletions(-) diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 749ba82ac4..0221a67569 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -3303,9 +3303,8 @@ public function filterByTruthyValue(Expr $expr): self $specifiedTypes = $this->typeSpecifier->specifyTypesInCondition($this, $expr, TypeSpecifierContext::createTruthy()); if ($specifiedTypes->shouldSpecifyOnly()) { - [$markerExpr, $markerValue] = $this->unwrapSpecifyOnlyMarker($expr, true); $specifiedTypes = $specifiedTypes->unionWith( - $this->typeSpecifier->create($markerExpr, new ConstantBooleanType($markerValue), TypeSpecifierContext::createTrue(), $this), + $this->typeSpecifier->create($expr, new ConstantBooleanType(true), TypeSpecifierContext::createTrue(), $this), ); } $scope = $this->filterBySpecifiedTypes($specifiedTypes); @@ -3326,9 +3325,8 @@ public function filterByFalseyValue(Expr $expr): self $specifiedTypes = $this->typeSpecifier->specifyTypesInCondition($this, $expr, TypeSpecifierContext::createFalsey()); if ($specifiedTypes->shouldSpecifyOnly()) { - [$markerExpr, $markerValue] = $this->unwrapSpecifyOnlyMarker($expr, false); $specifiedTypes = $specifiedTypes->unionWith( - $this->typeSpecifier->create($markerExpr, new ConstantBooleanType($markerValue), TypeSpecifierContext::createTrue(), $this), + $this->typeSpecifier->create($expr, new ConstantBooleanType(false), TypeSpecifierContext::createTrue(), $this), ); } $scope = $this->filterBySpecifiedTypes($specifiedTypes); @@ -3337,24 +3335,6 @@ public function filterByFalseyValue(Expr $expr): self return $scope; } - /** - * Strips BooleanNot wrappers from a specifyOnly condition so the boolean - * result marker is stored for the underlying call (e.g. `array_key_exists(...)`) - * rather than for the negated form (`!array_key_exists(...)`). The negated form - * is then derived from the inner value instead of being capped at bool. - * - * @return array{Expr, bool} - */ - private function unwrapSpecifyOnlyMarker(Expr $expr, bool $value): array - { - while ($expr instanceof Expr\BooleanNot) { - $expr = $expr->expr; - $value = !$value; - } - - return [$expr, $value]; - } - /** * @return static */ diff --git a/src/Rules/Methods/MethodCallWithPossiblyRenamedNamedArgumentRule.php b/src/Rules/Methods/MethodCallWithPossiblyRenamedNamedArgumentRule.php index b6d9790ef7..55d7e9d8ad 100644 --- a/src/Rules/Methods/MethodCallWithPossiblyRenamedNamedArgumentRule.php +++ b/src/Rules/Methods/MethodCallWithPossiblyRenamedNamedArgumentRule.php @@ -51,6 +51,10 @@ public function processNode(Node $node, NodeCallbackInvoker&Scope&CollectedDataE continue; } + if (!array_key_exists($prototypeParameterName, $prototypeMethodCalls)) { + continue; + } + $callsWithParameter = $prototypeMethodCalls[$prototypeParameterName]; foreach ($callsWithParameter as [$file, $line]) { $errors[] = RuleErrorBuilder::message(sprintf( diff --git a/tests/PHPStan/Analyser/nsrt/bug-14705.php b/tests/PHPStan/Analyser/nsrt/bug-14705.php index 2c1f108050..50ac5ed048 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-14705.php +++ b/tests/PHPStan/Analyser/nsrt/bug-14705.php @@ -128,14 +128,8 @@ public function realpathElvisWithLoop(string $fileName, array $paths): void } /** - * Duplicate array_key_exists after an early-continue narrows both the negated - * and the bare positive call. - * - * The condition is the BooleanNot `!array_key_exists(...)`. When the specifyOnly - * duplicate-detection marker is stored, the BooleanNot wrapper is stripped so the - * marker records the underlying `array_key_exists(...)` call as true. The negated - * form is then derived from that inner value (false), and the bare positive call - * reads the stored true directly. + * Duplicate array_key_exists after an early-continue narrows the negated + * call to false, while the non-negated call stays bool. * * @param array> $theInput * @phpstan-param array{'name':string,'owners':array} $theInput @@ -148,7 +142,7 @@ public function arrayKeyExistsDuplicateInLoop(array $theInput, array $theTags): continue; } assertType('false', !array_key_exists($tag, $theInput)); - assertType('true', array_key_exists($tag, $theInput)); + assertType('bool', array_key_exists($tag, $theInput)); } } diff --git a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php index 08d27a022c..336cc5aceb 100644 --- a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php @@ -546,16 +546,7 @@ public function testNonEmptySpecifiedString(): void public function testBug14705(): void { $this->treatPhpDocTypesAsCertain = true; - $this->analyse([__DIR__ . '/../../Analyser/nsrt/bug-14705.php'], [ - [ - 'Call to function array_key_exists() with \'name\'|\'owners\' and array{name: string, owners: array} will always evaluate to true.', - 150, - ], - [ - 'Call to function array_key_exists() with \'name\'|\'owners\' and array{name: string, owners: array} will always evaluate to true.', - 151, - ], - ]); + $this->analyse([__DIR__ . '/../../Analyser/nsrt/bug-14705.php'], []); } #[RequiresPhp('>= 8.0')] From 4c61516440e7c634ab45505a362dbac81ba8162e Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Sat, 30 May 2026 09:04:57 +0000 Subject: [PATCH 24/28] Remove duplicate-detection paragraph from setSpecifyOnly() PHPDoc Co-Authored-By: Claude Opus 4.8 --- src/Analyser/SpecifiedTypes.php | 9 --------- 1 file changed, 9 deletions(-) diff --git a/src/Analyser/SpecifiedTypes.php b/src/Analyser/SpecifiedTypes.php index 10db7aac42..b070bcd041 100644 --- a/src/Analyser/SpecifiedTypes.php +++ b/src/Analyser/SpecifiedTypes.php @@ -65,15 +65,6 @@ public function setAlwaysOverwriteTypes(): self * the check outcome. ImpossibleCheckTypeHelper will not use sureTypes * to report always-true/false for the check expression. * - * Duplicate detection: filterByTruthyValue stores the call's boolean - * result via TypeSpecifier::create(), which respects purity checks. - * Pure/possibly-impure calls get duplicate detection; impure calls - * (hasSideEffects=yes) do not, to avoid overwriting the expression's - * real return type in scope. Void assertion methods used as statements - * get duplicate detection via NodeScopeResolver's expression statement - * path, which bypasses purity checks since void calls are not used - * for their return value. - * * @api */ public function setSpecifyOnly(): self From f3fd4433166b8b411d60873f8480e2fdd655fe38 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Sat, 30 May 2026 09:04:57 +0000 Subject: [PATCH 25/28] Shorten specifyOnly expression-statement comment Co-Authored-By: Claude Opus 4.8 --- src/Analyser/NodeScopeResolver.php | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 8ce434356f..652e180074 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -1150,17 +1150,10 @@ public function processStmtNode( ); $scope = $scope->filterBySpecifiedTypes($specifiedTypes); if ($specifiedTypes->shouldSpecifyOnly()) { - // This is the expression-statement counterpart of the specifyOnly handling - // in MutatingScope::filterByTruthyValue(). A void assertion method used as a - // statement (e.g. `$this->assertValid($x);` with `@phpstan-assert =non-empty-string $x`) - // only narrows types as a side effect and never determines a check outcome, so - // ImpossibleCheckTypeHelper would otherwise have nothing to detect a duplicate - // call against. Store ConstantBooleanType(true) for the call expression so a - // second identical call is reported as always-true. Unlike filterByTruthyValue, - // we overwrite directly instead of going through TypeSpecifier::create(): void - // calls are not used for their return value, so the purity check there would - // drop the marker for the impure calls that need it most, and intersecting - // `true` with the void return type would produce *NEVER*. + // Statement counterpart of the specifyOnly handling in filterByTruthyValue(): + // store the call's true result so a duplicate void assertion statement is + // reported as always-true. We overwrite directly (not via TypeSpecifier::create) + // because void calls have no return value to protect from the purity check. $scope = $scope->filterBySpecifiedTypes( (new SpecifiedTypes( [$scope->getNodeKey($stmt->expr) => [$stmt->expr, new ConstantBooleanType(true)]], From 32bc5c99ce26c4a959633e9d0b5df623fc9c5b59 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Sat, 30 May 2026 09:07:16 +0000 Subject: [PATCH 26/28] Annotate duplicate array_key_exists assertType with '// could be true' Co-Authored-By: Claude Opus 4.8 --- tests/PHPStan/Analyser/nsrt/bug-14705.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/PHPStan/Analyser/nsrt/bug-14705.php b/tests/PHPStan/Analyser/nsrt/bug-14705.php index 50ac5ed048..e5dc4525e5 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-14705.php +++ b/tests/PHPStan/Analyser/nsrt/bug-14705.php @@ -142,7 +142,7 @@ public function arrayKeyExistsDuplicateInLoop(array $theInput, array $theTags): continue; } assertType('false', !array_key_exists($tag, $theInput)); - assertType('bool', array_key_exists($tag, $theInput)); + assertType('bool', array_key_exists($tag, $theInput)); // could be true } } From 18e2767ee70acafd005d2cf4906910cf77b2d4be Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Thu, 4 Jun 2026 12:01:32 +0000 Subject: [PATCH 27/28] Avoid setAlwaysOverwriteTypes() in specifyOnly statement path Replace the SpecifiedTypes + setAlwaysOverwriteTypes() construction with a direct assignExpression() call, which is what the overwrite branch of filterBySpecifiedTypes() does internally. Functionally identical, but avoids the bug-prone setAlwaysOverwriteTypes() flag. Co-Authored-By: Claude Opus 4.8 --- src/Analyser/NodeScopeResolver.php | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 652e180074..875490c841 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -1152,14 +1152,9 @@ public function processStmtNode( if ($specifiedTypes->shouldSpecifyOnly()) { // Statement counterpart of the specifyOnly handling in filterByTruthyValue(): // store the call's true result so a duplicate void assertion statement is - // reported as always-true. We overwrite directly (not via TypeSpecifier::create) - // because void calls have no return value to protect from the purity check. - $scope = $scope->filterBySpecifiedTypes( - (new SpecifiedTypes( - [$scope->getNodeKey($stmt->expr) => [$stmt->expr, new ConstantBooleanType(true)]], - [], - ))->setAlwaysOverwriteTypes(), - ); + // reported as always-true. We assign directly because void calls have no + // return value to protect, and intersecting true with void would produce never. + $scope = $scope->assignExpression($stmt->expr, new ConstantBooleanType(true), new ConstantBooleanType(true)); } $hasYield = $result->hasYield(); $throwPoints = $result->getThrowPoints(); From 69a9e9869f1e6b74d293159f223bd291e9b4ba72 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Thu, 4 Jun 2026 12:16:20 +0000 Subject: [PATCH 28/28] Rename specifyOnly to equality on SpecifiedTypes Renames setSpecifyOnly()/shouldSpecifyOnly() to setEquality()/isEquality() and the backing property to $equality, tying the flag to the documented "equality assertions" concept it implements (https://phpstan.org/writing-php-code/narrowing-types#equality-assertions). The flag is set by AssertTag::isEquality() assertions and by the type-specifying extensions that narrow argument types as a consequence of a check; in all those cases the narrowed types must not determine the check outcome in ImpossibleCheckTypeHelper. Co-Authored-By: Claude Opus 4.8 --- src/Analyser/MutatingScope.php | 4 +- src/Analyser/NodeScopeResolver.php | 4 +- src/Analyser/SpecifiedTypes.php | 38 ++++++++++--------- src/Analyser/TypeSpecifier.php | 2 +- .../Comparison/ImpossibleCheckTypeHelper.php | 2 +- ...yExistsFunctionTypeSpecifyingExtension.php | 2 +- .../Php/PregMatchTypeSpecifyingExtension.php | 2 +- .../StrContainingTypeSpecifyingExtension.php | 2 +- 8 files changed, 30 insertions(+), 26 deletions(-) diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 0221a67569..413190d0c5 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -3302,7 +3302,7 @@ public function filterByTruthyValue(Expr $expr): self } $specifiedTypes = $this->typeSpecifier->specifyTypesInCondition($this, $expr, TypeSpecifierContext::createTruthy()); - if ($specifiedTypes->shouldSpecifyOnly()) { + if ($specifiedTypes->isEquality()) { $specifiedTypes = $specifiedTypes->unionWith( $this->typeSpecifier->create($expr, new ConstantBooleanType(true), TypeSpecifierContext::createTrue(), $this), ); @@ -3324,7 +3324,7 @@ public function filterByFalseyValue(Expr $expr): self } $specifiedTypes = $this->typeSpecifier->specifyTypesInCondition($this, $expr, TypeSpecifierContext::createFalsey()); - if ($specifiedTypes->shouldSpecifyOnly()) { + if ($specifiedTypes->isEquality()) { $specifiedTypes = $specifiedTypes->unionWith( $this->typeSpecifier->create($expr, new ConstantBooleanType(false), TypeSpecifierContext::createTrue(), $this), ); diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 875490c841..99e0082a6d 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -1149,8 +1149,8 @@ public function processStmtNode( TypeSpecifierContext::createNull(), ); $scope = $scope->filterBySpecifiedTypes($specifiedTypes); - if ($specifiedTypes->shouldSpecifyOnly()) { - // Statement counterpart of the specifyOnly handling in filterByTruthyValue(): + if ($specifiedTypes->isEquality()) { + // Statement counterpart of the equality handling in filterByTruthyValue(): // store the call's true result so a duplicate void assertion statement is // reported as always-true. We assign directly because void calls have no // return value to protect, and intersecting true with void would produce never. diff --git a/src/Analyser/SpecifiedTypes.php b/src/Analyser/SpecifiedTypes.php index b070bcd041..45b40978b2 100644 --- a/src/Analyser/SpecifiedTypes.php +++ b/src/Analyser/SpecifiedTypes.php @@ -13,7 +13,7 @@ final class SpecifiedTypes private bool $overwrite = false; - private bool $specifyOnly = false; + private bool $equality = false; /** @var array */ private array $newConditionalExpressionHolders = []; @@ -53,7 +53,7 @@ public function setAlwaysOverwriteTypes(): self { $self = new self($this->sureTypes, $this->sureNotTypes); $self->overwrite = true; - $self->specifyOnly = $this->specifyOnly; + $self->equality = $this->equality; $self->newConditionalExpressionHolders = $this->newConditionalExpressionHolders; $self->rootExpr = $this->rootExpr; @@ -61,26 +61,30 @@ public function setAlwaysOverwriteTypes(): self } /** - * Marks these SpecifiedTypes as only narrowing types, not determining - * the check outcome. ImpossibleCheckTypeHelper will not use sureTypes - * to report always-true/false for the check expression. + * Marks these types as coming from an equality check, the same concept as + * the "=Type" equality assertions documented at + * https://phpstan.org/writing-php-code/narrowing-types#equality-assertions + * + * The narrowed types are only applied; they do not determine the check + * outcome, so ImpossibleCheckTypeHelper will not use them to report + * always-true/false for the check expression. * * @api */ - public function setSpecifyOnly(): self + public function setEquality(): self { $self = new self($this->sureTypes, $this->sureNotTypes); $self->overwrite = $this->overwrite; - $self->specifyOnly = true; + $self->equality = true; $self->newConditionalExpressionHolders = $this->newConditionalExpressionHolders; $self->rootExpr = $this->rootExpr; return $self; } - public function shouldSpecifyOnly(): bool + public function isEquality(): bool { - return $this->specifyOnly; + return $this->equality; } /** @@ -90,7 +94,7 @@ public function setRootExpr(?Expr $rootExpr): self { $self = new self($this->sureTypes, $this->sureNotTypes); $self->overwrite = $this->overwrite; - $self->specifyOnly = $this->specifyOnly; + $self->equality = $this->equality; $self->newConditionalExpressionHolders = $this->newConditionalExpressionHolders; $self->rootExpr = $rootExpr; @@ -104,7 +108,7 @@ public function setNewConditionalExpressionHolders(array $newConditionalExpressi { $self = new self($this->sureTypes, $this->sureNotTypes); $self->overwrite = $this->overwrite; - $self->specifyOnly = $this->specifyOnly; + $self->equality = $this->equality; $self->newConditionalExpressionHolders = $newConditionalExpressionHolders; $self->rootExpr = $this->rootExpr; @@ -156,7 +160,7 @@ public function removeExpr(string $exprString): self $self = new self($sureTypes, $sureNotTypes); $self->overwrite = $this->overwrite; - $self->specifyOnly = $this->specifyOnly; + $self->equality = $this->equality; $self->newConditionalExpressionHolders = $this->newConditionalExpressionHolders; $self->rootExpr = $this->rootExpr; @@ -196,8 +200,8 @@ public function intersectWith(SpecifiedTypes $other): self if ($this->overwrite && $other->overwrite) { $result = $result->setAlwaysOverwriteTypes(); } - if ($this->specifyOnly || $other->specifyOnly) { - $result->specifyOnly = true; + if ($this->equality || $other->equality) { + $result->equality = true; } return $result->setRootExpr($rootExpr); @@ -236,8 +240,8 @@ public function unionWith(SpecifiedTypes $other): self if ($this->overwrite || $other->overwrite) { $result = $result->setAlwaysOverwriteTypes(); } - if ($this->specifyOnly || $other->specifyOnly) { - $result->specifyOnly = true; + if ($this->equality || $other->equality) { + $result->equality = true; } $conditionalExpressionHolders = $this->newConditionalExpressionHolders; @@ -270,7 +274,7 @@ public function normalize(Scope $scope): self if ($this->overwrite) { $result = $result->setAlwaysOverwriteTypes(); } - $result->specifyOnly = $this->specifyOnly; + $result->equality = $this->equality; return $result->setRootExpr($this->rootExpr); } diff --git a/src/Analyser/TypeSpecifier.php b/src/Analyser/TypeSpecifier.php index eb0d549d24..0012265860 100644 --- a/src/Analyser/TypeSpecifier.php +++ b/src/Analyser/TypeSpecifier.php @@ -475,7 +475,7 @@ static function (Type $type, callable $traverse) use ($templateTypeMap, &$contai $scope, ); if ($containsUnresolvedTemplate || $assert->isEquality()) { - $newTypes = $newTypes->setSpecifyOnly(); + $newTypes = $newTypes->setEquality(); } $types = $types !== null ? $types->unionWith($newTypes) : $newTypes; diff --git a/src/Rules/Comparison/ImpossibleCheckTypeHelper.php b/src/Rules/Comparison/ImpossibleCheckTypeHelper.php index 68906ff60d..a24da433bc 100644 --- a/src/Rules/Comparison/ImpossibleCheckTypeHelper.php +++ b/src/Rules/Comparison/ImpossibleCheckTypeHelper.php @@ -309,7 +309,7 @@ private function getSpecifiedType( return null; } - if ($specifiedTypes->shouldSpecifyOnly()) { + if ($specifiedTypes->isEquality()) { if ($scope->hasExpressionType($node)->yes()) { $nodeType = $this->treatPhpDocTypesAsCertain ? $scope->getType($node) : $scope->getNativeType($node); if ($nodeType->isTrue()->yes()) { diff --git a/src/Type/Php/ArrayKeyExistsFunctionTypeSpecifyingExtension.php b/src/Type/Php/ArrayKeyExistsFunctionTypeSpecifyingExtension.php index 8dce9bb2ac..6782f40811 100644 --- a/src/Type/Php/ArrayKeyExistsFunctionTypeSpecifyingExtension.php +++ b/src/Type/Php/ArrayKeyExistsFunctionTypeSpecifyingExtension.php @@ -112,7 +112,7 @@ public function specifyTypes( $arrayType->getIterableValueType(), $context, $scope, - ))->setSpecifyOnly(); + ))->setEquality(); } return new SpecifiedTypes(); diff --git a/src/Type/Php/PregMatchTypeSpecifyingExtension.php b/src/Type/Php/PregMatchTypeSpecifyingExtension.php index 6ab80861bf..5f13ed6016 100644 --- a/src/Type/Php/PregMatchTypeSpecifyingExtension.php +++ b/src/Type/Php/PregMatchTypeSpecifyingExtension.php @@ -106,7 +106,7 @@ public function specifyTypes(FunctionReflection $functionReflection, FuncCall $n $matchedType, $context, $scope, - )->setSpecifyOnly(); + )->setEquality(); if ($overwrite) { $types = $types->setAlwaysOverwriteTypes(); } diff --git a/src/Type/Php/StrContainingTypeSpecifyingExtension.php b/src/Type/Php/StrContainingTypeSpecifyingExtension.php index 6223f295ad..d86f59b4ad 100644 --- a/src/Type/Php/StrContainingTypeSpecifyingExtension.php +++ b/src/Type/Php/StrContainingTypeSpecifyingExtension.php @@ -84,7 +84,7 @@ public function specifyTypes(FunctionReflection $functionReflection, FuncCall $n new IntersectionType($accessories), $context, $scope, - )->setSpecifyOnly(); + )->setEquality(); } }