From 8440114fab52fb6b023b7a002e3170e2417807b0 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 5cfa65dc53b..8c8c9c3aca6 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 27156a8b3f0..547a1bbecd8 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 55138f3de6a..75727bd8122 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 c48fee653fd..f10a4aeb19e 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 84b50e00cf3..4e50b181eae 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 4c8c7695324..bced3b1aace 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 00000000000..fb663588066 --- /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 496344eaef0b11dcc2406b59e8d4b6f4ba68157b 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 75727bd8122..effae89d0f9 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 da96a3b835d3588c6308f79094ee193a0f3f42df 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 8c8c9c3aca6..dde5a235b09 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 547a1bbecd8..f4b1cf08e85 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 effae89d0f9..a7f826b6ee8 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 f10a4aeb19e..af17a5e31ad 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 4e50b181eae..59f03b423e3 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 8214e1f14377284864eac206b0655a23e2695504 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 dde5a235b09..d2713eedee3 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 f4b1cf08e85..12d9cccc91a 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 a7f826b6ee8..89354b5515d 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 af17a5e31ad..8dce9bb2aca 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 59f03b423e3..6223f295ad8 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 c21b148860705a2d9a6bb20546804cccb6befa8d 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 12d9cccc91a..27156a8b3f0 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 89354b5515d..370c6912633 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 fb663588066..6a21c6efb71 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 8c923f9725184485a536ff43a7f17249afa991bc 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 8dce9bb2aca..a20e3641edc 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 6223f295ad8..98adef77b31 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 6a21c6efb71..d783369d271 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 5c4090eb6df37d244714a2df2bbb36a1c4d3af0b 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 d2713eedee3..e7ffc940b3a 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 370c6912633..55138f3de6a 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 d9e8c7e838e5e3367ee4cb0518d64e143fa9a0cb 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 e7ffc940b3a..f8367e523ed 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 27156a8b3f0..2a5f7320672 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 55138f3de6a..0d2c5ecf3fe 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 a20e3641edc..05205b3c648 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 98adef77b31..4b635a2f35c 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 bced3b1aace..5ae79ad2531 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 9dde818f42c..f3da25c066f 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 d783369d271..f6562237b8e 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 69f8f41c915676ba7100cf6ca4225c3fa7e14ee6 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 55d7e9d8ad1..b6d9790ef7c 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 9e2faf7044fc29763a601ccdd45ac709f6c075f2 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 c81d35e98fd..be2aa47079c 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 f8367e523ed..134b8c719f2 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 2a5f7320672..ec206ed6d19 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 0d2c5ecf3fe..d495dcc2524 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 05205b3c648..a1800a80ccc 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 2082dd00484..f544b87a7c7 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 4b635a2f35c..470dd0bbcb7 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 1944fadcc35485808f4b9dc00358d550e52e756c 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 5ae79ad2531..ec4272f8b25 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 f3da25c066f..5d7e93016e6 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 00000000000..dcec6d3291b --- /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 f6562237b8e..458073d5fb6 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 b623c58bb6eb99a4bb9b7e77bccb26a964dc52ba 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 be2aa47079c..079ff4b81fb 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 134b8c719f2..c28172946d2 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 ec206ed6d19..eb0d549d24b 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 d495dcc2524..40609122703 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 a1800a80ccc..8dce9bb2aca 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 f544b87a7c7..6ab80861bfc 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 470dd0bbcb7..6223f295ad8 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 271e05dd8cf5f58fcca75ad7d76ef31a130eeb81 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 b6d9790ef7c..55d7e9d8ad1 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 24d8128687232d3ac751d751ee763b7dadee3700 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 c28172946d2..ef4657ef12a 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 7a1e3591b6a4f84a086466d3be5014e7e1868ca0 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 ef4657ef12a..3e9df3531ae 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 a8e884d210d71dcbc2e6f3aa5d88704139de2a80 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 079ff4b81fb..89b01e20e86 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 9faedfce2f9..6c70a41cd16 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; @@ -1143,11 +1144,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 3e9df3531ae..7b2a946ee8d 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 5d7e93016e6..f1518632f81 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 458073d5fb6..88029fd15cd 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 01df13e9b1e30c88fb035b7b087b261742b48ac6 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 89b01e20e86..079ff4b81fb 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 7b2a946ee8d..0c699eed5b1 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 00000000000..881f0c9ed68 --- /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 7ee7ca6bb07d4ccbadc3c124f9d6c80fee1c937c 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 6c70a41cd16..b91d93541bd 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -1151,6 +1151,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 64bd38403c1aa4539302f7e1d3722ccd077161e7 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 00000000000..6e3c88c0fbe --- /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 881f0c9ed68..00000000000 --- 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 ec4272f8b25..336cc5aceb8 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 f1518632f81..ff1c9d403b4 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 88029fd15cd..00000000000 --- 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 f6e0c22548ccd8c24881f8bc66635755f451d02a 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 079ff4b81fb..0221a675699 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 b91d93541bd..c1d3fefa614 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -1150,7 +1150,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 0c699eed5b1..10db7aac427 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 40609122703..34858ff9bed 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 06aaa75e988cad8af5bd1a46444b08fe8f9da964 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 6e3c88c0fbe..50ac5ed0480 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 a6f0d80aa8e76444397f8bbc18d2e6f7a8b627a0 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 0221a675699..749ba82ac4f 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 55d7e9d8ad1..b6d9790ef7c 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 50ac5ed0480..2c1f1080500 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 336cc5aceb8..08d27a022c6 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 361b945b69ff8447b53fa02c67b91c9e7bbcc239 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 749ba82ac4f..0221a675699 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 b6d9790ef7c..55d7e9d8ad1 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 2c1f1080500..50ac5ed0480 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 08d27a022c6..336cc5aceb8 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 48401fdbaadf59daf71afa5e845257925881c0aa 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 10db7aac427..b070bcd041b 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 e6a3a3e69a02d289890eeeea71519ec43af43c40 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 c1d3fefa614..fd813ade525 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -1151,17 +1151,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 cfbf41349c8b8fdd6a424480faa2ae4d57394429 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 50ac5ed0480..e5dc4525e50 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 38dbc55f3ebb7d6f4309ac38f7eae3cc94418d7b 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 fd813ade525..0c77230b77a 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -1153,14 +1153,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 867ba241beb5bdae09d58a87cfa8f6456c52cbf8 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 0221a675699..413190d0c54 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 0c77230b77a..0968e40b362 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -1150,8 +1150,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 b070bcd041b..45b40978b22 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 eb0d549d24b..00122658600 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 34858ff9bed..bfdcbfb229c 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 8dce9bb2aca..6782f408112 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 6ab80861bfc..5f13ed60161 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 6223f295ad8..d86f59b4ad2 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(); } }