From bd433097c05918fa7620d38a10016f4ee85c22ca 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/26] 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 01ab4208a33..56aaa44219b 100644 --- a/src/Analyser/TypeSpecifier.php +++ b/src/Analyser/TypeSpecifier.php @@ -1856,7 +1856,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 0a1621c842a..ac0fd047fcb 100644 --- a/src/Rules/Comparison/ImpossibleCheckTypeHelper.php +++ b/src/Rules/Comparison/ImpossibleCheckTypeHelper.php @@ -273,6 +273,10 @@ public function findSpecifiedType( 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 a72250dfc0b..db0e40ab629 100644 --- a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php @@ -507,6 +507,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 8a3fc6e598e896df69947168a44c2c640edd042b Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Tue, 26 May 2026 19:14:56 +0000 Subject: [PATCH 02/26] 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 ac0fd047fcb..9a30414e366 100644 --- a/src/Rules/Comparison/ImpossibleCheckTypeHelper.php +++ b/src/Rules/Comparison/ImpossibleCheckTypeHelper.php @@ -273,6 +273,9 @@ public function findSpecifiedType( 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 673de07199650e9ccf4c217ca6e6d7ca79588e71 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Tue, 26 May 2026 19:42:03 +0000 Subject: [PATCH 03/26] 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 56aaa44219b..58dde492d4b 100644 --- a/src/Analyser/TypeSpecifier.php +++ b/src/Analyser/TypeSpecifier.php @@ -1858,7 +1858,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 9a30414e366..682dfc0b9e2 100644 --- a/src/Rules/Comparison/ImpossibleCheckTypeHelper.php +++ b/src/Rules/Comparison/ImpossibleCheckTypeHelper.php @@ -276,7 +276,7 @@ public function findSpecifiedType( // 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 184d584597f2c97962255ac9dd19912a8949cffa Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Tue, 26 May 2026 19:56:14 +0000 Subject: [PATCH 04/26] 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 58dde492d4b..42de1aac761 100644 --- a/src/Analyser/TypeSpecifier.php +++ b/src/Analyser/TypeSpecifier.php @@ -1858,7 +1858,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 682dfc0b9e2..756b30232a9 100644 --- a/src/Rules/Comparison/ImpossibleCheckTypeHelper.php +++ b/src/Rules/Comparison/ImpossibleCheckTypeHelper.php @@ -273,10 +273,7 @@ public function findSpecifiedType( 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 d6b3f77963e15a2858bdf4a6d4b69013d8ff0818 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Wed, 27 May 2026 09:23:33 +0000 Subject: [PATCH 05/26] 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 42de1aac761..01ab4208a33 100644 --- a/src/Analyser/TypeSpecifier.php +++ b/src/Analyser/TypeSpecifier.php @@ -1856,10 +1856,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 756b30232a9..dc9ff2f34a0 100644 --- a/src/Rules/Comparison/ImpossibleCheckTypeHelper.php +++ b/src/Rules/Comparison/ImpossibleCheckTypeHelper.php @@ -273,10 +273,6 @@ public function findSpecifiedType( return null; } - if ($specifiedTypes->isSpecifyOnly()) { - return null; - } - $sureTypes = $specifiedTypes->getSureTypes(); $sureNotTypes = $specifiedTypes->getSureNotTypes(); @@ -294,6 +290,10 @@ public function findSpecifiedType( 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 2fe0e2c2f62477c4b195846c0810c3530239522d Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Wed, 27 May 2026 20:01:07 +0200 Subject: [PATCH 06/26] 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 7e59a4273d491e85c64bab410fe1c13df5834138 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Wed, 27 May 2026 18:25:38 +0000 Subject: [PATCH 07/26] 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 dc9ff2f34a0..0a1621c842a 100644 --- a/src/Rules/Comparison/ImpossibleCheckTypeHelper.php +++ b/src/Rules/Comparison/ImpossibleCheckTypeHelper.php @@ -290,10 +290,6 @@ public function findSpecifiedType( return null; } - if ($specifiedTypes->isSpecifyOnly()) { - return null; - } - $results = []; $assignedInCallVars = []; From 3c6a6dfd658adaf1b20f76d9763f0f5e92503215 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Wed, 27 May 2026 22:33:16 +0000 Subject: [PATCH 08/26] 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 01ab4208a33..197ed24d668 100644 --- a/src/Analyser/TypeSpecifier.php +++ b/src/Analyser/TypeSpecifier.php @@ -1856,7 +1856,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 0a1621c842a..e6d3d9a4471 100644 --- a/src/Rules/Comparison/ImpossibleCheckTypeHelper.php +++ b/src/Rules/Comparison/ImpossibleCheckTypeHelper.php @@ -278,6 +278,16 @@ public function findSpecifiedType( $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 db0e40ab629..8d88a06b8a7 100644 --- a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php @@ -510,7 +510,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 ee9d8ef0d1db965f08ad4c9de10e759b1830dbe7 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Wed, 27 May 2026 22:33:21 +0000 Subject: [PATCH 09/26] 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 b4de1d8bf973f0c5faa391f0da824c7e71f9f936 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Thu, 28 May 2026 09:16:53 +0000 Subject: [PATCH 10/26] 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 214844aac16..3d31fea126f 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -3265,6 +3265,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; @@ -3282,6 +3287,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 197ed24d668..b244a94f02f 100644 --- a/src/Analyser/TypeSpecifier.php +++ b/src/Analyser/TypeSpecifier.php @@ -1858,17 +1858,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 e6d3d9a4471..65de419b8fe 100644 --- a/src/Rules/Comparison/ImpossibleCheckTypeHelper.php +++ b/src/Rules/Comparison/ImpossibleCheckTypeHelper.php @@ -273,11 +273,7 @@ public function findSpecifiedType( 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()) { @@ -288,6 +284,14 @@ public function findSpecifiedType( } } + 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 399ee9126fd..1ae9d0314fe 100644 --- a/src/Type/Php/PregMatchTypeSpecifyingExtension.php +++ b/src/Type/Php/PregMatchTypeSpecifyingExtension.php @@ -83,7 +83,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 08421e8647a46e7d704e2c1179015cdc8a3e446d Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Thu, 28 May 2026 09:33:59 +0000 Subject: [PATCH 11/26] 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 8d88a06b8a7..f3d57fe11b6 100644 --- a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php @@ -510,15 +510,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 8277b778c165d73c9f88fa714874cc26650a96cb Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Thu, 28 May 2026 15:52:38 +0000 Subject: [PATCH 12/26] 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 3d31fea126f..9e5ba8e9b70 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -3265,7 +3265,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), ); @@ -3287,7 +3287,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 b244a94f02f..9d8fee96a96 100644 --- a/src/Analyser/TypeSpecifier.php +++ b/src/Analyser/TypeSpecifier.php @@ -1858,7 +1858,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 65de419b8fe..0cfe960438a 100644 --- a/src/Rules/Comparison/ImpossibleCheckTypeHelper.php +++ b/src/Rules/Comparison/ImpossibleCheckTypeHelper.php @@ -273,7 +273,7 @@ public function findSpecifiedType( 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 1ae9d0314fe..3460d6321a7 100644 --- a/src/Type/Php/PregMatchTypeSpecifyingExtension.php +++ b/src/Type/Php/PregMatchTypeSpecifyingExtension.php @@ -83,7 +83,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 5d8e6cd833398bf9474cadec4149b9ff5910bc1d Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Thu, 28 May 2026 16:24:03 +0000 Subject: [PATCH 13/26] 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 b09209c58295df17615819073630fb7fe5f76064 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Thu, 28 May 2026 22:06:40 +0000 Subject: [PATCH 14/26] 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 980c39a1aac1433393c965b46704ecfa8dd2fe39 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Thu, 28 May 2026 22:38:04 +0000 Subject: [PATCH 15/26] 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 dbec47ea0a4eaaf3bba00fd5dcb2df25bf0e9fa7 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Fri, 29 May 2026 06:44:30 +0000 Subject: [PATCH 16/26] 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 9e5ba8e9b70..13e3e90e6c3 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -3265,12 +3265,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; @@ -3287,12 +3290,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 41fdf890721..867e5fd5a30 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 b03091abded73ab79928e2d077a73c6fdcfe5c0c Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Fri, 29 May 2026 07:51:50 +0000 Subject: [PATCH 17/26] 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 13e3e90e6c3..9e5ba8e9b70 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -3265,15 +3265,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; @@ -3290,15 +3287,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 df66dbd87287d68fed4b1e20682fbea5da580b01 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Fri, 29 May 2026 08:01:27 +0000 Subject: [PATCH 18/26] 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 867e5fd5a30..bab310c8224 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 341f63d87cdb530982d73e752c6fba7a810857d8 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Fri, 29 May 2026 08:10:19 +0000 Subject: [PATCH 19/26] 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 f3d57fe11b6..cdbc3a989a4 100644 --- a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php @@ -510,7 +510,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 a3e940bfb2d48952e7465c16cfb7bc86eb2a2da6 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Fri, 29 May 2026 09:03:24 +0000 Subject: [PATCH 20/26] 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 9e5ba8e9b70..3e9198be09a 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -3265,7 +3265,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), ); @@ -3287,7 +3287,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 bab310c8224..21087970b46 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 0cfe960438a..bd02624a402 100644 --- a/src/Rules/Comparison/ImpossibleCheckTypeHelper.php +++ b/src/Rules/Comparison/ImpossibleCheckTypeHelper.php @@ -273,7 +273,7 @@ public function findSpecifiedType( 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 d003b39a636c6f3ce8cd71fa1a49ccd18ea37736 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Fri, 29 May 2026 09:17:21 +0000 Subject: [PATCH 21/26] 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 6af082c671426d27528aaaaa1f094e4edf76cd78 Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Sat, 30 May 2026 11:00:00 +0200 Subject: [PATCH 22/26] 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 3e9198be09a..a652ac98250 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -3266,8 +3266,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); @@ -3288,8 +3289,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); @@ -3298,6 +3300,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 cdbc3a989a4..2abd5c078f7 100644 --- a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php @@ -510,7 +510,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 32acf003e36e174c5f8d018f383ac5c8aa0852ad Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Sat, 30 May 2026 11:00:10 +0200 Subject: [PATCH 23/26] 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 a652ac98250..3e9198be09a 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -3266,9 +3266,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); @@ -3289,9 +3288,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); @@ -3300,24 +3298,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 2abd5c078f7..cdbc3a989a4 100644 --- a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php @@ -510,16 +510,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 26cc74d4daa10f329d9c930f9f3c13d388231ee8 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Sat, 30 May 2026 09:04:57 +0000 Subject: [PATCH 24/26] 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 d3ca979e1acfe26b20f66fc11e08b670b4c0696d Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Sat, 30 May 2026 09:04:57 +0000 Subject: [PATCH 25/26] 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 21087970b46..d6ea8a7cd40 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 57ad82ed4085cd39d03422c86865f2fa3f41203b Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Sat, 30 May 2026 09:07:16 +0000 Subject: [PATCH 26/26] 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 } }