Skip to content

Add setSpecifyOnly() on SpecifiedTypes to replace FAUX function call workarounds#5769

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

Add setSpecifyOnly() on SpecifiedTypes to replace FAUX function call workarounds#5769
phpstan-bot wants to merge 9 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

return null;
}

if ($specifiedTypes->shouldNotImplyOppositeCase()) {
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.

It's unclear reading this early return how it's only about "opposite case".

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.

Added a comment above the shouldNotImplyOppositeCase() check in ImpossibleCheckTypeHelper.php explaining why this flag causes a full early return: the sureTypes are side effects of the check (not the determining condition), so they can't tell us whether the check is always-true or always-false. All tests and static analysis pass.

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.

Is the new property well named ?

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.

Renamed shouldNotImplyOppositeCase to shouldNotDetermineCheckResult across all 5 files. The old name implied the flag only prevented inferring the negated case, but it actually prevents ImpossibleCheckTypeHelper from determining any outcome (both always-true and always-false). The new name accurately describes the flag's effect. All 12155 tests pass and PHPStan self-analysis reports no errors.

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 and others added 5 commits May 27, 2026 18:54
…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
…turn

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The old name suggested the flag only prevented inferring the negated
(opposite) case, but it actually prevents ImpossibleCheckTypeHelper from
determining any outcome — both always-true and always-false. The new name
accurately describes the flag's effect: the sureTypes should not be used
to determine the check result.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The flag means "these SpecifiedTypes only narrow types, don't use them
to determine check outcome" — `specifyOnly` captures this concisely
using the domain language of the SpecifiedTypes class.

`overwrite` remains independent: it controls how types are applied in
MutatingScope (assign vs intersect), while `specifyOnly` controls
ImpossibleCheckTypeHelper. They already have separate early-return
checks and serve different purposes.

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

- Revert equality assertions (`@phpstan-assert =`) back to using
  `rootExpr = $call` instead of `specifyOnly`. The rootExpr mechanism
  in ImpossibleCheckTypeHelper provides more nuanced detection (constant
  boolean evaluation via scope) and is the established path for these.
  `specifyOnly` is reserved for the FAUX replacement cases (str_contains,
  array_key_exists) where sureTypes are pure side effects.

- Move the `specifyOnly` check after the `rootExpr` check in
  ImpossibleCheckTypeHelper so that rootExpr takes precedence when both
  flags are set (e.g. via unionWith/intersectWith propagation).

- Add duplicate call test cases (str_ends_with, str_contains) to
  document that nested identical calls are not reported as always-true.
  This was never detected before — the old FAUX mechanism also returned
  null for these — and would require a separate mechanism (tracking
  function call results in scope).

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

After the Rework commit unified both FAUX extensions and equality
assertions through setRootExpr($node), the specifyOnly flag became
dead code. Remove it entirely.

Add PHPDoc to setRootExpr() explaining the mechanism: when rootExpr
is set, ImpossibleCheckTypeHelper evaluates it in scope instead of
analysing sureTypes/sureNotTypes. When rootExpr === the check node
(as used by str_contains, array_key_exists, and equality assertions),
isSpecified() bails out immediately, preventing false always-true
reports from side-effect narrowing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
public function strEndsWithDuplicate(string $haystack, string $needle): void
{
if (str_ends_with($haystack, $needle)) {
if (str_ends_with($haystack, $needle)) { // could be reported as always-true
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 should be reported, it would certainly require to add

$this->typeSpecifier->create($rootExpr, new ConstantBooleanType(true), TypeSpecifierContext::createTruthy(), $scope),

and a dedicated logic before the early return null if it's specified.

But couldn't the typeSpecifier->create call be imply by the fact we set a rootExpr since it's made for this ?

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:

Duplicate call detection for rootExpr-based type specifying:

  • Extensions (StrContainingTypeSpecifyingExtension, ArrayKeyExistsFunctionTypeSpecifyingExtension): Now add a sureType $node → ConstantBooleanType(true) via unionWith($this->typeSpecifier->create(...)) alongside setRootExpr($node). This stores the call's boolean result in scope when filterByTruthyValue processes the condition.

  • TypeSpecifier::specifyTypesFromAsserts: For equality assertions and unresolved templates (where rootExpr is set), adds a sureType for the call expression with ConstantBooleanType($context->true())true in truthy context, false in falsey context.

  • ImpossibleCheckTypeHelper: Before the isSpecified early return in the rootExpr block, checks $scope->hasExpressionType($node). If the call's result type is already known in scope (stored from a previous identical check in an outer branch), reports always-true/false.

  • SpecifiedTypes::setRootExpr PHPDoc: Updated to document that callers should also add a sureType for the rootExpr expression.

  • Test expectations updated: Duplicate str_ends_with, str_contains, and equality assertion method calls are now reported as always-true.

Dead code fix: The new duplicate detection caught a real duplicate array_key_exists check in MethodCallWithPossiblyRenamedNamedArgumentRule.php:54 — removed the redundant check, reducing PHPStan self-analysis errors from 4 to 3.

All 12540 tests pass. PHPStan self-analysis shows only 3 pre-existing errors.

phpstan-bot and others added 2 commits May 27, 2026 22:33
When setRootExpr($node) is used, also add a sureType for the call
expression with ConstantBooleanType(true). This stores the expression
result in scope via filterByTruthyValue, enabling ImpossibleCheckTypeHelper
to detect duplicate calls (e.g. nested identical str_ends_with inside
if(str_ends_with(...))).

ImpossibleCheckTypeHelper now checks scope for the expression type before
the isSpecified early return. If the call result is already known (stored
from a previous identical check), it reports always-true/false.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The same array_key_exists($prototypeParameterName, $prototypeMethodCalls)
check was performed twice — the second was dead code. Detected by the
new duplicate call detection for rootExpr-based type specifying.

Co-Authored-By: Claude Opus 4.6 <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