Skip to content

Replace FAUX function call workarounds#5769

Open
phpstan-bot wants to merge 1 commit into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-7xy6xo5
Open

Replace FAUX function call workarounds#5769
phpstan-bot wants to merge 1 commit into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-7xy6xo5

Conversation

@phpstan-bot

Copy link
Copy Markdown
Collaborator

Summary

Type-specifying extensions like StrContainingTypeSpecifyingExtension and ArrayKeyExistsFunctionTypeSpecifyingExtension narrow argument types as a side effect of a check being true (e.g. str_contains($haystack, $needle) narrows $haystack to non-empty-string). Previously, to prevent ImpossibleCheckTypeHelper from incorrectly concluding "always true" when the narrowed types were already satisfied, these extensions fabricated rootExpr AST nodes containing FAUX_FUNCTION or __PHPSTAN_FAUX_CONSTANT — opaque expressions that could never resolve to a constant boolean.

This PR replaces that workaround with a proper shouldNotImplyOppositeCase flag on SpecifiedTypes, following the same concept as equality assertions (@phpstan-assert-if-true =Type $param) which also prevent the opposite case from being implied.

Changes

  • src/Analyser/SpecifiedTypes.php: Added shouldNotImplyOppositeCase boolean flag with @api-tagged setShouldNotImplyOppositeCase() setter and shouldNotImplyOppositeCase() getter. Flag is propagated through all immutable-copy methods: setAlwaysOverwriteTypes, setRootExpr, setNewConditionalExpressionHolders, removeExpr, intersectWith (OR propagation), unionWith (OR propagation), and normalize.
  • src/Rules/Comparison/ImpossibleCheckTypeHelper.php: Check the new flag before sureTypes/sureNotTypes analysis — when set, return null (no always-true/false reporting).
  • src/Type/Php/StrContainingTypeSpecifyingExtension.php: Replaced FAUX_FUNCTION rootExpr with setShouldNotImplyOppositeCase(). Removed unused imports.
  • src/Type/Php/ArrayKeyExistsFunctionTypeSpecifyingExtension.php: Replaced __PHPSTAN_FAUX_CONSTANT rootExpr with setShouldNotImplyOppositeCase(). Removed unused imports.
  • src/Analyser/TypeSpecifier.php: In specifyTypesFromAsserts(), equality assertions now use the new flag instead of setting rootExpr to the call expression. The containsUnresolvedTemplate case still uses rootExpr as before.

Analogous cases probed

  • ArraySearchFunctionTypeSpecifyingExtension: Already excluded via explicit return null in ImpossibleCheckTypeHelper — no change needed.
  • ClassExistsFunctionTypeSpecifyingExtension, FunctionExistsFunctionTypeSpecifyingExtension, DefinedConstantTypeSpecifyingExtension: Already excluded via in_array($functionName, [...]) check in ImpossibleCheckTypeHelper — no change needed.
  • MethodExistsTypeSpecifyingExtension, PropertyExistsTypeSpecifyingExtension: Have custom logic in ImpossibleCheckTypeHelper that handles them correctly — no change needed.

Root cause

The FAUX mechanism was a workaround for the lack of a proper "don't use sureTypes to determine check outcome" flag on SpecifiedTypes. When a type-specifying extension narrows an argument type as a side effect of a check (not the check's determining condition), ImpossibleCheckTypeHelper would incorrectly conclude the check is always-true if those types were already satisfied in scope. The fabricated FAUX_FUNCTION/__PHPSTAN_FAUX_CONSTANT expressions in rootExpr made the expression unevaluable, suppressing the false positive. The new flag achieves the same result cleanly.

Test

  • Added tests/PHPStan/Rules/Comparison/data/bug-14705.php with test cases for str_contains, str_starts_with, str_ends_with, strpos with non-empty-string haystack, and array_key_exists with non-constant key on non-empty-array — all verifying no false "always true" reports.
  • All 12155 existing tests pass. PHPStan self-analysis passes with no errors.

Fixes phpstan/phpstan#14705

Comment thread src/Rules/Comparison/ImpossibleCheckTypeHelper.php Outdated

@VincentLanglet VincentLanglet 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.

Should it be something like "specifyOnly" (improve the suggestion)

Also should overwrite imply this ?

@phpstan-bot

Copy link
Copy Markdown
Collaborator Author

Pushed. Here's what changed:

  • Renamed shouldNotDetermineCheckResult / setShouldNotDetermineCheckResult() to specifyOnly / setSpecifyOnly() / isSpecifyOnly() across all 5 files. The name uses the domain language of the SpecifiedTypes class — "specify only" means the object only narrows types, it doesn't determine check outcome.
  • overwrite stays independent: it controls type application in MutatingScope (assign vs intersect), while specifyOnly controls ImpossibleCheckTypeHelper. Both already have separate early-return checks at lines 272 and 276, and coupling them would conflate different semantics.

@VincentLanglet VincentLanglet changed the title Add setShouldNotImplyOppositeCase() on SpecifiedTypes to replace FAUX function call workarounds Add setSpecifyOnly() on SpecifiedTypes to replace FAUX function call workarounds May 26, 2026
@staabm

staabm commented May 27, 2026

Copy link
Copy Markdown
Contributor

@phpstan-bot describe how https://phpstan.org/writing-php-code/narrowing-types#equality-assertions is implemented (without changing code)

@VincentLanglet VincentLanglet 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.

When we use @phpstan-assert =string $foo the AssertTag has isEquality set to true.

Then there was the logic

$newTypes = $this->create(
					$assertExpr,
					$assertedType,
					$assert->isNegated() ? TypeSpecifierContext::createFalse() : TypeSpecifierContext::createTrue(),
					$scope,
				)->setRootExpr($containsUnresolvedTemplate || $assert->isEquality() ? $call : null);

which avoided the always-true/false error

Then seems like some specific case exists in ImpossibleCheckTypeHelper for $rootExpr !== null. Shouldn't we still rely on this logic ?

Also, it would be nice to keep reporting the second condition as always true in

if (str_ends_with($haystack, $needle)) {
			if (str_ends_with($haystack, $needle)) {

			}
		}

@VincentLanglet
VincentLanglet requested review from VincentLanglet and removed request for ondrejmirtes May 27, 2026 09:07
@phpstan-bot

Copy link
Copy Markdown
Collaborator Author

Pushed. Here's what changed:

Equality assertions (@phpstan-assert =): Reverted to using rootExpr = $call instead of specifyOnly. The rootExpr path in ImpossibleCheckTypeHelper is more nuanced — it can detect constant boolean results via $scope->getType($rootExpr) — and is the established mechanism for these assertions. specifyOnly is now reserved exclusively for the FAUX replacement cases (str_contains, array_key_exists) where sureTypes are pure side effects.

specifyOnly check order: Moved after the rootExpr check in ImpossibleCheckTypeHelper, so if both flags are set (e.g. through unionWith/intersectWith propagation), rootExpr takes precedence and can still detect constant boolean results.

Duplicate call detection: Added test cases showing that nested identical str_ends_with/str_contains calls are NOT reported as always-true. This was never detected before this PR either — the old FAUX mechanism also returned null. Detecting duplicates would require tracking function call results in scope (e.g., knowing str_ends_with($h, $n) is true inside if (str_ends_with($h, $n))), which is a separate mechanism from type-specifying extensions.

@VincentLanglet VincentLanglet 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.

I want

  • An Unified behavior for Faux function call and Assert with isEquality to true.

  • Duplicate call to be reported, like how it works for typeSpecifier without RootExpr.

class MondayMorning
{
    /**
	 * @param non-empty-string $needle
	 */
	public function strEndsWithDuplicate(string $haystack, string $needle): void
	{
		if (str_ends_with($haystack, $needle)) { // Not reported
			if (str_ends_with($haystack, $needle)) { // Reported

			}
		}
	}

	public function startDay(string $task): void
	{
		if ($this->isValid($task)) { // Not reported
			if ($this->isValid($task)) { // reported
				
			}
		}
	}

	/** @phpstan-assert-if-true =non-empty-string $foo */
	public function isValid(string $foo): bool
	{
		
	}
}

Maybe start by explaining the purpose of the rootExpr.

@VincentLanglet
VincentLanglet force-pushed the create-pull-request/patch-7xy6xo5 branch from f8567a4 to d6e4e48 Compare May 27, 2026 16:55
@VincentLanglet
VincentLanglet changed the base branch from 2.1.x to 2.2.x May 27, 2026 16:55

@VincentLanglet VincentLanglet 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.

I want

  • An Unified behavior for Faux function call and Assert with isEquality to true.

  • Duplicate call to be reported, like how it works for typeSpecifier without RootExpr.

class MondayMorning
{
    /**
	 * @param non-empty-string $needle
	 */
	public function strEndsWithDuplicate(string $haystack, string $needle): void
	{
		if (str_ends_with($haystack, $needle)) { // Not reported
			if (str_ends_with($haystack, $needle)) { // Reported

			}
		}
	}

	public function startDay(string $task): void
	{
		if ($this->isValid($task)) { // Not reported
			if ($this->isValid($task)) { // reported
				
			}
		}
	}

	/** @phpstan-assert-if-true =non-empty-string $foo */
	public function isValid(string $foo): bool
	{
		
	}
}

Maybe start by explaining the purpose of the rootExpr.

@phpstan-bot

Copy link
Copy Markdown
Collaborator Author

Pushed successfully. Here's a summary of what changed:

Unified specifyOnly and rootExpr into a single mechanism:

  • Removed specifyOnly flag entirely from SpecifiedTypes (property, setter, getter, propagation in all immutable-copy methods)
  • Extensions (StrContainingTypeSpecifyingExtension, ArrayKeyExistsFunctionTypeSpecifyingExtension): Replaced setSpecifyOnly() with setRootExpr($node) plus a sureType that stores ConstantBooleanType(true) for the call expression itself
  • Equality assertions (TypeSpecifier::specifyTypesFromAsserts): Added a sureType with ConstantBooleanType for the call expression, matching the extension pattern

Added duplicate call detection:

  • In ImpossibleCheckTypeHelper, before the isSpecified early return, check hasExpressionType — if the call was already stored in scope (from a previous filterByTruthyValue), read its type and report always-true/false
  • Used isTrue()/isFalse() instead of instanceof ConstantBooleanType in the new code path (per codebase conventions)

Tests: All 12540 tests pass. PHPStan shows only 4 pre-existing errors (none from this change). New test expectations verify duplicate detection for str_ends_with, str_contains, and equality assertion method calls.

@VincentLanglet
VincentLanglet force-pushed the create-pull-request/patch-7xy6xo5 branch from f7b6a02 to b601591 Compare May 27, 2026 18:01
Comment thread src/Type/Php/StrContainingTypeSpecifyingExtension.php Outdated
Comment thread tests/PHPStan/Rules/Comparison/data/bug-14705.php Outdated
@VincentLanglet VincentLanglet changed the title Add setSpecifyOnly() on SpecifiedTypes to replace FAUX function call workarounds Replace FAUX function call workarounds May 28, 2026

@VincentLanglet VincentLanglet 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.

Would it be possible to deprecate setRootExpr and use a new property/setter with a dedicated logic in ImpossibleCheckHelper instead which

  • Doesn't require to add
$this->create($call, new ConstantBooleanType($context->true()), TypeSpecifierContext::createTrue(), $scope),

as a workaround

  • Keep reporting duplicate calls
  • Still avoid always-true/always-false false positive

Without any implementation, propose one or multiple strategy in a PR comment.

@phpstan-bot

Copy link
Copy Markdown
Collaborator Author

I can't comment directly on the upstream PR due to token permissions. Since the review asks for strategy proposals without implementation, I'll provide the comment content here. The automated pipeline will post it to the PR.


Proposed strategies for deprecating setRootExpr in side-effect cases

Background: rootExpr's two roles

rootExpr currently serves two distinct purposes:

  1. Expression decomposition (TypeSpecifier-internal, ~100 call sites): When TypeSpecifier decomposes $x === 5 into sureTypes on $x, rootExpr points to $x === 5 so ImpossibleCheckTypeHelper evaluates the full expression instead of the decomposed parts. This is the original, legitimate purpose.

  2. Side-effect suppression (~5 call sites in extensions + equality assertions): Extensions like StrContaining and ArrayKeyExists set rootExpr = $node (the call itself). Since isSpecified($node, $rootExpr) returns true when $rootExpr === $node (line 380 of ImpossibleCheckTypeHelper), this triggers immediate return null, suppressing false always-true/false reports. The ConstantBooleanType sureType was then added alongside to enable duplicate detection via hasExpressionType (lines 281-289).

Only role 2 is being replaced. Role 1 continues to need setRootExpr. So setRootExpr itself isn't fully deprecated — but its hijacked use as a side-effect workaround is.


Strategy: setSideEffectOnly() flag + automatic call result storage in filterByTruthyValue/filterByFalseyValue

API from extension's perspective:

// StrContainingTypeSpecifyingExtension:
return $this->typeSpecifier->create(
    $args[$hackstackArg]->value,
    new IntersectionType($accessories),
    $context,
    $scope,
)->setSideEffectOnly();

// ArrayKeyExistsFunctionTypeSpecifyingExtension:
return $specifiedTypes->unionWith($this->typeSpecifier->create(
    $arrayDimFetch,
    $arrayType->getIterableValueType(),
    $context,
    $scope,
))->setSideEffectOnly();

No ConstantBooleanType, no setRootExpr, no unionWith(create($node, ...)) workaround.

Changes (4 locations):

  1. SpecifiedTypes: New bool $sideEffectOnly property with @api setter setSideEffectOnly(): self and getter isSideEffectOnly(): bool. Propagated through all immutable-copy methods (setAlwaysOverwriteTypes, setRootExpr, setNewConditionalExpressionHolders, removeExpr, intersectWith, unionWith, normalize) — same propagation semantics as overwrite (OR for union, AND or OR for intersect, preserved through normalize).

  2. ImpossibleCheckTypeHelper::findSpecifiedType: New dedicated block before the rootExpr block:

    if ($specifiedTypes->isSideEffectOnly()) {
        // Duplicate detection: scope stores the call result
        // from a previous identical check in an outer branch
        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;
            }
        }
        // Not a duplicate — don't analyse sureTypes for always-true/false
        return null;
    }
  3. MutatingScope::filterByTruthyValue / filterByFalseyValue: After getting the SpecifiedTypes from specifyTypesInCondition and applying them via filterBySpecifiedTypes, check the flag. If isSideEffectOnly() is true, additionally store the condition expression's boolean result (ConstantBooleanType(true) for truthy, false for falsey) in the scope. This is what enables duplicate detection — when the same call appears in a nested branch, hasExpressionType finds the stored result.

    filterByTruthyValue already has both the expression and the SpecifiedTypes:

    public function filterByTruthyValue(Expr $expr): self
    {
        $specifiedTypes = $this->typeSpecifier->specifyTypesInCondition($this, $expr, ...);
        $scope = $this->filterBySpecifiedTypes($specifiedTypes);
    
        if ($specifiedTypes->isSideEffectOnly()) {
            $scope = $scope->assignExpression(
                $expr,
                new ConstantBooleanType(true),
                new ConstantBooleanType(true),
            );
        }
    
        $this->truthyScopes[$exprString] = $scope;
        return $scope;
    }

    (And the mirror for filterByFalseyValue with ConstantBooleanType(false).)

  4. Callers — replace the current pattern in:

    • StrContainingTypeSpecifyingExtension: remove unionWith(create($node, ConstantBooleanType...)) and setRootExpr($node), add setSideEffectOnly()
    • ArrayKeyExistsFunctionTypeSpecifyingExtension: same
    • PregMatchTypeSpecifyingExtension: same (uses setRootExpr($node) for matches parameter narrowing)
    • TypeSpecifier::specifyTypesFromAsserts for isEquality() and containsUnresolvedTemplate: same — replace setRootExpr($call) + ConstantBooleanType sureType with setSideEffectOnly()

Edge case: compound conditions

When str_contains($h, $n) && other_check() is processed, TypeSpecifier unions the SpecifiedTypes from both sides. The sideEffectOnly flag propagates via unionWith. Then filterByTruthyValue receives the BooleanAnd expression.

This is fine — storing the BooleanAnd's result as ConstantBooleanType(true) is correct (the compound was true). If the same compound appears again, hasExpressionType detects the duplicate. If just str_contains(...) appears again (without the && other_check()), it gets its own specifyTypesInCondition call, separate from the compound — so it's handled independently.

The propagation semantics (OR in unionWith) are correct: if either side is side-effect-only, the combined SpecifiedTypes is side-effect-only (conservative). This matches overwrite's OR propagation.

What about StrlenFunctionTypeSpecifyingExtension?

StrlenFunctionTypeSpecifyingExtension uses setRootExpr($node) but also creates a sureType for $node itself (with StaticTypeFactory::falsey()/truthy()). This is role 1 usage — the rootExpr is tracking that strlen(...) was decomposed into types on both the call and the argument. It doesn't need the new flag.


Summary

Aspect Current approach Proposed
Extension API ->unionWith(create($node, ConstantBooleanType...))->setRootExpr($node) ->setSideEffectOnly()
False positive suppression isSpecified short-circuit via rootExpr === node Dedicated early return in ImpossibleCheckTypeHelper
Duplicate detection hasExpressionType using manually-added ConstantBooleanType sureType hasExpressionType using automatically-stored result in filterByTruthyValue
Infrastructure changes None filterByTruthyValue/filterByFalseyValue (~4 lines each)

No code changes in this iteration — this is a strategy proposal per the reviewer's request. The current PR code remains as-is until a strategy is agreed on.

@VincentLanglet VincentLanglet 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.

Implement the strategy from #5769 (comment)

@phpstan-bot

Copy link
Copy Markdown
Collaborator Author

Pushed successfully. Here's a summary of what was implemented:

Added setSideEffectOnly() flag on SpecifiedTypes — a dedicated mechanism to replace the setRootExpr($node) + ConstantBooleanType sureType workaround pattern.

How it works:

  1. Extensions (StrContaining, ArrayKeyExists, PregMatch) and equality assertions now call ->setSideEffectOnly() instead of manually adding a ConstantBooleanType sureType and setRootExpr($node).

  2. ImpossibleCheckTypeHelper has a dedicated sideEffectOnly block that checks hasExpressionType for duplicate detection (returning true/false) and otherwise returns null to suppress false positives.

  3. MutatingScope::filterByTruthyValue/filterByFalseyValue automatically stores the call's boolean result via TypeSpecifier::create() when sideEffectOnly is set. Using TypeSpecifier::create (rather than direct SpecifiedTypes construction) ensures the purity check is respected — impure functions like file_get_contents won't get a boolean type stored, which was the key fix for the bug-9991 regression.

@VincentLanglet VincentLanglet 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.

testBug14705 is failing on PHP 7.4 I think it's because is str_ends_with and str_contains doesn't exists.

Split the test in half, on general and one for PHP 8+

@phpstan-bot

Copy link
Copy Markdown
Collaborator Author

All 12541 tests pass, make phpstan is clean. The commit is pushed.

@VincentLanglet VincentLanglet 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.

You said

Side-effect suppression (~5 call sites in extensions + equality assertions): Extensions like StrContaining and ArrayKeyExists set rootExpr = $node (the call itself). Since isSpecified($node, $rootExpr) returns true when $rootExpr === $node (line 380 of ImpossibleCheckTypeHelper), this triggers immediate return null, suppressing false always-true/false reports. The ConstantBooleanType sureType was then added alongside to enable duplicate detection via hasExpressionType (lines 281-289).

But call the option sideEffectOnly ; is it consistent ?

@phpstan-bot

Copy link
Copy Markdown
Collaborator Author

Renamed sideEffectOnly to specifyOnly across all 7 files. The term "side effect" was overloaded — in programming it means function impurity, while here it meant "type narrowings that are a consequence of the check." specifyOnly fits the SpecifiedTypes domain: these types only specify (narrow) types, they don't determine the check outcome in ImpossibleCheckTypeHelper. All 12541 tests pass and make phpstan is clean.

Comment thread src/Analyser/SpecifiedTypes.php Outdated
@staabm

staabm commented May 29, 2026

Copy link
Copy Markdown
Contributor

issue-bot tells us that with this PR inference for this snippet will be narrowed:

<?php

declare(strict_types=1);

/**
 * @param array<string,string|array<int,string>> $theInput
 * @phpstan-param array{'name':string,'owners':array<int,string>} $theInput
 * @param array<int,string> $theTags
 * @return void
 */
function Example(array $theInput, array $theTags): void
{
	foreach ($theTags as $tag) {
		if (!array_key_exists($tag, $theInput)) {
			continue;
		}
		\PHPStan\dumpType(!array_key_exists($tag, $theInput));
		\PHPStan\dumpType(array_key_exists($tag, $theInput));
	}
}

after this PR:

 ------ -------------------------------------- 
  Line   foo.php                               
 ------ -------------------------------------- 
  17     Dumped type: false                     
         🪪  phpstan.dumpType (non-ignorable)  
         at foo.php:17                         
  18     Dumped type: bool                     
         🪪  phpstan.dumpType (non-ignorable)  
         at foo.php:18                         
 ------ --------------------------------------

before this PR:

 ------ -------------------------------------- 
  Line   foo.php                               
 ------ -------------------------------------- 
  17     Dumped type: bool                     
         🪪  phpstan.dumpType (non-ignorable)  
         at foo.php:17                         
  18     Dumped type: bool                     
         🪪  phpstan.dumpType (non-ignorable)  
         at foo.php:18                         
 ------ --------------------------------------

just to let you know, in case this is something which should be tested/asserted or analysed further

@VincentLanglet VincentLanglet 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.

Add in the nsrt file

/**
 * @param array<string,string|array<int,string>> $theInput
 * @phpstan-param array{'name':string,'owners':array<int,string>} $theInput
 * @param array<int,string> $theTags
 * @return void
 */
function Example(array $theInput, array $theTags): void
{
	foreach ($theTags as $tag) {
		if (!array_key_exists($tag, $theInput)) {
			continue;
		}
		\PHPStan\assertType(..., !array_key_exists($tag, $theInput));
		\PHPStan\assertType(..., array_key_exists($tag, $theInput));
	}
}

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

I love FAULX workarounds to get removed :)

@phpstan-bot

Copy link
Copy Markdown
Collaborator Author

Done. I addressed the review by adding the requested snippet to the nsrt file.

What I added

A new arrayKeyExistsDuplicateInLoop() method in tests/PHPStan/Analyser/nsrt/bug-14705.php mirroring staabm's snippet (kept as a method to match the file's class-based structure, preserving the @phpstan-param array{...} override):

foreach ($theTags as $tag) {
    if (!array_key_exists($tag, $theInput)) {
        continue;
    }
    assertType('false', !array_key_exists($tag, $theInput));
    assertType('bool', array_key_exists($tag, $theInput));
}

The assertType values (false for the negated duplicate, bool for the positive call) match the narrowed inference staabm reported after this PR — verified by running NodeScopeResolverTest, which auto-discovers the file and passed all assertions without edits.

Verification

  • NodeScopeResolverTest (testFile) — 1618/1618 pass, confirming the false/bool types are exactly what PHPStan infers.
  • ImpossibleCheckType{Function,Method}CallRuleTest — 111/111 pass (the new duplicate array_key_exists correctly produces no always-true report, since the positive call stays bool).
  • make phpstan — clean, no errors.
  • make tests — 12547 pass (96 skipped, pre-existing).

Committed and pushed to create-pull-request/patch-7xy6xo5.

Comment thread tests/PHPStan/Analyser/nsrt/bug-14705.php Outdated

@VincentLanglet VincentLanglet 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.

Can't we simplify node like !array_key_exists($tag, $theInput) when saving the value ?

if !array_key_exists is saved as false we could instead save array_key_exists as true ?

@phpstan-bot

Copy link
Copy Markdown
Collaborator Author

Done. Here's how I addressed the review.

The reviewer's question

Can't we simplify node like !array_key_exists($tag, $theInput) when saving the value? If !array_key_exists is saved as false we could instead save array_key_exists as true?

Yes — and it produces strictly better inference. Implemented.

What I changed

MutatingScope::filterByTruthyValue / filterByFalseyValue — when storing the specifyOnly duplicate-detection marker, I now strip BooleanNot wrappers via a new unwrapSpecifyOnlyMarker() helper. So for if (!array_key_exists(...)) continue;, instead of recording !array_key_exists(...) as false, the marker is stored for the underlying array_key_exists(...) call as true (flipping the value once per negation level). The negated form is then derived from the inner value rather than capped at bool.

Effect on inference (the test case the reviewer added):

  • assertType('false', !array_key_exists($tag, $theInput)) — still false (derived)
  • assertType('true', array_key_exists($tag, $theInput)) — now true (was bool)

Consequence — genuine duplicates now reported. Storing true means ImpossibleCheckTypeHelper now reports the duplicate positive calls after the guard as always-true (true positives — after if (!array_key_exists(...)) continue; the key definitely exists). I updated the rule-test expectations accordingly.

Dead code in PHPStan's own source. This stronger detection flagged a real duplicate guard in MethodCallWithPossiblyRenamedNamedArgumentRule.php (two identical if (!array_key_exists($prototypeParameterName, $prototypeMethodCalls)) continue; blocks). I removed the dead second block — exactly the kind of bug this PR's mechanism is designed to catch.

Verification

  • NodeScopeResolverTest (testFile) — 1618/1618 pass, confirming true/false inference.
  • ImpossibleCheckType{Function,Method}CallRuleTest — 111/111 pass.
  • make phpstan — clean (was 1 error, the now-removed duplicate).
  • make tests — all 12547 pass.

Committed as two logical commits and pushed.

@VincentLanglet
VincentLanglet force-pushed the create-pull-request/patch-7xy6xo5 branch from 7081004 to 32acf00 Compare May 30, 2026 09:00
Comment thread src/Analyser/NodeScopeResolver.php Outdated
Comment thread src/Analyser/SpecifiedTypes.php Outdated
Comment thread tests/PHPStan/Analyser/nsrt/bug-14705.php Outdated
@VincentLanglet VincentLanglet self-assigned this Jun 3, 2026
Comment thread src/Analyser/NodeScopeResolver.php Outdated

@ondrejmirtes ondrejmirtes left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

  1. I don't love the setSpecifyOnly and shouldSpecifyOnly names. I don't love it, by reading it I don't understand what it means.
  2. To be fair "equality assertions" (https://phpstan.org/writing-php-code/narrowing-types#equality-assertions) are not a great name either but they do exactly what we want here: https://phpstan.org/r/bf4d5f26-b438-44a2-b980-718fb870ff2e

The error "will always evaluate to true." is not reported and $t is not narrowed in else.

To track how is this implemented we need to look where AssertTag::isEquality() is called. In TypeSpecifier around line 1882 this is done:

->setRootExpr($containsUnresolvedTemplate || $assert->isEquality() ? $call : null)

What would happen if we started to passing null there instead of a FAUX_FUNCTION FuncCall or instead of calling ->setSpecifyOnly() in the context of this PR?

Could we introduce setEquality() as an alias to setRootExpr(null)? Would it work for all use cases? Would all tests still pass?

@phpstan-bot

Copy link
Copy Markdown
Collaborator Author

Both parts of the review are addressed. Here's my response for the PR:

1) Renamed specifyOnlyequality

Renamed setSpecifyOnly() / shouldSpecifyOnly() to setEquality() / isEquality() (backing property $equality) across all files. This adopts your suggested name and ties the flag to the documented equality assertions concept it actually implements. It also reads naturally at the source — the AssertTag::isEquality() branch in TypeSpecifier now simply calls ->setEquality():

if ($containsUnresolvedTemplate || $assert->isEquality()) {
    $newTypes = $newTypes->setEquality();
}

SpecifiedTypes::isEquality() mirrors AssertTag::isEquality(), and the str_contains/array_key_exists/preg_match extensions express that their argument narrowing has the same "narrow but don't determine the outcome" semantics as an equality assertion.

2) Can setEquality() just be an alias to setRootExpr(null)? — No

I ran the experiment you asked for: in the $assert->isEquality() branch I replaced ->setEquality() with ->setRootExpr(null) and ran testDocblockAssertEquality. It fails — the false positive comes back:

+36: Call to function equalsRandomInteger() with int will always evaluate to true.
 42: Call to function isAnInteger() with int will always evaluate to true.

Line 36 is equalsRandomInteger() with @phpstan-assert-if-true =int $x — exactly the equality assertion that must not be reported. With rootExpr === null, ImpossibleCheckTypeHelper skips the rootExpr block entirely and falls through to the sureTypes analysis: int is a supertype of int, so it concludes always-true. The old setRootExpr($call) worked only because rootExpr === $node short-circuits to return null (via isSpecified); passing null removes that short-circuit. So the suppression genuinely needs a dedicated flag — setEquality() can't be a no-op alias to setRootExpr(null).

The flag (renamed, not behaviorally changed) keeps all use cases working:

  • make phpstan — clean
  • ImpossibleCheckType{Function,Method}CallRuleTest — 111/111
  • NodeScopeResolverTest (testFile) — 1618/1618
  • make tests — all 12547 pass

Committed as 511f5d020 and pushed.

@VincentLanglet
VincentLanglet force-pushed the create-pull-request/patch-7xy6xo5 branch from 511f5d0 to a08a63e Compare June 4, 2026 13:53
@VincentLanglet
VincentLanglet force-pushed the create-pull-request/patch-7xy6xo5 branch from a08a63e to 867ba24 Compare June 13, 2026 09:18
@VincentLanglet
VincentLanglet force-pushed the create-pull-request/patch-7xy6xo5 branch from 867ba24 to 69a9e98 Compare June 17, 2026 08:12
@staabm
staabm force-pushed the create-pull-request/patch-7xy6xo5 branch from 69a9e98 to 17d1bee Compare June 25, 2026 05:05
@staabm

staabm commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

@VincentLanglet just saw the neos error.. is it valid?

@VincentLanglet VincentLanglet 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.

There is a false positive with

/**
     * @param string $srcDirectory
     */
    public function renderDirectory(string $srcDirectory): void
    {
        $files = scandir($srcDirectory) ?: [];

        foreach ($files as $key => $value) {

which reports Empty array passed to foreach.

@phpstan-bot

Copy link
Copy Markdown
Collaborator Author

Pushed. Here's a summary of how I addressed the review.

The false positive

$files = scandir($srcDirectory) ?: [];
foreach ($files as $key => $value) { // "Empty array passed to foreach."

scandir(...) ?: [] was inferred as array{} instead of list<string>.

Root cause

scandir() carries an equality assertion in its stub — @phpstan-assert-if-true =non-empty-string $directory — but returns a non-boolean type, list<string>|false.

The PR's filterByTruthyValue() / filterByFalseyValue() stored a ConstantBooleanType marker for the call expression whenever the SpecifiedTypes were isEquality(), to enable duplicate-call detection. That marker is intersected with the expression's real type. For a genuinely boolean call (str_contains, array_key_exists, an @phpstan-assert-if-true bool method) bool & true = true — fine. But for scandir, (list<string>|false) & true collapses to *NEVER* inside the truthy branch. The Elvis then computes removeFalsey(never) | [] = array{}, which trips the foreach rule.

The earlier realpath fix only covered impure functions (where create() drops the marker). scandir is treated as pure, so it slipped through.

Fix

Only add the boolean marker when the expression is actually boolean:

if ($specifiedTypes->isEquality() && $this->getType($expr)->isBoolean()->yes()) {
    ...
}

The marker exists purely for duplicate-call detection, which is only meaningful for boolean-returning calls. Guarding on isBoolean()->yes() keeps duplicate detection for all the boolean cases (str_contains, array_key_exists, @phpstan-assert-if-true methods) while preventing the marker from ever corrupting a meaningful non-boolean return type. The isEquality() flag itself is untouched, so ImpossibleCheckTypeHelper still suppresses false always-true reports from the argument narrowing.

Verification

  • New nsrt cases in tests/PHPStan/Analyser/nsrt/bug-14705.php asserting scandir($d) ?: [] is list<string> and scandir($d) is non-empty-list<string> inside an if (both fail without the fix).
  • make phpstan — clean.
  • make tests — all pass (17460 tests, 96 pre-existing skips).
  • ImpossibleCheckType{Function,Method}CallRuleTest and NodeScopeResolverTest — pass, confirming duplicate detection is preserved.

Committed as 5495f0483 and pushed.

@VincentLanglet

Copy link
Copy Markdown
Contributor

@VincentLanglet just saw the neos error.. is it valid?

Kinda fun, it was related to the fact that phpstan-assert-if-true was use on a non boolean return type.

…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 phpstan/phpstan#14705
@VincentLanglet
VincentLanglet force-pushed the create-pull-request/patch-7xy6xo5 branch from 5495f04 to 645777d Compare July 15, 2026 13:15
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.

add another flag on SpecifiedTypes to not imply the opposite case

4 participants