Skip to content

Run loop convergence at all nesting depths in NodeScopeResolver#5762

Open
phpstan-bot wants to merge 1 commit into
phpstan:2.1.xfrom
phpstan-bot:create-pull-request/patch-43ysfkk
Open

Run loop convergence at all nesting depths in NodeScopeResolver#5762
phpstan-bot wants to merge 1 commit into
phpstan:2.1.xfrom
phpstan-bot:create-pull-request/patch-43ysfkk

Conversation

@phpstan-bot
Copy link
Copy Markdown
Collaborator

Summary

When array-modifying functions like array_pop() or array_shift() were used inside a nested loop, PHPStan incorrectly narrowed the array type to array{} because the inner loop didn't converge during the outer loop's convergence analysis. This caused false positives like "Variable $text in empty() always exists and is always falsy."

The fix removes the if ($context->isTopLevel()) guard from loop convergence blocks so that nested loops always converge, while preserving $context->enterDeep() in the convergence body to suppress other top-level behaviors.

Changes

  • src/Analyser/NodeScopeResolver.php: Removed if ($context->isTopLevel()) guard from convergence blocks in all four loop types:
    • Foreach (was line 1453)
    • While (was line 1676)
    • Do-while (was line 1767)
    • For (was line 1885)
  • $context->enterDeep() is preserved unchanged in convergence body processing — this suppresses top-level-only behaviors like alwaysIterates/neverIterates detection and always-false-condition early exit for nested loops
  • Updated two existing test expectations that reflected the old less-precise analysis:
    • tests/PHPStan/Analyser/nsrt/generalize-scope-recursive.php: array{}|array{foo?: array}array{}|array{foo: array<array>} (more precise recursive type)
    • tests/PHPStan/Analyser/nsrt/bug-10438.php: array<string, array{}|array{string}|string>array<string, list<string>|string> (correctly generalizes to list)

Root cause

The convergence loop (do-while that iterates up to LOOP_SCOPE_ITERATIONS times to find stable types) was guarded by if ($context->isTopLevel()). The convergence body was processed with $context->enterDeep(), which set isTopLevel() to false. This caused nested loops to skip convergence entirely, getting only a single pass through their body.

For the reported bug: the outer while loop's convergence processed its body (containing the inner while loop) with enterDeep(). The inner while loop skipped convergence and executed only once, adding just 1 element to the array. Then array_pop() removed it, producing array{}. This incorrect type propagated to the outer loop's condition scope.

The fix decouples "run convergence" from "is top-level" by removing the guard. Convergence now runs at all depths, each bounded by LOOP_SCOPE_ITERATIONS (3). The enterDeep() in the body still suppresses other top-level behaviors, preventing regressions from nested loops getting full top-level treatment.

Test

  • Added tests/PHPStan/Rules/Variables/data/bug-10666.php with four variants covering all combinations: while+array_pop, while+array_shift, do-while+array_pop, for+array_pop — all inside an outer while loop with empty() check
  • Added tests/PHPStan/Rules/Variables/EmptyRuleTest::testBug10666() expecting no errors (false positive fix)
  • Added tests/PHPStan/Analyser/nsrt/bug-10666.php with assertType checks verifying correct list types after pop/shift in nested loops

Fixes phpstan/phpstan#10666

- Remove `if ($context->isTopLevel())` guard from convergence blocks
  in all four loop types (foreach, while, do-while, for) so that
  nested loops converge even when inside an outer loop's convergence
- Keep `$context->enterDeep()` in convergence body processing so that
  other top-level behaviors (alwaysIterates/neverIterates detection,
  early exit for always-false conditions) remain suppressed for nested
  loops
- Update two test expectations that reflected the old imprecise analysis:
  - generalize-scope-recursive.php: more precise recursive array type
  - bug-10438.php: list<string> instead of array{}|array{string}

Closes phpstan/phpstan#10666
@VincentLanglet VincentLanglet self-assigned this May 25, 2026
@VincentLanglet VincentLanglet requested a review from staabm May 25, 2026 22:05
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