Skip to content

Fall back to the full branch type for a conditional guard when the partial set difference still overlaps the other branch - #6830

Open
phpstan-bot wants to merge 1 commit into
phpstan:2.3.xfrom
phpstan-bot:create-pull-request/patch-kkwxkv9
Open

phpstan-bot wants to merge 1 commit into
phpstan:2.3.xfrom
phpstan-bot:create-pull-request/patch-kkwxkv9

Conversation

@phpstan-bot

Copy link
Copy Markdown
Collaborator

Summary

Since 2.3.0, a method call that runs on only one side of $title === null && $input->isInteractive() was remembered as true after a later if ($title === null) return;, as long as $title's types in the two branches only partly overlapped (e.g. string|null vs non-empty-string|null). This PR stops a guard type that still overlaps the other branch from being used for a conditional expression.

Changes

  • src/Analyser/ScopeOps.php (createConditionalExpressions()): if the set-difference remainder is not the full our-branch type and is not disjoint from the their-branch type, use the full our-branch type as the guard instead.
  • turbo-ext/src/ScopeOps.cpp: same change in the native mirror.
  • Probed variants: non-empty-string|null vs ?string (broken, fixed), non-empty-array|null vs ?array (broken, fixed), int<1, max>|null vs ?int (already correct, since the remainder int<min, 0> can be represented; kept as coverage).

Root cause

The guard type comes from TypeCombinator::remove($ours, $theirs). The code assumed one of two outcomes: an exact difference, or our full type unchanged when the difference can't be represented. There is a third outcome: a partial subtraction. Here remove(string|null, non-empty-string|null) gives string, which drops null but still overlaps non-empty-string. This string differs from the merged type string|null, so it was accepted as a guard, and the merge recorded "if $title is string then isInteractive() is true". Narrowing $title to non-null later triggered that conditional. Now a remainder that still overlaps their type falls back to the full type, and the merged-type comparison then drops the guard.

Test

tests/PHPStan/Analyser/nsrt/bug-15483.php contains the reproducer from the issue (f()), the is_string-only control (g()) and the parameter-based variants for non-empty-string, int range and non-empty-array. Without the fix, f(), h() and j() fail with true instead of bool. The full suite passes with and without the turbo extension loaded, and the turbo smoke test passes.

Fixes phpstan/phpstan#15483

…rtial set difference still overlaps the other branch

- ScopeOps::createConditionalExpressions() derives guard types from
  TypeCombinator::remove(ours, theirs). When remove() can only subtract part
  of their type (e.g. null from string|null, but not non-empty-string from
  string), the remainder (string) still overlaps the other branch, so seeing
  it later does not prove our branch ran. Such guards produced conditional
  expressions like "if $title=string then $input->isInteractive() is true".
  Now the guard falls back to our full type when the remainder is not
  disjoint from their type, which restores the merged-type comparison that
  was used before.
- Ported the same check to the native mirror in turbo-ext/src/ScopeOps.cpp.
- Variants also covered: plain ?string narrowed against non-empty-string|null,
  and ?array against non-empty-array|null (both were broken). The
  int<1, max>|null variant already worked, because int<min, 0> is
  representable; it is kept as coverage.
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.

Method call remembered as true after && when the other operand comes from an is_string($x) && $x !== '' ternary (regression in 2.3.0)

1 participant