Skip to content

Fix phpstan/phpstan#14001: loosing type in foreach analysis#5269

Closed
phpstan-bot wants to merge 1 commit intophpstan:2.1.xfrom
phpstan-bot:create-pull-request/patch-ghtx58u
Closed

Fix phpstan/phpstan#14001: loosing type in foreach analysis#5269
phpstan-bot wants to merge 1 commit intophpstan:2.1.xfrom
phpstan-bot:create-pull-request/patch-ghtx58u

Conversation

@phpstan-bot
Copy link
Collaborator

Summary

Fixes a bug where variable types were lost to mixed or *ERROR* inside foreach loops when using StatementResult::getScope() and calling mergeWith() on the result. The root cause was that StatementResult::getScope() returned the Scope interface, which doesn't have mergeWith() — only MutatingScope does. This caused the method call to return ErrorType, which poisoned the variable type across loop iterations.

Changes

  • src/Analyser/StatementResult.php: Changed constructor parameter and getScope() return type from Scope to MutatingScope
  • src/Analyser/StatementExitPoint.php: Same change — ScopeMutatingScope for constructor and getScope()
  • src/Node/ReturnStatement.php: Same change — ScopeMutatingScope for constructor and getScope()
  • src/Analyser/NodeScopeResolver.php:
    • Added ->toMutatingScope() on 4 ReturnStatement constructor calls where $scope comes from a Scope-typed callback parameter
    • Removed now-redundant ->toMutatingScope() calls on ReturnStatement::getScope() (lines ~845/849)
    • Fixed null check on $scope->getClassReflection() at line ~858, which was exposed by more precise type tracking

Root cause

StatementResult, StatementExitPoint, and ReturnStatement are public API classes (@api) that internally always hold MutatingScope instances but declared their types as Scope (the interface). Since mergeWith() is only defined on MutatingScope, calling it on the Scope return type produced ErrorType. In a foreach loop, this ErrorType (which extends MixedType) contaminated the variable type across convergence iterations, causing it to degrade to mixed or *ERROR*.

The fix narrows the stored/returned type to MutatingScope, which is a covariant (backwards-compatible) change for callers.

Test

Added tests/PHPStan/Analyser/nsrt/bug-14001.php — an NSRT test with two cases:

  1. Foreach with an early isAlwaysTerminating() + continue guard before the null check (matches the original issue's first playground sample)
  2. Foreach with just the null-check-and-assign pattern (matches the second playground sample)

Both assert that the variable type after the loop is MutatingScope|null instead of mixed or *ERROR*.

Fixes phpstan/phpstan#14001

…esult::getScope()

- Changed StatementResult, StatementExitPoint, and ReturnStatement to store
  and return MutatingScope instead of Scope, since mergeWith() is only on
  MutatingScope and calling it on Scope produced ErrorType, poisoning the
  variable type across foreach loop iterations
- Added toMutatingScope() calls in NodeScopeResolver where ReturnStatement
  is constructed from a Scope-typed callback parameter
- Fixed a null check on getClassReflection() in NodeScopeResolver that was
  exposed by the more precise type tracking
- Removed now-redundant toMutatingScope() calls on ReturnStatement::getScope()
- New regression test in tests/PHPStan/Analyser/nsrt/bug-14001.php
@staabm staabm force-pushed the create-pull-request/patch-ghtx58u branch 2 times, most recently from c64dfba to deaafc7 Compare March 21, 2026 12:27
@staabm staabm closed this Mar 21, 2026
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