Skip to content

Intersect stored FuncCall expression types with dynamically computed types in MutatingScope::resolveType()#5454

Open
phpstan-bot wants to merge 2 commits intophpstan:2.1.xfrom
phpstan-bot:create-pull-request/patch-fgzfwch
Open

Intersect stored FuncCall expression types with dynamically computed types in MutatingScope::resolveType()#5454
phpstan-bot wants to merge 2 commits intophpstan:2.1.xfrom
phpstan-bot:create-pull-request/patch-fgzfwch

Conversation

@phpstan-bot
Copy link
Copy Markdown
Collaborator

Summary

When count($arr) was used in a comparison like count($arr) > 2, the TypeSpecifier stored the narrowed count($arr) type in the scope's expression types for both the truthy and falsey branches. After scope merging, the stored type became int<0, max>. If $arr was subsequently narrowed to non-empty-array by a different condition (e.g. count($arr, COUNT_RECURSIVE) > 2 or $arr !== []), the stale stored count($arr) = int<0, max> was returned instead of being recomputed from the narrowed array type.

Changes

  • Modified MutatingScope::resolveType() in src/Analyser/MutatingScope.php: when a FuncCall expression has a stored expression type (with YES certainty), also compute the type dynamically via the ExprHandler and return the intersection of the stored and dynamic types.
  • The stored type is temporarily set in resolvedTypes as a recursion guard before computing the dynamic type.
  • Added regression test in tests/PHPStan/Analyser/nsrt/bug-13750.php covering:
    • The reported bug: count($arr) after count($arr) > 2 then count($arr, COUNT_RECURSIVE) > 2
    • Analogous case: count($arr) after count($arr) > 2 then $arr !== []
    • Analogous case: sizeof() (alias for count)
    • Analogous case: strlen() after strlen() > 5 then $str !== ''

Root cause

The bug was in MutatingScope::resolveType(). When a non-variable expression (like count($arr)) had a stored expression type with YES certainty, resolveType() returned it directly without consulting the ExprHandler/DynamicReturnTypeExtension. This is correct when the stored type is still valid, but becomes stale when the function's arguments are narrowed by a subsequent condition.

The conditional expressions mechanism (created during scope merging) normally handles this by linking derived expressions to their constituent variables. However, when using count($arr) > N with N >= 1, the falsey branch leaves $arr unchanged (array), which is the same as the merged type. This means $arr is not identified as a "type guard", so no conditional expression linking count($arr) to $arr is created. The stale count($arr) type then persists uncorrected.

The fix intersects the stored type with the dynamically computed type for all FuncCall expressions. This is always correct: the stored type narrows based on conditions, the dynamic type narrows based on current argument types, and their intersection is the most specific correct type.

Test

  • tests/PHPStan/Analyser/nsrt/bug-13750.php: NSRT test with assertType() calls verifying correct count(), sizeof(), and strlen() return types after scope merging and subsequent variable narrowing.
  • All existing count-related tests continue to pass.
  • Full test suite (11855 tests), PHPStan self-analysis, and coding standards all pass.

Fixes phpstan/phpstan#13750

…d types in `MutatingScope::resolveType()`

- When a FuncCall expression (e.g. `count($arr)`) has a stored expression
  type from a prior condition, `resolveType()` now also computes the
  dynamic type via the ExprHandler and returns the intersection of both.
- This prevents stale stored types from overriding the correct type when
  function arguments have been narrowed after scope merging.
- The bug occurred when `count($arr)` was used in a comparison (e.g.
  `count($arr) > 2`), storing `count($arr) = int<0, max>` after merge,
  and then `$arr` was narrowed to non-empty by a different condition.
- The fix also covers analogous cases: `sizeof()`, `strlen()`,
  `mb_strlen()`, and any other FuncCall with stored expression types.
Comment thread src/Analyser/MutatingScope.php Outdated
Comment on lines +976 to +994
$storedType = $this->expressionTypes[$exprString]->getType();

if ($node instanceof FuncCall) {
// Stored expression types for function calls can become stale
// when arguments are narrowed after scope merging.
// Intersect with the dynamically computed type to stay correct.
$this->resolvedTypes[$exprString] = $storedType;
foreach ($this->container->getServicesByTag(ExprHandler::EXTENSION_TAG) as $exprHandler) {
if (!$exprHandler->supports($node)) {
continue;
}
$dynamicType = $exprHandler->resolveType($this, $node);
unset($this->resolvedTypes[$exprString]);
return TypeCombinator::intersect($storedType, $dynamicType);
}
unset($this->resolvedTypes[$exprString]);
}

return $storedType;
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.

instead of re-calculating types at type-resolving time, couldn't we make sure expressionTypes are properly updated when evaluating the AST?

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 a summary of the changes made to address staabm's review:

Review feedback: Instead of re-calculating types at type-resolving time, update expressionTypes during AST evaluation.

What changed:

  1. Reverted resolveType() back to its original behavior - no more special FuncCall handling at type resolution time.

  2. Added FuncCall type updating in filterBySpecifiedTypes() - when type narrowing is applied from conditions (e.g., $arr !== [] narrows $arr), the method now detects stored FuncCall expression types whose arguments were actually narrowed and updates them by intersecting the stored type with the freshly computed dynamic type.

Key design decisions:

  • Intersection, not invalidation: Simply removing stale FuncCall types would lose precision from conditions on the function result (e.g., count($arr) === 3 gives 3, not int<1, max>). Intersecting preserves narrowing from both sources.
  • Type change guard: Only updates FuncCall entries when the argument's type actually changed, avoiding unnecessary recomputation when narrowing is redundant.
  • Skip specified/conditional entries: FuncCall expressions that were directly specified or updated by conditional expressions are left alone.

Test results: All 11,855 tests pass, PHPStan self-analysis passes with no errors.

Address review feedback: instead of re-calculating FuncCall types at
type-resolving time, update stored expression types during AST
evaluation when their arguments are narrowed.

In filterBySpecifiedTypes(), after processing type specifications and
conditional expressions, detect FuncCall entries whose arguments' types
actually changed. For each, temporarily remove the stored type, compute
the dynamic type from the narrowed arguments, then store the
intersection. This preserves narrowing from both sources: conditions on
the function result (e.g. count($arr) === 3) and conditions on the
arguments (e.g. $arr !== []).

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.

2 participants