From ee2b8a83a67c1de4f98bd88b2d65823368297119 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 1/9] 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 89b4111ad1968524616fa81fc217651c0ce008b9 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Tue, 26 May 2026 19:14:56 +0000 Subject: [PATCH 2/9] 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 5cbed3abe1f9283faf81f5ca0b550aa89b74d15c Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Tue, 26 May 2026 19:42:03 +0000 Subject: [PATCH 3/9] 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 bde206669d2e2641e724711982de1a1640d2bfea Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Tue, 26 May 2026 19:56:14 +0000 Subject: [PATCH 4/9] 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 d6e4e48649108100224662f97be2f8a11dad614c Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Wed, 27 May 2026 09:23:33 +0000 Subject: [PATCH 5/9] 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 b60159164bfb1aa68b2c34c513fcd31f62814816 Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Wed, 27 May 2026 20:01:07 +0200 Subject: [PATCH 6/9] 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 d7609ac8722f066d1114948cd5e38161b66300e8 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Wed, 27 May 2026 18:25:38 +0000 Subject: [PATCH 7/9] 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 c96acf8390757c22db67e22e79f6323f24f5a3bc Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Wed, 27 May 2026 22:33:16 +0000 Subject: [PATCH 8/9] 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 54c24d97539922800bcdce9af0da3fb50791f5bc Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Wed, 27 May 2026 22:33:21 +0000 Subject: [PATCH 9/9] 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(