Report always-true in_array(..., true) when every finite needle value is guaranteed in the haystack - #5944
Conversation
…ue 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.
staabm
left a comment
There was a problem hiding this comment.
this change leads to a false positive in
<?php
declare(strict_types=1);
namespace InArrayOfObjectsAlwaysTrue;
class Article {}
abstract class LegalConditionsFacade
{
/**
* @return Article|null
*/
public function findTermsAndConditions()
{
return rand(0, 1) === 1 ? new Article() : null;
}
/**
* @return Article|null
*/
public function findPrivacyPolicy()
{
return rand(0, 1) === 1 ? new Article() : null;
}
/**
* @param Article $article
* @return bool
*/
public function isArticleUsedAsLegalConditions(Article $article)
{
$legalConditionsArticles = [
$this->findTermsAndConditions(),
$this->findPrivacyPolicy(),
];
if (in_array($article, $legalConditionsArticles, true)) {
return true;
}
return false;
}
}on line 38:
Call to function in_array() with arguments InArrayOfObjectsAlwaysTrue\Article, array{InArrayOfObjectsAlwaysTrue\Article|null, InArrayOfObjectsAlwaysTrue\Article|null} and true will always evaluate to true.
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 <noreply@anthropic.com>
|
I've addressed the review. Here's a summary: Root causeThe reviewer found a false positive: The PR's helper change introduced this. The strict- Before the PR, the old loop always bailed for objects (their values aren't constant scalars), so this was a regression introduced by the change. FixAdded an early bail in the guard: when Verification
I checked the adjacent symmetric logic in Committed as |
| if ($constantScalarType->isSuperTypeOf($needleType)->yes()) { | ||
| continue 2; | ||
| } | ||
| foreach ($haystackArrayType->getIterableValueType()->getFiniteTypes() as $finiteType) { |
There was a problem hiding this comment.
Do we have test for this branch ?
Also, I'm not sure about it since array<int, 1|2> doesn't enforce every finite typed to be present.
There was a problem hiding this comment.
I've addressed both of VincentLanglet's review comments. Here's a summary of what I did and why.
Comment 1 — ImpossibleCheckTypeHelper.php: "Do we have a test for this branch? … array<int, 1|2> doesn't enforce every finite type to be present"
The reviewer is correct on both counts.
Soundness: The else branch collected every finite type of a general array's value type into $guaranteedValueTypes, then treated them as guaranteed-present. That's unsound — array<int, 1|2> could be [1], [2], or even empty, so it guarantees neither 1 nor 2. With a 1|2 needle this would wrongly prove always-true.
Reachability: I instrumented the branch and ran the suite — it's never reached. As my experiments confirmed, TypeCombinator always collapses a constant-array | general-array union into a single general array, and a purely general haystack is already handled earlier (the isConstantArray()->no() block at lines 162-181). So the branch only survives as a defensive path for non-collapsing union haystacks.
Rather than keep speculative, untestable code that guesses guarantees from a general array, I replaced it with a conservative return null (inconclusive) plus a comment explaining exactly when the branch is reached and why a general array can't guarantee a value's presence. I also added explicit generalArrays() coverage to nsrt/bug-14873.php asserting that array<int, 1|2> / non-empty-array<int, 1|2> needles infer bool (not always-true), while non-empty-array<int, 1> correctly stays true.
Comment 2 — InArrayFunctionTypeSpecifyingExtension.php: "Why do we need setRootExpr?"
Added a comment: each per-item SpecifiedTypes carries its own $needle === $item as its root expression; once multiple items are combined, that per-item root expression no longer describes the whole in_array() call, and leaving it set lets ImpossibleCheckTypeHelper read a single item's comparison type and draw a wrong conclusion about the whole call. Clearing it removes the leak.
Verification
make phpstan— clean.NodeScopeResolverTest(1664),TypeSpecifierTest, allRules/Comparison+Rules/Functions(1012) — all pass.- The three new
generalArrays()assertions fail appropriately if the old unsound collection is restored.
Committed as two logical commits and pushed.
There was a problem hiding this comment.
Pushed. Here's my summary of how this review is addressed.
Response to VincentLanglet's review
Both comments were on an earlier version of the diff (the quoted hunk still shows the old getIterableValueType()->getFiniteTypes() loop). They have since been addressed by the two most recent commits, and I've now also added rule-level test coverage.
Comment 1 — ImpossibleCheckTypeHelper.php: "Do we have a test for this branch? Also, I'm not sure about it since array<int, 1|2> doesn't enforce every finite type to be present."
You're right — the old else branch collected every finite type of a general array's value type and treated each as guaranteed-present, which is unsound: array<int, 1|2> can be [1], [2], or empty, so it guarantees neither value.
Fixed in a9635ab28: the general-array branch now bails conservatively with return null instead of guessing guarantees. A general (non-constant) array can never guarantee a specific value is present, and a purely general haystack is already handled earlier (the isConstantArray()->no() block at lines 162–181), so the else is only reachable for non-collapsing constant | general union haystacks — which TypeCombinator collapses in practice, making it a defensive path.
Tests:
nsrt/bug-14873.php::generalArrays()assertsarray<int, 1|2>andnon-empty-array<int, 1|2>needles inferbool(nottrue), whilenon-empty-array<int, 1>correctly staystrue.- I've now also added
generalArrays()to the rule test fixture (tests/PHPStan/Rules/Comparison/data/bug-14873.php, commit55d090143) confirming the helper itself reports no false always-true for general arrays — this is the rule-level counterpart to the type-inference test, directly answering your "do we have a test" question.
The still-active constant-array branch is sound and tested: it only marks a value guaranteed when its position has exactly one finite type (count(...) === 1), so array{1|2} is never treated as guaranteeing 1 or 2, whereas array{1, 2} correctly guarantees both. Covered by variableHaystack()/integers()/enums() and the rule test's lines 17/21.
Comment 2 — InArrayFunctionTypeSpecifyingExtension.php: "Why do we need setRootExpr?"
Explained in code in 565576e63: each per-item SpecifiedTypes from specifyTypesInCondition(Identical($needle, $item)) carries its own $needle === $item as its root expression. For a single-element array that root expr correctly describes the whole call, but once multiple items are combined it no longer does — and mergeRootExpr(null, X) lets the last item's comparison re-surface as the combined root expr. ImpossibleCheckTypeHelper then reads getType(that single comparison) (line 339), which for a subset needle is the constant false, and wrongly concludes the whole in_array() is always-false. Clearing it (only when multiple items were combined) removes that leak.
All checks green: ImpossibleCheckTypeFunctionCallRuleTest (100), ParameterCastableToStringRuleTest (8), NodeScopeResolverTest (1664), and make phpstan clean.
|
|
||
| if ($types !== null) { | ||
| if ($combinedMultipleItems) { | ||
| $types = $types->setRootExpr(null); |
There was a problem hiding this comment.
Why do we need setRootExpr ?
A general (non-constant) array never guarantees that a specific value is present (array<int, 1|2> 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 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
| foreach ($haystackArrayType->getIterableValueType()->getConstantScalarTypes() as $constantScalarType) { | ||
| if ($constantScalarType->isSuperTypeOf($needleType)->yes()) { | ||
| continue 2; | ||
| // A general (non-constant) array cannot guarantee that any specific |
There was a problem hiding this comment.
Use a shorter comment
There was a problem hiding this comment.
Done. I addressed VincentLanglet's review comment by shortening the multi-line comment on the general-array haystack branch in ImpossibleCheckTypeHelper.php to a single concise line:
} else {
// A general array cannot guarantee any specific value is present.
return null;
}make phpstan is clean and ImpossibleCheckTypeFunctionCallRuleTest passes (100/100). The change was committed and pushed (after rebasing on top of remote work that landed in the meantime).
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 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Summary
PHPStan did not report
in_array($needle, $haystack, true)as always-true when$needlecan take a finite set of values that is a subset of the haystack's values (e.g.$needleis'a'|'b'and$haystackisarray{'a','b','c'}). For the array-literal form of the same call it was even worse: the call's return type was wrongly inferred asfalse, producing a false "always false" report.This change makes both forms correctly report (and infer) always-true, and extends the same behaviour to integer- and enum-case needles.
Changes
src/Rules/Comparison/ImpossibleCheckTypeHelper.phpin_array()guard loop. Instead of bailing unless a single haystack value is a supertype of the entire needle, it now collects each haystack variant's guaranteed values and requires that every finite needle value is guaranteed present. Union needles that are subsets of the haystack are now provable.getConstantScalarTypes()togetFiniteTypes(), so enum-case needles are treated like scalar needles (matching the symmetric false-context logic inInArrayFunctionTypeSpecifyingExtension::computeNeedleNarrowingType).src/Type/Php/InArrayFunctionTypeSpecifyingExtension.phpArray_haystack, clear the resulting root expression when more than one item was combined. The per-item root expression is meaningless for the whole call.src/Rules/Functions/ParameterCastableToStringRule.phpelseif (in_array($functionName, $checkFirstArgFunctions, true))(plus its unreachableelse { return []; }) with a plainelse— the stricter analysis correctly proves this check always-true.tests/PHPStan/Analyser/nsrt/bug-14873.php(type inference) andtests/PHPStan/Rules/Comparison/data/bug-14873.php+testBug14873()(rule). Added the newly-detected always-true at line 259 of the existingcheck-type-function-call.phpfixture.Root cause
Two independent defects, both surfacing only for a finite needle that is a subset of the haystack:
Helper (variable haystack). The guard loop asked "is there one haystack value that is a supertype of the needle?". For a single needle value
'a'that works, but for a union needle'a'|'b'no single haystack value ('a','b', …) is a supertype of the union, so the helper always bailed tonull. The correct question is per-needle-value: each finite value of the needle must be guaranteed present. Additionally the loop only considered constant scalar haystack values, so enum-case haystacks/needles were never provable.Type-specifying extension (array-literal haystack). The
Array_branch builds the call's narrowing by combining oneSpecifiedTypesper array item, each carrying its own$needle === $itemas its root expression.SpecifiedTypes::mergeRootExpr(null, X)returnsX, so after the first two differing items zeroed the root expression, the last item's comparison ($needle === $lastItem) re-became the combined root expression. The helper then readgetType($lastItem comparison)— which for a subset needle is the constantfalse— and concluded the wholein_array()call was always false. Clearing the root expression once multiple items are combined removes this leak; the call result is then derived from the (correct) per-needle narrowing.Test
tests/PHPStan/Analyser/nsrt/bug-14873.phpasserts the inferred return type ofin_array(..., true)for variable and literal haystacks across string, integer and enum-case needles (subset ⇒true, non-subset ⇒bool, disjoint ⇒false). Fails before the fix (literal subset case inferredfalse, variable/enum subset cases inferredbool).tests/PHPStan/Rules/Comparison/data/bug-14873.php+testBug14873()assert the rule reports always-true for the reported variable-haystack scenario. Fails before the fix (no error reported).ImpossibleCheckTypeFunctionCallRuleTestgained the always-true atcheck-type-function-call.php:259(in_array($fooOrBar, ['foo','bar'], true)with$fooOrBarof type'foo'|'bar'), which was previously a missed report.in_array()(intentionally left inconclusive),array_key_exists()/class_exists()type-specifying extensions (no per-item root-expression leak — they set their root expression explicitly), and integer/enum needle variants (now fixed viagetFiniteTypes()).Fixes phpstan/phpstan#14873