Skip to content

Skip boolean conditional holders whose target is an operand of the condition-side arm with an independent sibling operand#6060

Open
phpstan-bot wants to merge 1 commit into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-5ycaiig
Open

Skip boolean conditional holders whose target is an operand of the condition-side arm with an independent sibling operand#6060
phpstan-bot wants to merge 1 commit into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-5ycaiig

Conversation

@phpstan-bot

Copy link
Copy Markdown
Collaborator

Summary

$needle !== null && in_array($needle, $haystack) wrongly narrowed $needle to
null in a later elseif/if branch, producing a false
notIdentical.alwaysFalse (and identical.alwaysTrue) on a subsequent
$needle !== null / $needle === null check. This regressed in 2.2.3 as part of
the #14807 / #14908 boolean-conditional-holder work.

Reaching the else branch of !(A && B) does not imply $needle is null: the
&& is also false when $needle is a non-null string that simply is not in
$haystack. This fix stops the unsound narrowing.

Changes

  • src/Analyser/ExprHandler/BooleanAndHandler.php: pass the condition-side arm
    expression (the opposite arm from the holder side) into each
    processBooleanConditionalTypes() call.
  • src/Analyser/ExprHandler/Helper/ConditionalExpressionHolderHelper.php:
    • accept the new ?Expr $conditionSideExpr parameter and inject ExprPrinter;
    • collect the trackable operands of the condition-side arm and, when building a
      holder, skip a target that is an operand of that arm if the arm also narrows
      an independent sibling operand (via conditionSideHasIndependentOperand()
      / collectExpressionStrings()).
  • Probed the analogous BooleanOr true-context path
    (src/Analyser/ExprHandler/BooleanOrHandler.php): it is merely conservative
    there (misses some narrowing) and never produces a false positive for this
    shape, so it was intentionally left unchanged.

Root cause

The BooleanAnd false-context builds conditional expression holders such as
"if in_array($needle, $haystack) is true then $needle === null". The arm's
truth cannot be represented directly, so it is decomposed into per-expression
narrowings of the arm's truthy specification. For in_array() that truthy
narrowing carries two operands: $needle (the value) and $haystack (being
non-empty). When the holder for target $needle drops $needle's own
self-condition, only the sibling $haystack: non-empty condition survived — and
$haystack being non-empty is necessary but not sufficient for the arm to be
true. The holder {$haystack: non-empty} ⟹ {$needle: null} therefore fired
whenever $haystack was later narrowed to non-empty (e.g. elseif ($haystack !== [])),
collapsing $needle to null.

The fix identifies exactly this situation: the target is an operand of the
condition-side arm and the surviving conditions come from a structurally
independent sibling operand. Cases where the sibling refers to the same access
path — e.g. isset($data['k']) narrowing both the container $data and the
element $data['k'] — are kept, because the container narrowing genuinely
captures the arm's truth.

Test

  • tests/PHPStan/Analyser/nsrt/bug-14966.php — the reported reproducer plus
    variants: plain array haystack, an int needle, and the strict
    in_array(..., true) form (which shares the same root cause and is also
    fixed). Each asserts the needle stays string|null / int|null in the branch
    reached when the && is false. All assertions fail before the fix (needle is
    wrongly null) and pass after it.
  • Existing isset() / array_key_exists() conditional-holder regression tests
    (bug-10644, bug-14874, bug-6202, negated-boolean-and-conditional-holders,
    boolean-and-conditional-holders-mixed-context, bug-14828) continue to pass,
    confirming the sound narrowings are preserved.

Fixes phpstan/phpstan#14966

…ndition-side arm with an independent sibling operand

- Pass the condition-side arm expression into
  `ConditionalExpressionHolderHelper::processBooleanConditionalTypes()` from
  `BooleanAndHandler` so the helper can tell which expressions the arm's truth
  depends on.
- When building a `BooleanAnd` false-context conditional holder, skip a target
  that is an operand of the condition-side arm whenever that arm also narrows an
  independent sibling operand (neither an ancestor nor a descendant of the
  target). Example: for `$needle !== null && in_array($needle, $haystack)`, the
  arm's truthy narrowing carries `$haystack` being non-empty, which is necessary
  but not sufficient for `in_array()` to be true — firing `$needle === null` from
  `$haystack` being non-empty alone is unsound.
- The existing sound narrowings where the sibling operand refers to the same
  access path (e.g. `isset($data['k'])` narrowing both `$data` and `$data['k']`)
  are preserved, because such an operand does capture the arm's truth.
- Probed the `BooleanOr` true-context mirror: it only under-narrows there (no
  false positive), so no change was needed in `BooleanOrHandler`.
@VincentLanglet

Copy link
Copy Markdown
Contributor

Will be fixed by #5983 I think

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants