Skip to content

Record conditional definedness guards when merging if branches with a side-effect-free condition#5875

Closed
phpstan-bot wants to merge 1 commit into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-2nlu4bx
Closed

Record conditional definedness guards when merging if branches with a side-effect-free condition#5875
phpstan-bot wants to merge 1 commit into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-2nlu4bx

Conversation

@phpstan-bot

Copy link
Copy Markdown
Collaborator

Summary

When the same condition is used in two separate if blocks and the first one defines a variable, PHPStan wrongly reported "Variable might not be defined" in the second block:

if (isset($elements['a'])) {
    $a = 1;
}
if (isset($elements['a'])) {
    echo $a; // Variable $a might not be defined.
}

The same happened with $_GET['something'] == 'banana', $s === 'banana', etc. This PR makes PHPStan remember that a variable defined inside an if is defined again whenever the same deterministic condition holds, so the second block no longer reports a false positive.

Changes

  • src/Analyser/MutatingScope.php
    • New createConditionalDefinednessExpressions() that, when two branches are merged, records a ConditionalExpressionHolder for each variable defined only in one branch: "this variable is defined whenever these guards hold again".
    • Guards are the expressions the condition narrowed in that branch:
      • type guards: defined in both branches, but with a type that differs from the merged type (e.g. $s narrowed to 'banana', an array gaining a hasOffsetValue accessory, $x narrowed to 1);
      • existence guards: made certain (Yes) only in our branch (e.g. $a['x'] made certain by isset($a['x'])).
    • All guards for a variable are combined into a single holder, so reusing only part of an && condition later does not prove the variable defined. At least one type guard is required, which filters out spurious guards created when the other branch's scope tracks nothing for an expression (e.g. an untracked superglobal access in the == example).
    • mergeWith() gets a new optional bool $createConditionalDefinedness = false parameter that enables this pass (off by default, so all other merge sites are unaffected).
  • src/Analyser/NodeScopeResolver.php
    • The If_ handler passes createConditionalDefinedness = true to the branch merges only when the condition has no impure points and the statement has no elseif branches.

Root cause

PHPStan already tracked "conditional definedness" through the branch-merge guard machinery, but only when the discriminating guard was an expression with a disjoint type across branches and Yes certainty in both (e.g. the boolean variable in $b = isset(...); if ($b) {...}). For an inline isset($a['x']) the discriminator is the existence of $a['x'] (Yes in the truthy branch, absent in the falsey branch), which the existing createConditionalExpressions() could not use as a guard — and the array's hasOffset narrowing is a subtype of the falsey branch's plain array type, so it was rejected by the theirExprIsSuperTypeOfGuard skip added in #5848. As a result no conditional was recorded and the second if re-reported the variable as possibly undefined.

The fix adds a dedicated pass that accepts existence guards and subtype "type guards", combining all of them into one condition. Soundness relies on two gates:

  • combining all guards (so a later partial condition like if ($a) after if ($a && $b) does not match), and
  • only running for deterministic conditions (no impure points), so a non-deterministic condition such as rand(0, 1) === 1 && $x === 1 does not falsely prove definedness when only $x === 1 is reused.

Because it builds on the generic merge mechanism, the fix applies uniformly to isset(), array_key_exists(), instanceof, ===/!==, !== null, property comparisons, is_*() checks, and both if and if/else.

Test

tests/PHPStan/Analyser/nsrt/bug-7718.php uses assertVariableCertainty() to cover:

  • Positive (now Yes): isset() twice, === on a string twice, === on an int twice, isset with an else branch, array_key_exists() twice, instanceof twice, !== null twice.
  • Negative (correctly stays Maybe): reusing only part of an && condition (if ($x === 1 && $y === 2) then if ($x === 1)), a non-deterministic condition (rand(0, 1) === 1 && $x === 1), and a different condition ($s === 'a' then $s === 'b').

The test fails before the fix (the positive cases report Maybe) and passes after it. The full test suite (make tests), PHPStan self-analysis (make phpstan) and coding standards (make cs) all pass.

Fixes phpstan/phpstan#7718

… a side-effect-free condition

- Add `MutatingScope::createConditionalDefinednessExpressions()`, which records that a variable defined only in one `if` branch is defined again whenever the same branch condition holds. The condition is reconstructed from the expressions it narrowed: "type guards" (defined in both branches with a type differing from the merged type, e.g. `$s` narrowed to `'banana'`, or an array gaining a `hasOffset` accessory) and "existence guards" (made certain only in our branch, e.g. `$a['x']` via `isset()`).
- All guards are combined into a single `ConditionalExpressionHolder`, so reusing only part of an `&&` condition does not wrongly imply the variable is defined. At least one type guard is required so untracked superglobal accesses (empty other-branch scope) cannot create spurious guards.
- Gate the new behavior behind a `createConditionalDefinedness` flag on `mergeWith()`, set by the `if` handler only when the condition has no impure points and there are no elseif branches — a deterministic condition is what guarantees a later identical `if` picks the same branch. This keeps non-deterministic conditions (`rand() && $x === 1`) sound.
- Works uniformly for `isset()`, `array_key_exists()`, `instanceof`, `=== `, `!== null`, property comparisons, `is_*()`, etc., and for both `if`/`if-else`, because it builds on the generic branch-merge guard mechanism rather than special-casing functions.
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.

"Variable might not be defined" in second identical if block

2 participants