🌐 US-Proxy
class="logged-out env-production page-responsive" style="word-wrap: break-word;" >
Skip to content

SpecifiedTypes::unionWith() drops alternative-form entries on key collision #15039

Description

@paulbalandan

Bug report

SpecifiedTypes::unionWith() discards one of two alternative-form entries when both sides of the merge constrain the same expression, so one conjunct of a composed && narrowing is silently lost.

Regression in 2.2.7. Bisected against the snippet below: 2.2.6 gives 0, 2.2.7 gives int<0, max>. Introduced by phpstan/phpstan-src#6133 ("Replace SpecifiedTypes::normalize() with symbolic alternative-form entries").

Cause

// SpecifiedTypes::unionWith()
$result->alternativeTypes = $this->alternativeTypes + $other->alternativeTypes;

Array + keeps the left operand on key collision, so when both sides carry an alternative-form entry for the same expression, the right one is dropped. intersectWith() has collectTerms() to reconcile constraints of differing kinds. unionWith() has no equivalent for alternative-vs-alternative.

In the snippet, both !(is_int($v) && $v < 0) and !(is_int($v) && $v >= 1) are falsey BooleanAnd merges, so each contributes an alternative-form entry keyed on $v, and the && between them unions the two.

Two notes for reproducing

is_int($v) is load-bearing, even though $v is already int. It is what makes each intersectWith() mix a sure-not (int) with a sure (int<0, max>), which is the only way an entry reaches alternativeTypes. Drop it and both conjuncts stay in sureTypes, where the merge is still exact and the result is correctly 0. The two function.alreadyNarrowedType errors in the snippet are expected for that reason.

The equivalent if (...) does not reproduce it. BooleanAndHandler::processExpr() builds the branch scope by calling filterByTruthyValue() sequentially per operand, so the merged SpecifiedTypes never reaches the scope. assert() consumes the merged SpecifiedTypes of the whole condition in one go. if (!(!(...))) reproduces it too.

Impact

Found in the PHPStan extension of nexusphp/assert, whose MethodTypeSpecifyingExtension calls specifyTypesInCondition() over a composite predicate accumulated across a chained assertion. For example Assert::that($v)->isInt()->not()->isNegativeInt()->isPositiveInt() builds is_int($v) && !(is_int($v) && $v < 0) && !(is_int($v) && $v >= 1) and now narrows to int<0, max> instead of 0. Any extension that specifies types over a composed && instead of letting the statement handler walk it is exposed to this.

Code snippet that reproduces the problem

https://phpstan.org/r/7f55afc0-fe43-4a1c-9867-095de2e947fb

Expected output

Line 12: Dumped type: 0

On 2.2.7 it reports Dumped type: int<0, max>, which is exactly what the first conjunct produces on its own. 2.2.6 reports 0.

The two function.alreadyNarrowedType errors on line 8 are correct and not part of the report.

Did PHPStan help you today? Did it make you happy in any way?

Very much. I maintain a chainable assertion library whose entire value proposition is that every runtime assertion is paired with an extension that narrows the type in the caller's scope, and that only exists because the type specifier is extensible enough to express it. Tracking this one down was a pleasant read through SpecifiedTypes rather than a slog.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions