Skip to content

Do not let conditional expressions unset variables with proven Yes certainty in filterBySpecifiedTypes#5475

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

Do not let conditional expressions unset variables with proven Yes certainty in filterBySpecifiedTypes#5475
phpstan-bot wants to merge 1 commit intophpstan:2.1.xfrom
phpstan-bot:create-pull-request/patch-m4wti3d

Conversation

@phpstan-bot
Copy link
Copy Markdown
Collaborator

Summary

When a variable's existence was proven via isset(), subsequent foreach loops (or if branches) that triggered conditional expression resolution could incorrectly unset the variable, causing a false positive "Variable might not be defined" error.

Changes

  • Modified src/Analyser/MutatingScope.php in filterBySpecifiedTypes(): when conditional expressions resolve to no() certainty, skip the unset if the variable already has yes() certainty in the current scope
  • Added regression test in tests/PHPStan/Rules/Variables/data/bug-14353.php covering the foreach case and the if branching case

Root cause

When filterBySpecifiedTypes() processes conditional expressions (lines ~3263-3286), expressions whose conditional certainty resolves to no() are completely removed from expressionTypes via unset(). This is correct for variables whose certainty is maybe() or no(), but incorrect for variables with yes() certainty — the yes() certainty represents a proven fact (from isset(), direct assignment, etc.) that should not be overridden by conditional reasoning.

The conditional expressions are created during scope merges (e.g., after foreach loops where a variable is assigned conditionally). They encode relationships like "if the iterable is empty, this variable doesn't exist." After isset() confirms the variable exists, these conditional expressions become stale but remain on the scope. When a later type narrowing (like $reports === [] in a foreach zero-iterations merge or an if branch) triggers the guard condition, the conditional expression fires and incorrectly removes the variable.

Analogous cases probed:

  • while loops: not affected (the condition-based merge doesn't trigger the same conditional expression pattern)
  • if/else branching on the iterable: was affected and is now fixed by the same change, covered by the second test case
  • !isset() (falsy branch): not affected (uses unsetExpression directly, not conditional expressions)
  • Variables without isset() guard: not affected (they have maybe() certainty, not yes(), so the fix doesn't apply)

Test

  • testBug14353 in DefinedVariableRuleTest: expects no errors for the original reproduction case (foreach after isset) and for the if-branch case (branching on $reports === [] after isset)

Fixes phpstan/phpstan#14353

…rtainty in `filterBySpecifiedTypes`

- In `MutatingScope::filterBySpecifiedTypes()`, when conditional expressions resolve to
  `no()` certainty for a variable, skip the unset if the variable already has `yes()`
  certainty in the current scope.
- This prevents stale conditional expressions (created during earlier scope merges)
  from overriding definite variable existence proven by `isset()` or direct assignment.
- The bug manifested when a `foreach` loop's zero-iterations scope merge triggered
  conditional expressions linking a variable's existence to the iterable being non-empty,
  even though `isset()` had already proven the variable was defined.
- The same bug also affected `if` branches that narrowed the iterable type to empty.
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.

1 participant