From 6b6dfcaad63d02f8a87a9a635146e753b5a94a3a Mon Sep 17 00:00:00 2001 From: VincentLanglet <9052536+VincentLanglet@users.noreply.github.com> Date: Sun, 28 Jun 2026 22:26:45 +0000 Subject: [PATCH 01/10] Report always-true `in_array(..., true)` when every finite needle value is guaranteed in the haystack - `ImpossibleCheckTypeHelper`: rewrite the strict `in_array()` guard so it checks that *each* finite needle value is guaranteed present in the haystack, instead of requiring a single haystack value to be a supertype of the whole needle. This makes union needles that are a subset of the haystack values (e.g. `'a'|'b'` in `array{'a','b','c'}`) report as always-true. - Use `getFiniteTypes()` instead of `getConstantScalarTypes()` when collecting the haystack's guaranteed values, so enum-case needles are handled the same way as string/int/bool needles (mirrors `InArrayFunctionTypeSpecifyingExtension::computeNeedleNarrowingType`). - `InArrayFunctionTypeSpecifyingExtension`: when combining the per-item comparisons of an array-literal haystack, clear the leftover root expression. Otherwise one item's `$needle === $oneItem` comparison (e.g. the always-false `$s === 'baz'`) was promoted by `mergeRootExpr(null, X)` to stand in for the whole call, making `in_array($subset, ['a','b','c'], true)` wrongly infer `false`. - `ParameterCastableToStringRule`: drop the now-redundant `elseif (in_array(...))`/dead `else` branch that the stricter analysis correctly flags as always-true. --- .../Comparison/ImpossibleCheckTypeHelper.php | 34 +++++---- .../ParameterCastableToStringRule.php | 4 +- ...InArrayFunctionTypeSpecifyingExtension.php | 10 +++ tests/PHPStan/Analyser/nsrt/bug-14873.php | 72 +++++++++++++++++++ ...mpossibleCheckTypeFunctionCallRuleTest.php | 23 ++++++ .../Rules/Comparison/data/bug-14873.php | 30 ++++++++ 6 files changed, 158 insertions(+), 15 deletions(-) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-14873.php create mode 100644 tests/PHPStan/Rules/Comparison/data/bug-14873.php diff --git a/src/Rules/Comparison/ImpossibleCheckTypeHelper.php b/src/Rules/Comparison/ImpossibleCheckTypeHelper.php index f5599c6394b..4e7419414cd 100644 --- a/src/Rules/Comparison/ImpossibleCheckTypeHelper.php +++ b/src/Rules/Comparison/ImpossibleCheckTypeHelper.php @@ -187,33 +187,43 @@ private function getSpecifiedType( } if ($isNeedleSupertype->maybe() || $isNeedleSupertype->yes()) { + $needleFiniteTypes = $needleType->getFiniteTypes(); foreach ($haystackArrayTypes as $haystackArrayType) { + $guaranteedValueTypes = []; if ($haystackArrayType instanceof ConstantArrayType) { foreach ($haystackArrayType->getValueTypes() as $i => $haystackArrayValueType) { if ($haystackArrayType->isOptionalKey($i)) { continue; } - $haystackArrayValueConstantScalarTypes = $haystackArrayValueType->getConstantScalarTypes(); - if (count($haystackArrayValueConstantScalarTypes) > 1) { + $haystackArrayValueFiniteTypes = $haystackArrayValueType->getFiniteTypes(); + if (count($haystackArrayValueFiniteTypes) !== 1) { continue; } - foreach ($haystackArrayValueConstantScalarTypes as $constantScalarType) { - if ($constantScalarType->isSuperTypeOf($needleType)->yes()) { - continue 3; - } - } + $guaranteedValueTypes[] = $haystackArrayValueFiniteTypes[0]; } } else { - foreach ($haystackArrayType->getIterableValueType()->getConstantScalarTypes() as $constantScalarType) { - if ($constantScalarType->isSuperTypeOf($needleType)->yes()) { - continue 2; - } + foreach ($haystackArrayType->getIterableValueType()->getFiniteTypes() as $finiteType) { + $guaranteedValueTypes[] = $finiteType; } } - return null; + // in_array() is only guaranteed true when every possible needle value + // is guaranteed to be present in this haystack variant. + foreach ($needleFiniteTypes as $needleFiniteType) { + $found = false; + foreach ($guaranteedValueTypes as $guaranteedValueType) { + if ($guaranteedValueType->isSuperTypeOf($needleFiniteType)->yes()) { + $found = true; + break; + } + } + + if (!$found) { + return null; + } + } } } diff --git a/src/Rules/Functions/ParameterCastableToStringRule.php b/src/Rules/Functions/ParameterCastableToStringRule.php index ebf48791625..8142ec7320c 100644 --- a/src/Rules/Functions/ParameterCastableToStringRule.php +++ b/src/Rules/Functions/ParameterCastableToStringRule.php @@ -74,7 +74,7 @@ public function processNode(Node $node, Scope $scope): array if (in_array($functionName, $checkAllArgsFunctions, true)) { $argsToCheck = $origArgs; - } elseif (in_array($functionName, $checkFirstArgFunctions, true)) { + } else { $normalizedFuncCall = ArgumentsNormalizer::reorderFuncArguments($parametersAcceptor, $node); if ($normalizedFuncCall === null) { @@ -86,8 +86,6 @@ public function processNode(Node $node, Scope $scope): array return []; } $argsToCheck = [0 => $normalizedArgs[0]]; - } else { - return []; } $errors = []; diff --git a/src/Type/Php/InArrayFunctionTypeSpecifyingExtension.php b/src/Type/Php/InArrayFunctionTypeSpecifyingExtension.php index df07f5d11d7..66f5a64588e 100644 --- a/src/Type/Php/InArrayFunctionTypeSpecifyingExtension.php +++ b/src/Type/Php/InArrayFunctionTypeSpecifyingExtension.php @@ -72,6 +72,7 @@ public function specifyTypes(FunctionReflection $functionReflection, FuncCall $n if ($arrayExpr instanceof Array_) { $types = null; + $combinedMultipleItems = false; foreach ($arrayExpr->items as $item) { if ($item->unpack) { $types = null; @@ -89,10 +90,19 @@ public function specifyTypes(FunctionReflection $functionReflection, FuncCall $n continue; } + $combinedMultipleItems = true; $types = $context->true() ? $types->normalize($scope)->intersectWith($itemTypes->normalize($scope)) : $types->unionWith($itemTypes); } if ($types !== null) { + // The root expression of a single item's comparison must not stand in + // for the whole in_array() call once multiple items are combined, + // otherwise an arbitrary "$needle === $oneItem" comparison would be + // treated as the call's result. + if ($combinedMultipleItems) { + $types = $types->setRootExpr(null); + } + return $types; } } diff --git a/tests/PHPStan/Analyser/nsrt/bug-14873.php b/tests/PHPStan/Analyser/nsrt/bug-14873.php new file mode 100644 index 00000000000..18f61942b8a --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-14873.php @@ -0,0 +1,72 @@ +analyse([__DIR__ . '/data/bug-12412.php'], []); } + public function testBug14873(): void + { + $tipText = 'Because the type is coming from a PHPDoc, you can turn off this check by setting treatPhpDocTypesAsCertain: false in your %configurationFile%.'; + + $this->treatPhpDocTypesAsCertain = true; + $this->analyse([__DIR__ . '/data/bug-14873.php'], [ + [ + 'Call to function in_array() with arguments \'a\'|\'b\'|\'c\', array{\'a\', \'b\', \'c\'} and true will always evaluate to true.', + 17, + $tipText, + ], + [ + 'Call to function in_array() with arguments \'a\'|\'b\', array{\'a\', \'b\', \'c\'} and true will always evaluate to true.', + 21, + $tipText, + ], + ]); + } + public function testBug2730(): void { $this->treatPhpDocTypesAsCertain = true; diff --git a/tests/PHPStan/Rules/Comparison/data/bug-14873.php b/tests/PHPStan/Rules/Comparison/data/bug-14873.php new file mode 100644 index 00000000000..3a67a95fd56 --- /dev/null +++ b/tests/PHPStan/Rules/Comparison/data/bug-14873.php @@ -0,0 +1,30 @@ + Date: Mon, 29 Jun 2026 08:32:46 +0200 Subject: [PATCH 02/10] Update bug-14873.php --- tests/PHPStan/Analyser/nsrt/bug-14873.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/PHPStan/Analyser/nsrt/bug-14873.php b/tests/PHPStan/Analyser/nsrt/bug-14873.php index 18f61942b8a..974797911d6 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-14873.php +++ b/tests/PHPStan/Analyser/nsrt/bug-14873.php @@ -1,4 +1,6 @@ -= 8.1 + +declare(strict_types = 1); namespace Bug14873; From 0f0314b4652c4bb4169e9e7572b374f6c839a286 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Mon, 29 Jun 2026 08:44:20 +0200 Subject: [PATCH 03/10] more tests --- tests/PHPStan/Analyser/nsrt/bug-14873.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/PHPStan/Analyser/nsrt/bug-14873.php b/tests/PHPStan/Analyser/nsrt/bug-14873.php index 974797911d6..a044337fc5d 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-14873.php +++ b/tests/PHPStan/Analyser/nsrt/bug-14873.php @@ -41,9 +41,13 @@ public function variableHaystack(string $full, string $subset, string $partial): public function literalHaystack(string $full, string $subset, string $partial): void { assertType('true', in_array($full, ['a', 'b', 'c'], true)); + assertType('bool', in_array($full, ['a', 'b', 'c'], false)); // non-strict assertType('true', in_array($subset, ['a', 'b', 'c'], true)); assertType('bool', in_array($partial, ['a', 'b', 'c'], true)); assertType('false', in_array($subset, ['x', 'y'], true)); + + $fullOrEmpty = rand(0,1) ? $full : []; + assertType('bool', in_array($fullOrEmpty, ['a', 'b', 'c'], true)); } /** From 0db41f421e5aa86a5b885315cd5233c91aac6cad Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Mon, 29 Jun 2026 07:13:12 +0000 Subject: [PATCH 04/10] Do not report always-true in_array() for needles without finite values A needle without a finite set of possible values (e.g. a plain object) cannot be proven to be present in the haystack, so bail out instead of falling through to an always-true conclusion. Co-Authored-By: Claude Opus 4.8 --- .../Comparison/ImpossibleCheckTypeHelper.php | 7 +++++++ tests/PHPStan/Analyser/nsrt/bug-14873.php | 18 ++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/src/Rules/Comparison/ImpossibleCheckTypeHelper.php b/src/Rules/Comparison/ImpossibleCheckTypeHelper.php index 4e7419414cd..cab0c8986c4 100644 --- a/src/Rules/Comparison/ImpossibleCheckTypeHelper.php +++ b/src/Rules/Comparison/ImpossibleCheckTypeHelper.php @@ -188,6 +188,13 @@ private function getSpecifiedType( if ($isNeedleSupertype->maybe() || $isNeedleSupertype->yes()) { $needleFiniteTypes = $needleType->getFiniteTypes(); + if ($needleFiniteTypes === []) { + // The needle does not have a finite set of possible values + // (e.g. a plain object), so it cannot be proven that every + // possible needle value is present in the haystack. + return null; + } + foreach ($haystackArrayTypes as $haystackArrayType) { $guaranteedValueTypes = []; if ($haystackArrayType instanceof ConstantArrayType) { diff --git a/tests/PHPStan/Analyser/nsrt/bug-14873.php b/tests/PHPStan/Analyser/nsrt/bug-14873.php index a044337fc5d..56f9ba49264 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-14873.php +++ b/tests/PHPStan/Analyser/nsrt/bug-14873.php @@ -75,4 +75,22 @@ public function enums(Suit $subset): void assertType('bool', in_array($subset, [Suit::Hearts], true)); } + /** + * Plain objects do not have a finite set of possible values, so in_array() + * must not be reported as always-true even when the needle's class matches + * every haystack value type. + */ + public function objects(Article $article, ?Article $a, ?Article $b): void + { + $haystack = [$a, $b]; + + assertType('bool', in_array($article, $haystack, true)); + assertType('bool', in_array($article, [$a, $b], true)); + } + +} + +class Article +{ + } From 74be15ab47a02e72818ebc771a2f1faaf6a3ea30 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Mon, 29 Jun 2026 09:26:42 +0200 Subject: [PATCH 05/10] strip comments --- src/Rules/Comparison/ImpossibleCheckTypeHelper.php | 3 --- src/Type/Php/InArrayFunctionTypeSpecifyingExtension.php | 4 ---- 2 files changed, 7 deletions(-) diff --git a/src/Rules/Comparison/ImpossibleCheckTypeHelper.php b/src/Rules/Comparison/ImpossibleCheckTypeHelper.php index cab0c8986c4..a18013451c1 100644 --- a/src/Rules/Comparison/ImpossibleCheckTypeHelper.php +++ b/src/Rules/Comparison/ImpossibleCheckTypeHelper.php @@ -189,9 +189,6 @@ private function getSpecifiedType( if ($isNeedleSupertype->maybe() || $isNeedleSupertype->yes()) { $needleFiniteTypes = $needleType->getFiniteTypes(); if ($needleFiniteTypes === []) { - // The needle does not have a finite set of possible values - // (e.g. a plain object), so it cannot be proven that every - // possible needle value is present in the haystack. return null; } diff --git a/src/Type/Php/InArrayFunctionTypeSpecifyingExtension.php b/src/Type/Php/InArrayFunctionTypeSpecifyingExtension.php index 66f5a64588e..9651a5bb2aa 100644 --- a/src/Type/Php/InArrayFunctionTypeSpecifyingExtension.php +++ b/src/Type/Php/InArrayFunctionTypeSpecifyingExtension.php @@ -95,10 +95,6 @@ public function specifyTypes(FunctionReflection $functionReflection, FuncCall $n } if ($types !== null) { - // The root expression of a single item's comparison must not stand in - // for the whole in_array() call once multiple items are combined, - // otherwise an arbitrary "$needle === $oneItem" comparison would be - // treated as the call's result. if ($combinedMultipleItems) { $types = $types->setRootExpr(null); } From a9635ab28fcd41616412c0efe8a388562ebf4bfa Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Mon, 29 Jun 2026 08:32:40 +0000 Subject: [PATCH 06/10] Bail conservatively for general-array haystack in strict in_array() A general (non-constant) array never guarantees that a specific value is present (array guarantees neither 1 nor 2, and a possibly-empty array guarantees nothing), so the in_array() guard loop must not treat a general array variant's value types as guaranteed present. Purely general haystacks are already handled earlier; the remaining path is union haystacks mixing a constant and a general array, for which we now bail out. Co-Authored-By: Claude Opus 4.8 --- .../Comparison/ImpossibleCheckTypeHelper.php | 10 +++++++--- tests/PHPStan/Analyser/nsrt/bug-14873.php | 18 ++++++++++++++++++ 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/src/Rules/Comparison/ImpossibleCheckTypeHelper.php b/src/Rules/Comparison/ImpossibleCheckTypeHelper.php index a18013451c1..ab194a16a88 100644 --- a/src/Rules/Comparison/ImpossibleCheckTypeHelper.php +++ b/src/Rules/Comparison/ImpossibleCheckTypeHelper.php @@ -208,9 +208,13 @@ private function getSpecifiedType( $guaranteedValueTypes[] = $haystackArrayValueFiniteTypes[0]; } } else { - foreach ($haystackArrayType->getIterableValueType()->getFiniteTypes() as $finiteType) { - $guaranteedValueTypes[] = $finiteType; - } + // A general (non-constant) array cannot guarantee that any specific + // value is present: array guarantees neither 1 nor 2, and a + // possibly-empty array guarantees nothing at all. A purely general + // haystack is already handled above (see the isConstantArray()->no() + // branch), so we only get here for union haystacks mixing a constant + // and a general array - bail out conservatively for those. + return null; } // in_array() is only guaranteed true when every possible needle value diff --git a/tests/PHPStan/Analyser/nsrt/bug-14873.php b/tests/PHPStan/Analyser/nsrt/bug-14873.php index 56f9ba49264..1677b3c5f08 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-14873.php +++ b/tests/PHPStan/Analyser/nsrt/bug-14873.php @@ -88,6 +88,24 @@ public function objects(Article $article, ?Article $a, ?Article $b): void assertType('bool', in_array($article, [$a, $b], true)); } + /** + * A general (non-constant) array does not guarantee that any particular value + * is present, so a subset needle must not be reported as always-true. Only a + * non-empty array whose values all share a single finite type guarantees that + * value's presence. + * + * @param 1|2 $needle + * @param array $maybeEmpty + * @param non-empty-array $nonEmptyMulti + * @param non-empty-array $nonEmptySingle + */ + public function generalArrays(int $needle, array $maybeEmpty, array $nonEmptyMulti, array $nonEmptySingle): void + { + assertType('bool', in_array($needle, $maybeEmpty, true)); + assertType('bool', in_array($needle, $nonEmptyMulti, true)); + assertType('true', in_array(1, $nonEmptySingle, true)); + } + } class Article From 565576e634cf1dabbe48d7857a814995747ef75a Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Mon, 29 Jun 2026 08:32:40 +0000 Subject: [PATCH 07/10] Explain why the combined in_array() root expression is cleared Co-Authored-By: Claude Opus 4.8 --- src/Type/Php/InArrayFunctionTypeSpecifyingExtension.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/Type/Php/InArrayFunctionTypeSpecifyingExtension.php b/src/Type/Php/InArrayFunctionTypeSpecifyingExtension.php index 9651a5bb2aa..d6e3f0e3587 100644 --- a/src/Type/Php/InArrayFunctionTypeSpecifyingExtension.php +++ b/src/Type/Php/InArrayFunctionTypeSpecifyingExtension.php @@ -96,6 +96,12 @@ public function specifyTypes(FunctionReflection $functionReflection, FuncCall $n if ($types !== null) { if ($combinedMultipleItems) { + // Each per-item SpecifiedTypes carries its own "$needle === $item" + // as its root expression. Once the comparisons of multiple items are + // combined, that per-item root expression no longer describes the whole + // in_array() call, so it must be cleared. Leaving it set lets callers + // such as ImpossibleCheckTypeHelper read the type of a single item's + // comparison and draw a wrong conclusion about the whole call. $types = $types->setRootExpr(null); } From 55d0901436f4299d30357fe366194b20860da1a3 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Mon, 29 Jun 2026 08:41:53 +0000 Subject: [PATCH 08/10] Add rule-level coverage for general-array haystacks in strict in_array() Directly demonstrates that a general (non-constant) array haystack never produces a false always-true report, complementing the type-inference coverage in nsrt/bug-14873.php. Co-Authored-By: Claude Opus 4.8 --- .../Rules/Comparison/data/bug-14873.php | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tests/PHPStan/Rules/Comparison/data/bug-14873.php b/tests/PHPStan/Rules/Comparison/data/bug-14873.php index 3a67a95fd56..a64b5e809ba 100644 --- a/tests/PHPStan/Rules/Comparison/data/bug-14873.php +++ b/tests/PHPStan/Rules/Comparison/data/bug-14873.php @@ -27,4 +27,24 @@ public function sayHello(string $full, string $subset, string $partial): void } } + /** + * A general (non-constant) array does not guarantee that any particular value + * is present, so a subset needle must not be reported as always-true - even + * when every finite needle value appears in the array's value type. + * + * @param 1|2 $needle + * @param array $maybeEmpty + * @param non-empty-array $nonEmptyMulti + */ + public function generalArrays(int $needle, array $maybeEmpty, array $nonEmptyMulti): void + { + if (in_array($needle, $maybeEmpty, true)) { + echo 'maybeEmpty'; + } + + if (in_array($needle, $nonEmptyMulti, true)) { + echo 'nonEmptyMulti'; + } + } + } From 201382d6bc9c1dc9bf4bc84b7ce94218aea20a98 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Mon, 29 Jun 2026 08:42:43 +0000 Subject: [PATCH 09/10] Shorten the general-array haystack comment Co-Authored-By: Claude Opus 4.8 --- src/Rules/Comparison/ImpossibleCheckTypeHelper.php | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/Rules/Comparison/ImpossibleCheckTypeHelper.php b/src/Rules/Comparison/ImpossibleCheckTypeHelper.php index ab194a16a88..9e9e45cf72a 100644 --- a/src/Rules/Comparison/ImpossibleCheckTypeHelper.php +++ b/src/Rules/Comparison/ImpossibleCheckTypeHelper.php @@ -208,12 +208,7 @@ private function getSpecifiedType( $guaranteedValueTypes[] = $haystackArrayValueFiniteTypes[0]; } } else { - // A general (non-constant) array cannot guarantee that any specific - // value is present: array guarantees neither 1 nor 2, and a - // possibly-empty array guarantees nothing at all. A purely general - // haystack is already handled above (see the isConstantArray()->no() - // branch), so we only get here for union haystacks mixing a constant - // and a general array - bail out conservatively for those. + // A general array cannot guarantee any specific value is present. return null; } From b28239174dc3959a7444a97e83427d9d9e9f9994 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Mon, 29 Jun 2026 10:53:51 +0200 Subject: [PATCH 10/10] cs --- .../Comparison/ImpossibleCheckTypeHelper.php | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/src/Rules/Comparison/ImpossibleCheckTypeHelper.php b/src/Rules/Comparison/ImpossibleCheckTypeHelper.php index 9e9e45cf72a..f729683ecab 100644 --- a/src/Rules/Comparison/ImpossibleCheckTypeHelper.php +++ b/src/Rules/Comparison/ImpossibleCheckTypeHelper.php @@ -194,22 +194,22 @@ private function getSpecifiedType( foreach ($haystackArrayTypes as $haystackArrayType) { $guaranteedValueTypes = []; - if ($haystackArrayType instanceof ConstantArrayType) { - foreach ($haystackArrayType->getValueTypes() as $i => $haystackArrayValueType) { - if ($haystackArrayType->isOptionalKey($i)) { - continue; - } + if (!($haystackArrayType instanceof ConstantArrayType)) { + // A general array cannot guarantee any specific value is present. + return null; + } - $haystackArrayValueFiniteTypes = $haystackArrayValueType->getFiniteTypes(); - if (count($haystackArrayValueFiniteTypes) !== 1) { - continue; - } + foreach ($haystackArrayType->getValueTypes() as $i => $haystackArrayValueType) { + if ($haystackArrayType->isOptionalKey($i)) { + continue; + } - $guaranteedValueTypes[] = $haystackArrayValueFiniteTypes[0]; + $haystackArrayValueFiniteTypes = $haystackArrayValueType->getFiniteTypes(); + if (count($haystackArrayValueFiniteTypes) !== 1) { + continue; } - } else { - // A general array cannot guarantee any specific value is present. - return null; + + $guaranteedValueTypes[] = $haystackArrayValueFiniteTypes[0]; } // in_array() is only guaranteed true when every possible needle value