From ca0009482d47d979141bc435e1704f771e21df6e Mon Sep 17 00:00:00 2001 From: VincentLanglet <9052536+VincentLanglet@users.noreply.github.com> Date: Wed, 4 Nov 2026 20:58:01 +0000 Subject: [PATCH 1/4] Treat `match (true)` as exhaustive when the arms' falsey narrowing leaves an expression typed `never` - MatchHandler: for a constant-boolean subject (`match (true)` / `match (false)`) with no default arm and no always-true condition, check the scope left after every arm's falsey narrowing. If an expression that still had a possible value before the arms is now certainly `never` (a variable, a property fetch, ...), no arm failing to match is impossible, so the match is exhaustive. Before this, exhaustiveness relied on the last arm's condition being typed constant `true`. That works for `is_float()` (its stub has a conditional return type) but not for functions/methods that only declare `@phpstan-assert-if-true`. - Mirrored the new hasExpressionNarrowedToNever() in turbo-ext/src/MatchHandler.cpp. - Analogous cases covered by the same fix and tested: instance methods, static methods and functions with `@phpstan-assert-if-true`, mixing them with `is_*()`, property fetch subjects, array offset subjects, multi-condition arms and `match (false)`. Non-exhaustive cases (`object` with `instanceof` arms, a union with a member no arm covers) are still reported. --- src/Analyser/ExprHandler/MatchHandler.php | 27 ++- .../Comparison/MatchExpressionRuleTest.php | 44 +++++ .../Rules/Comparison/data/bug-11374.php | 170 ++++++++++++++++++ turbo-ext/src/MatchHandler.cpp | 72 +++++++- 4 files changed, 311 insertions(+), 2 deletions(-) create mode 100644 tests/PHPStan/Rules/Comparison/data/bug-11374.php diff --git a/src/Analyser/ExprHandler/MatchHandler.php b/src/Analyser/ExprHandler/MatchHandler.php index 546a7187a15..e8fb347aa52 100644 --- a/src/Analyser/ExprHandler/MatchHandler.php +++ b/src/Analyser/ExprHandler/MatchHandler.php @@ -468,7 +468,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex } if (!$hasDefaultCond && !$hasAlwaysTrueCond && $condType->isBoolean()->yes() && $condType->isConstantScalarValue()->yes()) { - if ($this->isScopeConditionallyImpossible($matchScope)) { + if ($this->hasExpressionNarrowedToNever($scope, $matchScope) || $this->isScopeConditionallyImpossible($matchScope)) { $hasAlwaysTrueCond = true; $matchScope = $matchScope->addTypeToExpression($expr->cond, new NeverType()); } @@ -605,6 +605,31 @@ private function getFilteringExprForMatchArm(Match_ $expr, array $conditions, ar ); } + /** + * Whether the arms' falsey narrowing left an expression (a variable, a + * property fetch, ...) without any possible value - then no arm being + * matched is impossible. + */ + private function hasExpressionNarrowedToNever(MutatingScope $scopeBeforeArms, MutatingScope $matchScope): bool + { + foreach ($matchScope->expressionTypes as $exprString => $holder) { + if (!$holder->getCertainty()->yes()) { + continue; + } + if (!$holder->getType() instanceof NeverType) { + continue; + } + $holderBeforeArms = $scopeBeforeArms->expressionTypes[$exprString] ?? null; + if ($holderBeforeArms !== null && $holderBeforeArms->getType() instanceof NeverType) { + continue; + } + + return true; + } + + return false; + } + private function isScopeConditionallyImpossible(MutatingScope $scope): bool { $boolVars = []; diff --git a/tests/PHPStan/Rules/Comparison/MatchExpressionRuleTest.php b/tests/PHPStan/Rules/Comparison/MatchExpressionRuleTest.php index c58aa7710dc..133d7487937 100644 --- a/tests/PHPStan/Rules/Comparison/MatchExpressionRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/MatchExpressionRuleTest.php @@ -601,4 +601,48 @@ public function testBug10583(): void ]); } + #[RequiresPhp('>= 8.0.0')] + public function testBug11374(): void + { + $this->treatPhpDocTypesAsCertain = true; + $this->analyse([__DIR__ . '/data/bug-11374.php'], [ + [ + 'Call to function is_float() with float will always evaluate to true.', + 13, + ], + [ + 'Call to method Bug11374\\HelloWorld::isFloat() with float will always evaluate to true.', + 21, + ], + [ + 'Call to method Bug11374\\HelloWorld::isFloat() with float will always evaluate to true.', + 29, + ], + [ + 'Call to function is_float() with float will always evaluate to true.', + 37, + ], + [ + 'Call to static method Bug11374\\Analogous::isFloatStatic() with float will always evaluate to true.', + 75, + ], + [ + 'Call to function isFloatFunction() with float will always evaluate to true.', + 83, + ], + [ + 'Call to static method Bug11374\\Analogous::isFloatStatic() with float will always evaluate to true.', + 110, + ], + [ + 'Match expression does not handle remaining value: true', + 124, + ], + [ + 'Match expression does not handle remaining value: true', + 132, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Comparison/data/bug-11374.php b/tests/PHPStan/Rules/Comparison/data/bug-11374.php new file mode 100644 index 00000000000..c7028525028 --- /dev/null +++ b/tests/PHPStan/Rules/Comparison/data/bug-11374.php @@ -0,0 +1,170 @@ += 8.0 + +declare(strict_types = 1); + +namespace Bug11374; + +class HelloWorld +{ + public function test(float|int $value): string + { + return match (true) { + is_int($value) => (string) $value, + is_float($value) => (string) $value, + }; + } + + public function test2(float|int $value): string + { + return match (true) { + $this->isInt($value) => (string) $value, + $this->isFloat($value) => (string) $value, + }; + } + + public function test3(float|int $value): string + { + return match (true) { + is_int($value) => (string) $value, + $this->isFloat($value) => (string) $value, + }; + } + + public function test4(float|int $value): string + { + return match (true) { + $this->isInt($value) => (string) $value, + is_float($value) => (string) $value, + }; + } + + /** + * @phpstan-assert-if-true float $value + */ + private function isFloat(mixed $value): bool + { + return is_float($value); + } + + /** + * @phpstan-assert-if-true int $value + */ + private function isInt(mixed $value): bool + { + return is_int($value); + } +} + +class A {} +class B {} + +final class Analogous +{ + + private int|float $prop = 1; + + public function setProp(int|float $prop): void + { + $this->prop = $prop; + } + + public function staticCall(float|int $value): string + { + return match (true) { + self::isIntStatic($value) => (string) $value, + self::isFloatStatic($value) => (string) $value, + }; + } + + public function functionCall(float|int $value): string + { + return match (true) { + isIntFunction($value) => (string) $value, + isFloatFunction($value) => (string) $value, + }; + } + + public function propertyFetch(): string + { + return match (true) { + self::isIntStatic($this->prop) => (string) $this->prop, + self::isFloatStatic($this->prop) => (string) $this->prop, + }; + } + + /** + * @param array{x: int|float} $a + */ + public function arrayOffset(array $a): string + { + return match (true) { + self::isIntStatic($a['x']) => 'int', + self::isFloatStatic($a['x']) => 'float', + }; + } + + public function multipleConditions(float|int|string $value): string + { + return match (true) { + self::isIntStatic($value), is_string($value) => 'int or string', + self::isFloatStatic($value) => 'float', + }; + } + + public function instanceofUnion(A|B $obj): string + { + return match (true) { + $obj instanceof A => 'a', + $obj instanceof B => 'b', + }; + } + + public function notExhaustiveObject(object $obj): string + { + return match (true) { + $obj instanceof A => 'a', + $obj instanceof B => 'b', + }; + } + + public function notExhaustiveAssert(float|int|string $value): string + { + return match (true) { + self::isIntStatic($value) => 'int', + self::isFloatStatic($value) => 'float', + }; + } + + /** + * @phpstan-assert-if-true float $value + */ + private static function isFloatStatic(mixed $value): bool + { + return is_float($value); + } + + /** + * @phpstan-assert-if-true int $value + */ + private static function isIntStatic(mixed $value): bool + { + return is_int($value); + } + +} + +/** + * @phpstan-assert-if-true float $value + */ +function isFloatFunction(mixed $value): bool +{ + return is_float($value); +} + +/** + * @phpstan-assert-if-true int $value + */ +function isIntFunction(mixed $value): bool +{ + return is_int($value); +} diff --git a/turbo-ext/src/MatchHandler.cpp b/turbo-ext/src/MatchHandler.cpp index 681a7674a1a..c27aabaa0ae 100644 --- a/turbo-ext/src/MatchHandler.cpp +++ b/turbo-ext/src/MatchHandler.cpp @@ -627,7 +627,8 @@ class MatchHandler if (UNEXPECTED(isConstant < 0)) return zv::Val(); if (isConstant == PT_TRI_YES) { bool impossible; - if (UNEXPECTED(!isScopeConditionallyImpossible(matchScope.raw(), impossible))) return zv::Val(); + if (UNEXPECTED(!hasExpressionNarrowedToNever(scope.raw(), matchScope.raw(), impossible))) return zv::Val(); + if (!impossible && UNEXPECTED(!isScopeConditionallyImpossible(matchScope.raw(), impossible))) return zv::Val(); if (impossible) { hasAlwaysTrueCond = true; zval never; @@ -1265,6 +1266,75 @@ class MatchHandler return pt_type_new(PT_CLASS_FUNC_CALL, 2, callArgv); } + /* $scope->expressionTypes - a public property read by name: rare path, + * and the twin's slot layout need not be assumed; NULL with an Error + * pending when it is missing */ + static HashTable *scopeExpressionTypes(zval *scope) + { + if (UNEXPECTED(Z_TYPE_P(scope) != IS_OBJECT)) { + zend_throw_error(NULL, "Attempt to read property \"expressionTypes\" on %s", zend_zval_value_name(scope)); + return NULL; + } + zv::Ref value = zv::ObjRef(Z_OBJ_P(scope)).prop(PT_LC("expressionTypes")); + if (UNEXPECTED(value.raw() == NULL || !value.deref().isArray())) { + zend_throw_error(NULL, "phpstan_turbo: %s has no array property $expressionTypes", ZSTR_VAL(Z_OBJCE_P(scope)->name)); + return NULL; + } + return Z_ARRVAL_P(value.deref().raw()); + } + + /* $holder->getType() of an ExpressionTypeHolder: the native holder's + * slot, the method of anything else */ + static zv::Val holderType(zval *holder) + { + if (EXPECTED(Z_OBJCE_P(holder) == pt_ce_expr_type_holder)) return zv::Val::copyOf(zv::Ref(OBJ_PROP_NUM(Z_OBJ_P(holder), PT_ETH_PROP_TYPE))); + return pt_type_call(Z_OBJ_P(holder), PT_LC("gettype"), 0, NULL); + } + + /* Mirrors the private hasExpressionNarrowedToNever(); false = pending + * exception */ + [[nodiscard]] static bool hasExpressionNarrowedToNever(zval *scopeBeforeArms, zval *matchScope, bool &out) + { + out = false; + HashTable *expressionTypes = scopeExpressionTypes(matchScope); + if (UNEXPECTED(expressionTypes == NULL)) return false; + HashTable *expressionTypesBeforeArms = scopeExpressionTypes(scopeBeforeArms); + if (UNEXPECTED(expressionTypesBeforeArms == NULL)) return false; + for (zv::ArrayEntry entry : zv::TableRef(expressionTypes)) { + zval *holder = entry.value().deref().raw(); + if (UNEXPECTED(Z_TYPE_P(holder) != IS_OBJECT)) return !callOnNonObject("getCertainty", holder).isUndef(); + zend_long certainty; + if (EXPECTED(Z_OBJCE_P(holder) == pt_ce_expr_type_holder)) { + certainty = pt_holder_certainty_value(Z_OBJ_P(holder)); + } else { + zv::Val certaintyObject = pt_type_call(Z_OBJ_P(holder), PT_LC("getcertainty"), 0, NULL); + if (UNEXPECTED(certaintyObject.isUndef())) return false; + certainty = pt_type_trinary_value(certaintyObject.raw()); + if (UNEXPECTED(certainty < 0)) return false; + } + if (certainty != PT_TRI_YES) continue; + zv::Val type = holderType(holder); + if (UNEXPECTED(type.isUndef())) return false; + if (!type.ref().instanceOf(pt_ce_never_type)) continue; + + zval *holderBeforeArms = entry.hasStringKey() + ? zend_symtable_find(expressionTypesBeforeArms, entry.stringKey()) + : zend_hash_index_find(expressionTypesBeforeArms, entry.indexKey()); + if (holderBeforeArms != NULL) { + ZVAL_DEREF(holderBeforeArms); + if (UNEXPECTED(Z_TYPE_P(holderBeforeArms) != IS_OBJECT)) return !callOnNonObject("getType", holderBeforeArms).isUndef(); + zv::Val typeBeforeArms = holderType(holderBeforeArms); + if (UNEXPECTED(typeBeforeArms.isUndef())) return false; + if (typeBeforeArms.ref().instanceOf(pt_ce_never_type)) continue; + } + + out = true; + return true; + } + + return true; + } + /* Mirrors the private isScopeConditionallyImpossible(); false = pending * exception */ [[nodiscard]] bool isScopeConditionallyImpossible(zval *scope, bool &out) const From d6dee687bc43cd75c3bf4e4478f133fd77049a96 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Wed, 4 Nov 2026 21:30:33 +0000 Subject: [PATCH 2/4] Cover `match (false)` with assert-if-false and asserts on `$this->prop` in bug-11374 test Co-Authored-By: Claude Opus 5.5 --- .../Comparison/MatchExpressionRuleTest.php | 8 +++ .../Rules/Comparison/data/bug-11374.php | 63 +++++++++++++++++++ 2 files changed, 71 insertions(+) diff --git a/tests/PHPStan/Rules/Comparison/MatchExpressionRuleTest.php b/tests/PHPStan/Rules/Comparison/MatchExpressionRuleTest.php index 133d7487937..e216d084f33 100644 --- a/tests/PHPStan/Rules/Comparison/MatchExpressionRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/MatchExpressionRuleTest.php @@ -642,6 +642,14 @@ public function testBug11374(): void 'Match expression does not handle remaining value: true', 132, ], + [ + 'Call to static method Bug11374\\MoreAnalogous::isNotFloat() with float will always evaluate to false.', + 181, + ], + [ + 'Match expression does not handle remaining value: false', + 195, + ], ]); } diff --git a/tests/PHPStan/Rules/Comparison/data/bug-11374.php b/tests/PHPStan/Rules/Comparison/data/bug-11374.php index c7028525028..69ff185806a 100644 --- a/tests/PHPStan/Rules/Comparison/data/bug-11374.php +++ b/tests/PHPStan/Rules/Comparison/data/bug-11374.php @@ -168,3 +168,66 @@ function isIntFunction(mixed $value): bool { return is_int($value); } + +final class MoreAnalogous +{ + + private int|float $prop = 1; + + public function matchFalse(float|int $value): string + { + return match (false) { + self::isNotInt($value) => 'int', + self::isNotFloat($value) => 'float', + }; + } + + public function assertOnProperty(): string + { + return match (true) { + $this->propIsInt() => 'int', + $this->propIsFloat() => 'float', + }; + } + + public function notExhaustiveMatchFalse(float|int|string $value): string + { + return match (false) { + self::isNotInt($value) => 'int', + self::isNotFloat($value) => 'float', + }; + } + + /** + * @phpstan-assert-if-false int $value + */ + private static function isNotInt(mixed $value): bool + { + return !is_int($value); + } + + /** + * @phpstan-assert-if-false float $value + */ + private static function isNotFloat(mixed $value): bool + { + return !is_float($value); + } + + /** + * @phpstan-assert-if-true int $this->prop + */ + private function propIsInt(): bool + { + return is_int($this->prop); + } + + /** + * @phpstan-assert-if-true float $this->prop + */ + private function propIsFloat(): bool + { + return is_float($this->prop); + } + +} From f612885c68f948dd8e7268ed1046211c04eb69b9 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Thu, 5 Nov 2026 08:35:58 +0000 Subject: [PATCH 3/4] Regenerate MatchHandler turbo declarations for hasExpressionNarrowedToNever() Co-Authored-By: Claude Opus 5.5 --- turbo-ext/src/generated/MatchHandler.h | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/turbo-ext/src/generated/MatchHandler.h b/turbo-ext/src/generated/MatchHandler.h index 618edc01b36..0334c96f18f 100644 --- a/turbo-ext/src/generated/MatchHandler.h +++ b/turbo-ext/src/generated/MatchHandler.h @@ -73,9 +73,12 @@ inline constexpr char strings[] = "[]\0" /* 738 */ "PhpParser\\Node\\Expr\\BinaryOp\\Identical|PhpParser\\Node\\Expr\\FuncCall\0" /* 741 */ "getFilteringExprForMatchArm\0" /* 809 */ - "isScopeConditionallyImpossible\0" /* 837 */ - "varNames\0" /* 868 */ - "scopeHasNeverVariable"; /* 877 */ + "scopeBeforeArms\0" /* 837 */ + "matchScope\0" /* 853 */ + "hasExpressionNarrowedToNever\0" /* 864 */ + "isScopeConditionallyImpossible\0" /* 893 */ + "varNames\0" /* 924 */ + "scopeHasNeverVariable"; /* 933 */ inline constexpr reg::PackedArg args[] = { reg::packed(0, MAY_BE_VOID), /* resetFileAnalysisState return */ reg::packed(24, MAY_BE_BOOL), /* __construct $treatPhpDocTypesAsCertain */ @@ -99,10 +102,13 @@ inline constexpr reg::PackedArg args[] = { reg::packed(718, MAY_BE_ARRAY), /* getFilteringExprForMatchArm $conditions */ reg::packed(729, MAY_BE_ARRAY, reg::NoString, false, false, 738), /* getFilteringExprForMatchArm $condData */ reg::packed(0, 0, 741), /* getFilteringExprForMatchArm return */ + reg::packed(837, 0, 536), /* hasExpressionNarrowedToNever $scopeBeforeArms */ + reg::packed(853, 0, 536), /* hasExpressionNarrowedToNever $matchScope */ + reg::packed(0, MAY_BE_BOOL), /* hasExpressionNarrowedToNever return */ reg::packed(530, 0, 536), /* isScopeConditionallyImpossible $scope */ reg::packed(0, MAY_BE_BOOL), /* isScopeConditionallyImpossible return */ reg::packed(530, 0, 536), /* scopeHasNeverVariable $scope */ - reg::packed(868, MAY_BE_ARRAY), /* scopeHasNeverVariable $varNames */ + reg::packed(924, MAY_BE_ARRAY), /* scopeHasNeverVariable $varNames */ reg::packed(0, MAY_BE_BOOL), /* scopeHasNeverVariable return */ }; using Sig = reg::Sig; @@ -116,8 +122,9 @@ inline constexpr sigtab::Sig supports = { { 387 /* supports */, 1, 6, 1, 7, ZEND inline constexpr sigtab::Sig getCapturedArmScopesAndTypes = { { 423 /* getCapturedArmScopesAndTypes */, 1, 8, 1, 9, ZEND_ACC_PUBLIC } }; inline constexpr sigtab::Sig processExpr = { { 706 /* processExpr */, 7, 10, 7, 17, ZEND_ACC_PUBLIC } }; inline constexpr sigtab::Sig getFilteringExprForMatchArm = { { 809 /* getFilteringExprForMatchArm */, 2, 18, 3, 21, ZEND_ACC_PRIVATE } }; -inline constexpr sigtab::Sig isScopeConditionallyImpossible = { { 837 /* isScopeConditionallyImpossible */, 1, 22, 1, 23, ZEND_ACC_PRIVATE } }; -inline constexpr sigtab::Sig scopeHasNeverVariable = { { 877 /* scopeHasNeverVariable */, 2, 24, 2, 26, ZEND_ACC_PRIVATE } }; +inline constexpr sigtab::Sig hasExpressionNarrowedToNever = { { 864 /* hasExpressionNarrowedToNever */, 2, 22, 2, 24, ZEND_ACC_PRIVATE } }; +inline constexpr sigtab::Sig isScopeConditionallyImpossible = { { 893 /* isScopeConditionallyImpossible */, 1, 25, 1, 26, ZEND_ACC_PRIVATE } }; +inline constexpr sigtab::Sig scopeHasNeverVariable = { { 933 /* scopeHasNeverVariable */, 2, 27, 2, 29, ZEND_ACC_PRIVATE } }; } // namespace sig } // namespace ptdecl::MatchHandler From cdf764aaad29677324e7362bb623abef28b83546 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Thu, 5 Nov 2026 09:09:25 +0000 Subject: [PATCH 4/4] Do not check certainty in hasExpressionNarrowedToNever() Narrowing always writes a certain (Yes) holder, so a maybe-certain never-typed expression cannot reach this check - the condition was dead and left an escaped mutant. scopeHasNeverVariable() also ignores certainty. Co-Authored-By: Claude Opus 5.5 --- src/Analyser/ExprHandler/MatchHandler.php | 3 --- turbo-ext/src/MatchHandler.cpp | 12 +----------- 2 files changed, 1 insertion(+), 14 deletions(-) diff --git a/src/Analyser/ExprHandler/MatchHandler.php b/src/Analyser/ExprHandler/MatchHandler.php index e8fb347aa52..0d0b4b634af 100644 --- a/src/Analyser/ExprHandler/MatchHandler.php +++ b/src/Analyser/ExprHandler/MatchHandler.php @@ -613,9 +613,6 @@ private function getFilteringExprForMatchArm(Match_ $expr, array $conditions, ar private function hasExpressionNarrowedToNever(MutatingScope $scopeBeforeArms, MutatingScope $matchScope): bool { foreach ($matchScope->expressionTypes as $exprString => $holder) { - if (!$holder->getCertainty()->yes()) { - continue; - } if (!$holder->getType() instanceof NeverType) { continue; } diff --git a/turbo-ext/src/MatchHandler.cpp b/turbo-ext/src/MatchHandler.cpp index c27aabaa0ae..6e70b093067 100644 --- a/turbo-ext/src/MatchHandler.cpp +++ b/turbo-ext/src/MatchHandler.cpp @@ -1302,17 +1302,7 @@ class MatchHandler if (UNEXPECTED(expressionTypesBeforeArms == NULL)) return false; for (zv::ArrayEntry entry : zv::TableRef(expressionTypes)) { zval *holder = entry.value().deref().raw(); - if (UNEXPECTED(Z_TYPE_P(holder) != IS_OBJECT)) return !callOnNonObject("getCertainty", holder).isUndef(); - zend_long certainty; - if (EXPECTED(Z_OBJCE_P(holder) == pt_ce_expr_type_holder)) { - certainty = pt_holder_certainty_value(Z_OBJ_P(holder)); - } else { - zv::Val certaintyObject = pt_type_call(Z_OBJ_P(holder), PT_LC("getcertainty"), 0, NULL); - if (UNEXPECTED(certaintyObject.isUndef())) return false; - certainty = pt_type_trinary_value(certaintyObject.raw()); - if (UNEXPECTED(certainty < 0)) return false; - } - if (certainty != PT_TRI_YES) continue; + if (UNEXPECTED(Z_TYPE_P(holder) != IS_OBJECT)) return !callOnNonObject("getType", holder).isUndef(); zv::Val type = holderType(holder); if (UNEXPECTED(type.isUndef())) return false; if (!type.ref().instanceOf(pt_ce_never_type)) continue;