Skip to content

Replace FAUX function call workarounds#5769

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

Replace FAUX function call workarounds#5769
phpstan-bot wants to merge 26 commits 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
Copy link
Copy Markdown
Contributor

@VincentLanglet VincentLanglet left a comment

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
Copy link
Copy Markdown
Contributor

staabm commented May 27, 2026

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

Copy link
Copy Markdown
Contributor

@VincentLanglet VincentLanglet left a comment

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.

Copy link
Copy Markdown
Contributor

@VincentLanglet VincentLanglet left a comment

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
Copy link
Copy Markdown
Contributor

@VincentLanglet VincentLanglet left a comment

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
new Arg($args[$needleArg]->value),
]),
));
)->setRootExpr($node);
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.

What's the purpose of the setRootExpr method ?

And why setting the $node has exactly the same behavior than the previous code with the FAUX_FUNCTION` ?

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 what changed:

Removed the specifyOnly flag entirely — after the Rework commit unified both FAUX extensions and equality assertions through setRootExpr($node), the flag was dead code. Removed the property, setter, getter, and all propagation in intersectWith/unionWith/normalize/immutable-copy methods.

Documented setRootExpr() with a PHPDoc explaining the mechanism and answering the reviewer's question:

  • Purpose: When rootExpr is set, ImpossibleCheckTypeHelper evaluates it directly in scope instead of analysing sureTypes/sureNotTypes. This separates "side-effect narrowing" from "check outcome determination."
  • Why setRootExpr($node) behaves the same as FAUX_FUNCTION: Both reach return null in ImpossibleCheckTypeHelper, just via different paths. With $node, the isSpecified() check sees $rootExpr === $node (line 374) and returns true immediately, causing an early return null. With FAUX_FUNCTION, isSpecified() returned false (fabricated expression ≠ node), then $scope->getType(fabricatedExpr) couldn't resolve to a ConstantBooleanType (undefined function), falling through to the same return null.

All 12539 tests pass, PHPStan self-analysis reports no errors.

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
Copy link
Copy Markdown
Contributor

@VincentLanglet VincentLanglet left a comment

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.

Copy link
Copy Markdown
Contributor

@VincentLanglet VincentLanglet left a comment

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.

Copy link
Copy Markdown
Contributor

@VincentLanglet VincentLanglet left a comment

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.

Copy link
Copy Markdown
Contributor

@VincentLanglet VincentLanglet left a comment

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.

continue;
}

if (!array_key_exists($prototypeParameterName, $prototypeMethodCalls)) {
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.

how is this change related?

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.

Reverted the unrelated array_key_exists removal in MethodCallWithPossiblyRenamedNamedArgumentRule.php. The original file had a genuinely duplicated array_key_exists check that was caught during make phpstan runs with this PR's changes, but it's a pre-existing issue unrelated to the specifyOnly mechanism. All 12541 tests pass and make phpstan is clean.

phpstan-bot and others added 7 commits May 29, 2026 10:33
The duplicate check in MethodCallWithPossiblyRenamedNamedArgumentRule
is a pre-existing issue unrelated to this PR. Reverting to keep the
PR focused on the specifyOnly mechanism.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add PHPDoc explaining that setSpecifyOnly() works correctly with
extensions that synthesize expressions for specifyTypesInCondition()
(like phpstan-webmozart-assert), because filterByTruthyValue() stores
the boolean result for the original call node, not the synthesized one.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The previous PHPDoc incorrectly claimed that extensions synthesizing
expressions (e.g. decomposing Assert::string($x) into is_string($x))
can rely on filterByTruthyValue() for duplicate detection. This is only
true for boolean-returning calls used in conditions (if/while).

For void assertion methods used as statements, filterByTruthyValue() is
never called — NodeScopeResolver processes them via filterBySpecifiedTypes
with null context. ImpossibleCheckTypeHelper::determineContext() also
returns null context for void methods. The specifyOnly mechanism does not
provide duplicate detection in that path (and neither did the former
setRootExpr/FAUX workaround).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…tion

The previous approach used TypeSpecifier::create() which has a purity
check that returns empty SpecifiedTypes for impure/void method calls.
This prevented the boolean marker from being stored in scope, breaking
duplicate detection for void assertion methods.

Additionally, the marker was added via unionWith before
filterBySpecifiedTypes, which caused addTypeToExpression to intersect
ConstantBooleanType(true) with the existing void return type, producing
NeverType instead of the expected true marker.

Fix: store the boolean marker AFTER filterBySpecifiedTypes using a
separate filterBySpecifiedTypes call with setAlwaysOverwriteTypes().
This bypasses both the purity check and the type intersection issue.

Also adds specifyOnly handling to NodeScopeResolver's expression
statement path, enabling duplicate detection for void assertion methods
used as statements (e.g. Assert::string($x) called twice).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The previous commit stored ConstantBooleanType(true) for the call
expression using setAlwaysOverwriteTypes(), bypassing TypeSpecifier's
purity check. For impure functions like realpath() (which has
@phpstan-assert-if-true =non-empty-string), this overwrote the
function's real return type in the truthy scope: getType(realpath($x))
returned true instead of non-empty-string, causing the Elvis operator
realpath($x) ?: $x to produce string|true instead of string.

Fix: use TypeSpecifier::create() with its purity check, as in the
approach before commit 0392487. For impure calls (hasSideEffects=yes),
create() returns empty SpecifiedTypes, preventing the boolean marker
from overwriting the return type. Duplicate detection still works for
pure and possibly-impure calls. The NodeScopeResolver expression
statement path (for void assertions) retains the overwrite approach
since void calls are not used for their return value.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ases

Consolidates the rule data file and the specifyOnly-elvis-realpath nsrt
file into a single nsrt/bug-14705.php that asserts the narrowed types
inside the if branches. The ImpossibleCheckType rule tests now reference
the moved file for always-true duplicate-call reporting.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@VincentLanglet VincentLanglet force-pushed the create-pull-request/patch-7xy6xo5 branch from 816af8c to 341f63d Compare May 29, 2026 08:33
@VincentLanglet
Copy link
Copy Markdown
Contributor

This would be used this way phpstan/phpstan-webmozart-assert#216

Comment thread src/Analyser/SpecifiedTypes.php Outdated
Mirrors the existing setAlwaysOverwriteTypes()/shouldOverwrite() naming
pair, so the specifyOnly setter/getter follows the same convention.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@staabm
Copy link
Copy Markdown
Contributor

staabm commented May 29, 2026

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

Copy link
Copy Markdown
Contributor

@VincentLanglet VincentLanglet left a comment

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));
	}
}

Copy link
Copy Markdown
Contributor

@staabm staabm left a comment

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@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.

continue;
}
assertType('false', !array_key_exists($tag, $theInput));
assertType('bool', array_key_exists($tag, $theInput));
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 this is not true ?

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. Here's my answer to the reviewer's question.

Why array_key_exists($tag, $theInput) is bool, not true

bool is correct (just less precise than true). The reason is in how the specifyOnly duplicate-detection marker works versus how a normal bool-returning function narrows.

The marker is keyed by the exact condition node. The boolean result of a specifyOnly call is only recorded in scope by filterByTruthyValue/filterByFalseyValue, for the literal expression that was used as a condition. In this loop the condition is the BooleanNot !array_key_exists($tag, $theInput), so after the early-continue only that node is recorded — as false. The bare positive array_key_exists($tag, $theInput) is a different AST node that was never used as a condition, so it keeps its declared bool return type.

I confirmed this is purely about which node is the condition:

Condition array_key_exists(...) !array_key_exists(...)
if (!array_key_exists(...)) continue; bool false
if (array_key_exists(...)) { ... } true false

When the positive call is the condition, it resolves to true, fully symmetric.

Why specifyOnly funcs differ from normal funcs. A normal bool-returning function (check()) gets its call expression itself narrowed to true/false by TypeSpecifier, so that narrowing propagates through the BooleanNot and check($x) becomes true after if (!check($x)) return;. The specifyOnly extensions (array_key_exists, str_contains, …) deliberately do not narrow the call expression — they only narrow the argument (the array/haystack) as a side effect, and set shouldSpecifyOnly() so ImpossibleCheckTypeHelper doesn't treat that argument-narrowing as the check's outcome.

Making it true isn't worth it here. It would require either reintroducing the per-call create($node, ConstantBooleanType) marker inside the extensions — exactly the workaround this PR removed at your request — or special-casing BooleanNot inside MutatingScope. bool is genuinely correct, so I documented the reasoning in the test rather than expanding scope.

I added a comment block above arrayKeyExistsDuplicateInLoop capturing this. Verified: NodeScopeResolverTest (1618), the three testBug14705* rule tests, and make phpstan all pass. Committed as f223e96fd and pushed.

Copy link
Copy Markdown
Contributor

@VincentLanglet VincentLanglet left a comment

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
);
$scope = $scope->filterBySpecifiedTypes($specifiedTypes);
if ($specifiedTypes->shouldSpecifyOnly()) {
// This is the expression-statement counterpart of the specifyOnly handling
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 less verbose 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 both review comments:

Changes

src/Analyser/SpecifiedTypes.php (VincentLanglet: "Remove the second paragraph") — Removed the second paragraph from the setSpecifyOnly() PHPDoc, which described the filterByTruthyValue/purity-check duplicate-detection mechanics. The PHPDoc now keeps just the first paragraph describing what the flag means.

src/Analyser/NodeScopeResolver.php ("Use a less verbose comment") — Condensed the 11-line comment in the specifyOnly expression-statement block down to 4 lines, keeping the essentials: it's the statement counterpart of filterByTruthyValue(), it stores the call's true result for duplicate-call reporting, and the direct-overwrite reasoning (void calls have no return value to protect from the purity check).

Verification

  • make phpstan — clean, no errors
  • ImpossibleCheckType{Function,Method}CallRuleTest — 111/111 pass

Both changes are comment/PHPDoc-only, committed separately and pushed to create-pull-request/patch-7xy6xo5.

Comment thread src/Analyser/SpecifiedTypes.php Outdated
* the check outcome. ImpossibleCheckTypeHelper will not use sureTypes
* to report always-true/false for the check expression.
*
* Duplicate detection: filterByTruthyValue stores the call's boolean
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.

Remove the second paragraph

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 a summary of how I addressed the review.

Review comments addressed

The latest round of review had three comment-only requests, all from VincentLanglet:

  1. SpecifiedTypes.php — "Remove the second paragraph" of the setSpecifyOnly() PHPDoc (the duplicate-detection explanation).
  2. NodeScopeResolver.php — "Use a less verbose comment" for the specifyOnly expression-statement block.
  3. bug-14705.php — "Add a comment // could be true" on the duplicate array_key_exists assertType.

What happened

When I went to push, the automated pipeline had already landed the first two changes upstream as separate commits:

  • 26cc74d4d Remove duplicate-detection paragraph from setSpecifyOnly() PHPDoc
  • d3ca979e1 Shorten specifyOnly expression-statement comment

So I reset onto the remote and contributed only the still-missing third change — annotating the array_key_exists($tag, $theInput) assertType with // could be true (committed as 57ad82ed4 and pushed).

Verification (before the reset, on my equivalent local state)

  • make phpstan — clean, no errors.
  • ImpossibleCheckType{Function,Method}CallRuleTest — 111/111 pass.
  • NodeScopeResolverTest — 1618/1618 pass.

All three changes are comment/PHPDoc-only and don't affect inference, so behavior is unchanged.

continue;
}
assertType('false', !array_key_exists($tag, $theInput));
assertType('bool', array_key_exists($tag, $theInput));
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 a comment // could be true

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.

The change is complete and already in the branch.

Summary

The reviewer (VincentLanglet) asked to add a // could be true comment to the assertType('bool', array_key_exists($tag, $theInput)); line in tests/PHPStan/Analyser/nsrt/bug-14705.php.

I made the change, verified it with make tests (12547 pass) and make phpstan (clean). When pushing, the rebase revealed the automated pipeline had already committed the identical change upstream (commit 57ad82ed4, "Annotate duplicate array_key_exists assertType with '// could be true'"), so my commit was correctly detected as a duplicate and skipped. The line now reads:

assertType('bool', array_key_exists($tag, $theInput)); // could be true

The branch is in sync with the remote and the requested change is in place.

phpstan-bot and others added 3 commits May 30, 2026 09:04
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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

3 participants