Skip to content

Re-check scalar types after integer range expansion in TypeCombinator::union#5660

Merged
staabm merged 5 commits into
phpstan:2.1.xfrom
phpstan-bot:create-pull-request/patch-0mbssc0
May 14, 2026
Merged

Re-check scalar types after integer range expansion in TypeCombinator::union#5660
staabm merged 5 commits into
phpstan:2.1.xfrom
phpstan-bot:create-pull-request/patch-0mbssc0

Conversation

@phpstan-bot
Copy link
Copy Markdown
Collaborator

Summary

  • Fixed order-dependent bug in TypeCombinator::union() where the scalar processing loop did not re-check previously-processed scalar items after an IntegerRangeType expanded by absorbing a ConstantIntegerType. For example, union(0|int<2,3>, 1) incorrectly produced 0|int<1,3> (a UnionType) instead of int<0,3> (a single IntegerRangeType).
  • This incomplete union caused ExpressionTypeHolder::equalTypes() to return false during scope merging in MutatingScope::createConditionalExpressions(), which created spurious conditional type guards. These guards incorrectly narrowed superglobal types (e.g., $_SESSION) in branches guarded by comparisons like $value == 0, leading to false positive "expression is always true" reports from IssetCheck.
  • The fix restarts the inner scalar loop ($j = -1) when a scalar is absorbed into a range, ensuring all remaining scalar items are tested against the newly expanded range.

Test plan

  • New TypeCombinatorTest case verifies union(0|int<2,3>, 1) produces int<0, 3>
  • New IssetRuleTest::testBug14610() verifies no false positives for isset(), ??, and empty() on superglobal keys after conditional variable assignment
  • New NSRT test bug-14610.php verifies correct type inference: $_SESSION stays array<mixed> (not narrowed to hasOffsetValue) inside if ($value == 0) branch
  • make tests passes (12066 tests, 27563 assertions)
  • make phpstan passes with no errors
  • make cs-fix passes with no violations

Closes phpstan/phpstan#14610

🤖 Generated with Claude Code

if ($a !== null) {
$types[$i] = $a;
array_splice($scalarTypeItems, $j--, 1);
array_splice($scalarTypeItems, $j, 1);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we need the same fix for the if ($b !== null) { block below?
if so, come up with tests currently failling and fix it

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All tests pass (12066 tests, 79688 assertions) and PHPStan reports no errors.

Here's my analysis for the reviewer's question:

The $b !== null branch does NOT need the same fix. The issue in the $a !== null branch occurs because an IntegerRangeType in $types[$i] expands by absorbing a ConstantIntegerType scalar, and previously-checked scalars might now be adjacent to the expanded range. The $b !== null branch has the inverse relationship — the scalar $scalarTypeItems[$j] is supposed to absorb $types[$i]. But this path can never trigger the expanding-range scenario because:

  1. $scalarTypeItems contains only constant scalar types (ConstantIntegerType, ConstantStringType, etc.) — never IntegerRangeType, since IntegerRangeType::isConstantScalarValue() returns no and these are separated into $integerRangeTypes then merged back into $types.

  2. The [null, $b] return from compareTypesInUnion for the IntegerRangeType path (line 517-522) requires $b instanceof IntegerRangeType, which is never true for a scalar item.

  3. The other [null, $b] paths (SubtractableType, $b->isSuperTypeOf($a)) either don't apply to constant scalar types or return $b unchanged — the scalar doesn't grow.

  4. Since the scalar never expands, there's no need to re-check earlier $types entries.

I was unable to construct a test case that fails due to the $b !== null branch lacking a restart, which confirms the analysis.

No code changes are needed. Tests and static analysis both pass.

Copy link
Copy Markdown
Contributor

@VincentLanglet VincentLanglet left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Explain why it only occurs for superglobal and not

/** @param array<mixed> $a */
function test($a): void
{
    $value = 0;

    if (isset($a['test'])) {
        $value = rand(0,3);
        if ($value == 1) {
        }
    }

    if ($value == 0) {
        $result = isset($a['test']);
    }
}

@phpstan-bot
Copy link
Copy Markdown
Collaborator Author

Pushed. Here's the explanation for VincentLanglet's review question:

Why the bug only affects superglobals, not array<mixed> parameters:

The root cause is how superglobals vs parameters are tracked in MutatingScope::expressionTypes:

  1. Parameters ($a) are always present in expressionTypes in both branches of the isset() check — they're set up when entering the function scope. So during scope merging in createConditionalExpressions(), $a exists in theirExpressionTypes (the false branch), and its merged type (array<mixed>) equals the false-branch type (array<mixed>). This causes $a to be removed from newVariableTypes at line 3705, so it never becomes a type guard and no conditional expression links $a's narrowed type to $value.

  2. Superglobals ($_SESSION) are NOT stored in expressionTypes by default — they use lazy default types via getVariableType() (line 679-680). When isset($_SESSION['test']) narrows $_SESSION, it gets added to expressionTypes only in the true branch. In the false branch, $_SESSION is absent from expressionTypes. During scope merging, $_SESSION is never iterated in the false-branch loop, so it stays in newVariableTypes and becomes a type guard. This creates a conditional expression: "when $value matches 0|int<1,3>, then $_SESSION has hasOffsetValue."

  3. The TypeCombinator bug then makes $value a type guard too, because its true-branch type 0|int<1,3> is structurally different from the merged type int<0,3> (despite being semantically equivalent). With the fix, these are identical, so $value is NOT a type guard, and no conditional expression can fire.

I added the reviewer's array<mixed> parameter test case to both test files to demonstrate this difference explicitly.

Copy link
Copy Markdown
Contributor

@VincentLanglet VincentLanglet left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove testWithParam and merge the two bug-14610 files into a single one

@phpstan-bot
Copy link
Copy Markdown
Collaborator Author

Done. The changes:

  • Removed testWithParam functions from both test files
  • Merged the two bug-14610.php files into a single file at tests/PHPStan/Analyser/nsrt/bug-14610.php (combining the assertType calls with the isset rule test scenarios)
  • Deleted tests/PHPStan/Rules/Variables/data/bug-14610.php
  • Updated IssetRuleTest to reference the nsrt file path
  • All 12066 tests pass and make phpstan reports no errors

@staabm
Copy link
Copy Markdown
Contributor

staabm commented May 14, 2026

3. The TypeCombinator bug then makes $value a type guard too, because its true-branch type 0|int<1,3> is structurally different from the merged type int<0,3>

@phpstan-bot where does this structured comparison happen? why does check not realize 0|int<1,3> and int<0,3> are the same thing?

@VincentLanglet
Copy link
Copy Markdown
Contributor

  1. The TypeCombinator bug then makes $value a type guard too, because its true-branch type 0|int<1,3> is structurally different from the merged type int<0,3>

@phpstan-bot where does this structured comparison happen? why does check not realize 0|int<1,3> and int<0,3> are the same thing?

In the same way I assume than int<0,1> and 0|1 are not equals ; that might creates issues one day.
But I think the fix proposed is still an ok improvement

Comment on lines +1645 to +1666
[
[
new UnionType([
new ConstantIntegerType(0),
IntegerRangeType::fromInterval(2, 3),
]),
new ConstantIntegerType(1),
],
IntegerRangeType::class,
'int<0, 3>',
],
[
[
new ConstantIntegerType(1),
new UnionType([
new ConstantIntegerType(0),
IntegerRangeType::fromInterval(2, 3),
]),
],
IntegerRangeType::class,
'int<0, 3>',
],
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think these tests make clear that a fix in TypeCombinator is valid (and the super-global example is just a "bad example" which is fixed by it at the same time)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All 12,068 tests pass (79,692 assertions) and make phpstan reports no errors.

The reviewer's comment is positive feedback — they're agreeing that the TypeCombinator unit tests clearly demonstrate the fix is valid on their own, independent of the superglobal scenario. No code changes are needed to address this comment.

@staabm staabm requested a review from VincentLanglet May 14, 2026 09:46
phpstan-bot and others added 4 commits May 14, 2026 11:52
…::union

When an IntegerRangeType absorbs a ConstantIntegerType and expands
(e.g., int<2,3> absorbs 1 to become int<1,3>), previously-checked
scalar items that were disjoint with the old range but adjacent to
the new range were never re-checked. This caused union(0|int<2,3>, 1)
to produce 0|int<1,3> instead of int<0,3>.

The incomplete union caused ExpressionTypeHolder::equalTypes() to
return false during scope merging, creating spurious conditional
expressions that incorrectly narrowed superglobal types in branches
guarded by comparisons on a conditionally-assigned variable.

Fix: restart the inner scalar loop when a range expands, so all
remaining scalars are re-tested against the updated range.
…c behavior

The bug only affects superglobals because they are not tracked in
expressionTypes by default (they use lazy default types via
getVariableType()). This means during scope merging in
createConditionalExpressions(), the parameter $a exists in both
branches' expressionTypes and gets correctly filtered out, while
$_SESSION only exists in the isset-true branch, causing it to become
a spurious type guard.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…file

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@staabm staabm force-pushed the create-pull-request/patch-0mbssc0 branch from 75800f8 to e65d557 Compare May 14, 2026 09:52
@staabm staabm merged commit f8dd187 into phpstan:2.1.x May 14, 2026
654 of 659 checks passed
@staabm staabm deleted the create-pull-request/patch-0mbssc0 branch May 14, 2026 10:15
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.

3 participants