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.
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 givesint<0, max>. Introduced by phpstan/phpstan-src#6133 ("ReplaceSpecifiedTypes::normalize()with symbolic alternative-form entries").Cause
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()hascollectTerms()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 falseyBooleanAndmerges, 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$vis alreadyint. It is what makes eachintersectWith()mix a sure-not (int) with a sure (int<0, max>), which is the only way an entry reachesalternativeTypes. Drop it and both conjuncts stay insureTypes, where the merge is still exact and the result is correctly0. The twofunction.alreadyNarrowedTypeerrors in the snippet are expected for that reason.The equivalent
if (...)does not reproduce it.BooleanAndHandler::processExpr()builds the branch scope by callingfilterByTruthyValue()sequentially per operand, so the mergedSpecifiedTypesnever reaches the scope.assert()consumes the mergedSpecifiedTypesof the whole condition in one go.if (!(!(...)))reproduces it too.Impact
Found in the PHPStan extension of nexusphp/assert, whose
MethodTypeSpecifyingExtensioncallsspecifyTypesInCondition()over a composite predicate accumulated across a chained assertion. For exampleAssert::that($v)->isInt()->not()->isNegativeInt()->isPositiveInt()buildsis_int($v) && !(is_int($v) && $v < 0) && !(is_int($v) && $v >= 1)and now narrows toint<0, max>instead of0. 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: 0On 2.2.7 it reports
Dumped type: int<0, max>, which is exactly what the first conjunct produces on its own. 2.2.6 reports0.The two
function.alreadyNarrowedTypeerrors 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
SpecifiedTypesrather than a slog.