🌐 US-Proxy
class="logged-out env-production page-responsive" style="word-wrap: break-word;" >
Skip to content

Report always-true in_array(..., true) when every finite needle value is guaranteed in the haystack - #5944

Merged
staabm merged 10 commits into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-u73aorw
Jun 29, 2026
Merged

Report always-true in_array(..., true) when every finite needle value is guaranteed in the haystack#5944
staabm merged 10 commits into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-u73aorw

Conversation

@phpstan-bot

Copy link
Copy Markdown
Collaborator

Summary

PHPStan did not report in_array($needle, $haystack, true) as always-true when $needle can take a finite set of values that is a subset of the haystack's values (e.g. $needle is 'a'|'b' and $haystack is array{'a','b','c'}). For the array-literal form of the same call it was even worse: the call's return type was wrongly inferred as false, 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.php
    • Rewrote the strict-in_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.
    • Switched the guaranteed-value collection from getConstantScalarTypes() to getFiniteTypes(), so enum-case needles are treated like scalar needles (matching the symmetric false-context logic in InArrayFunctionTypeSpecifyingExtension::computeNeedleNarrowingType).
  • src/Type/Php/InArrayFunctionTypeSpecifyingExtension.php
    • After combining the per-item comparisons of an Array_ 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.php
    • Replaced the now-redundant elseif (in_array($functionName, $checkFirstArgFunctions, true)) (plus its unreachable else { return []; }) with a plain else — the stricter analysis correctly proves this check always-true.
  • Tests: added tests/PHPStan/Analyser/nsrt/bug-14873.php (type inference) and tests/PHPStan/Rules/Comparison/data/bug-14873.php + testBug14873() (rule). Added the newly-detected always-true at line 259 of the existing check-type-function-call.php fixture.

Root cause

Two independent defects, both surfacing only for a finite needle that is a subset of the haystack:

  1. 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 to null. 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.

  2. Type-specifying extension (array-literal haystack). The Array_ branch builds the call's narrowing by combining one SpecifiedTypes per array item, each carrying its own $needle === $item as its root expression. SpecifiedTypes::mergeRootExpr(null, X) returns X, 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 read getType($lastItem comparison) — which for a subset needle is the constant false — and concluded the whole in_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.php asserts the inferred return type of in_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 inferred false, variable/enum subset cases inferred bool).
  • 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).
  • The existing ImpossibleCheckTypeFunctionCallRuleTest gained the always-true at check-type-function-call.php:259 (in_array($fooOrBar, ['foo','bar'], true) with $fooOrBar of type 'foo'|'bar'), which was previously a missed report.
  • Probed analogous constructs: loose 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 via getFiniteTypes()).

Fixes phpstan/phpstan#14873

VincentLanglet and others added 3 commits June 28, 2026 22:26
…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 staabm left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
@phpstan-bot

Copy link
Copy Markdown
Collaborator Author

I've addressed the review. Here's a summary:

Root cause

The reviewer found a false positive: in_array($article, [$a, $b], true) where the values are objects (Article|null) was reported as always-true.

The PR's helper change introduced this. The strict-in_array() guard loop in ImpossibleCheckTypeHelper computes $needleFiniteTypes = $needleType->getFiniteTypes() and then bails (return null) only inside the per-needle-value loop. For a plain object needle, getFiniteTypes() returns [] (objects have no finite set of values), so that loop body never ran — the guard never bailed, and execution fell through to the generic type-specifier path which incorrectly concluded always-true.

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.

Fix

Added an early bail in the guard: when $needleFiniteTypes === [], the needle doesn't have a finite, enumerable set of possible values, so it's impossible to prove every possible value is present in the haystack — return null (don't report always-true). Because UnionType::getFiniteTypes() returns [] if any member is non-finite, this also correctly handles mixed needles like Article|'a'.

Verification

  • Reproducer (reviewer's snippet and a variable-haystack variant) no longer reports always-true; both infer bool.
  • Added a regression test (objects()) to nsrt/bug-14873.php — confirmed it fails before the fix (Expected: bool, Actual: true) and passes after.
  • The original legit always-true cases (string/int/enum subset) still report correctly.
  • make phpstan clean; ImpossibleCheckTypeFunctionCallRuleTest, ParameterCastableToStringRuleTest, and the nsrt test all pass.

I checked the adjacent symmetric logic in InArrayFunctionTypeSpecifyingExtension::computeNeedleNarrowingType — it already guards with count($needleType->getFiniteTypes()) === 0, and isolating the extension change confirmed it doesn't produce the false positive on its own.

Committed as 0db41f421 and pushed.

@staabm
staabm requested a review from VincentLanglet June 29, 2026 08:13
if ($constantScalarType->isSuperTypeOf($needleType)->yes()) {
continue 2;
}
foreach ($haystackArrayType->getIterableValueType()->getFiniteTypes() as $finiteType) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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, all Rules/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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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() asserts array<int, 1|2> and non-empty-array<int, 1|2> needles infer bool (not true), while non-empty-array<int, 1> correctly stays true.
  • I've now also added generalArrays() to the rule test fixture (tests/PHPStan/Rules/Comparison/data/bug-14873.php, commit 55d090143) 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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need setRootExpr ?

phpstan-bot and others added 2 commits June 29, 2026 08:32
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use a shorter comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

phpstan-bot and others added 2 commits June 29, 2026 08:41
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>
@staabm
staabm merged commit b13b0ac into phpstan:2.2.x Jun 29, 2026
668 of 671 checks passed
@staabm
staabm deleted the create-pull-request/patch-u73aorw branch June 29, 2026 09:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

PHPStan should report in_array(..., true) calls that are obviously always true

3 participants