From 5db96728e4ea404028afb49a1ef02de0190e16df Mon Sep 17 00:00:00 2001 From: phpstan-bot <79867460+phpstan-bot@users.noreply.github.com> Date: Mon, 21 Sep 2026 20:00:46 +0000 Subject: [PATCH] Keep `never` in `MutatingScope::intersectButNotNever()` instead of widening it to `null` * `intersectButNotNever()` restores the native type's nullability onto the intersection result. It relied on the old `TypeCombinator::addNull()` behaviour, where `addNull(never)` returned `never`. Since `addNull()` was improved it returns `null` for `never`, so a nullable native type combined with an inferred `never` produced `null`. * Return the intersection unchanged as soon as it is `never`, before the nullability is added back. * Fixes closure and arrow function return types resolved in `ClosureTypeResolver` (`fn (): null => neverReturning()` and `function (): ?int { throw ...; }` are `Closure(): never` again), including the `static` variants and closures declared `: ?Generator` whose body only throws. * Fixes the same helper's other call sites - closure and arrow function parameter types in `MutatingScope::enterAnonymousFunctionWithoutReflection()` and `enterArrowFunctionWithoutReflection()`. A `?int` parameter in a `callable(never): void` context (e.g. `array_map()` over `list`) is `never` inside the body again, for both the fixed and the variadic parameter branch. * Probed the other `TypeCombinator::addNull()` call sites that can receive a `never` by instrumenting `addNull()` and running the whole test suite. The nullsafe short-circuit sites (`MethodCallHandler`, `StaticCallHandler`, `PropertyFetchHandler`, `StaticPropertyFetchHandler`, `ArrayDimFetchHandler`, `MutatingScope::resolveScopeStateType()`) and `ArrayFindFunctionReturnTypeExtension` are correct as they are: there `null` really is the result of the short circuit / of not finding anything. `TypehintHelper::decideType()` cannot reach its `addNull()` with a `never`, because an explicit `@return never` returns early and an implicit one is never picked as the result type. (cherry picked from commit 7c9cd26883f3309f58e79737ba091aebe5c4f015) --- src/Analyser/MutatingScope.php | 6 ++ tests/PHPStan/Analyser/nsrt/bug-15285.php | 99 +++++++++++++++++++ .../Rules/Functions/CallCallablesRuleTest.php | 6 ++ .../Rules/Functions/data/bug-15285.php | 33 +++++++ 4 files changed, 144 insertions(+) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-15285.php create mode 100644 tests/PHPStan/Rules/Functions/data/bug-15285.php diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index f4f74bff5cc..b02a54e6b0f 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -2621,6 +2621,12 @@ public static function intersectButNotNever(Type $nativeType, Type $inferredType } $result = TypeCombinator::intersect($nativeType, $inferredType); + if ($result instanceof NeverType) { + // the inferred type says no value is ever produced - the native + // type's nullability must not resurrect one + return $result; + } + if (TypeCombinator::containsNull($nativeType)) { return TypeCombinator::addNull($result); } diff --git a/tests/PHPStan/Analyser/nsrt/bug-15285.php b/tests/PHPStan/Analyser/nsrt/bug-15285.php new file mode 100644 index 00000000000..622324460e0 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-15285.php @@ -0,0 +1,99 @@ += 8.2 + +declare(strict_types = 1); + +namespace Bug15285; + +use Closure; +use Generator; +use LogicException; +use RuntimeException; +use function PHPStan\Testing\assertType; + +final class CartController +{ + + public function redirect(string $destination): never + { + throw new RuntimeException($destination); + } + + /** + * @param Closure(string, (Closure(): never)|null=): never $errorCallback + */ + public function addToCart(bool $isAvailable, Closure $errorCallback): void + { + if ($isAvailable) { + return; + } + + $errorCallback( + 'This offer is no longer available.', + fn (): null => $this->redirect('/product'), + ); + } + + public function dump(): void + { + assertType('Closure(): never', fn () => $this->redirect('/product')); + assertType('Closure(): never', fn (): null => $this->redirect('/product')); + assertType('Closure(): never', fn (): int => throw new LogicException()); + assertType('Closure(): never', fn (): ?int => throw new LogicException()); + + assertType('Closure(): never', function () { + $this->redirect('/product'); + }); + assertType('Closure(): never', function (): null { + $this->redirect('/product'); + }); + assertType('Closure(): never', function (): ?int { + throw new LogicException(); + }); + + assertType('static-Closure(): never', static fn (): ?int => throw new LogicException()); + assertType('static-Closure(): never', static function (): null { + throw new LogicException(); + }); + + assertType('Closure(): never', function (): ?Generator { + throw new LogicException(); + }); + assertType('Closure(): Generator', function (): ?Generator { + yield 1; + }); + } + +} + +/** + * @param list $emptyList + */ +function neverCallableParameters(array $emptyList): void +{ + array_map(function (?int $i): void { + assertType('never', $i); + }, $emptyList); + + array_map(fn (?int $i) => assertType('never', $i), $emptyList); + + array_map(function (null $i): void { + assertType('never', $i); + }, $emptyList); +} + +/** + * @param callable(never...): void $cb + */ +function neverVariadicCallable(callable $cb): void +{ +} + +function neverVariadicCallableParameters(): void +{ + neverVariadicCallable(function (?int $a, ?string $b): void { + assertType('never', $a); + assertType('never', $b); + }); + + neverVariadicCallable(fn (?int $a) => assertType('never', $a)); +} diff --git a/tests/PHPStan/Rules/Functions/CallCallablesRuleTest.php b/tests/PHPStan/Rules/Functions/CallCallablesRuleTest.php index 67d0c0adc28..536bd63a807 100644 --- a/tests/PHPStan/Rules/Functions/CallCallablesRuleTest.php +++ b/tests/PHPStan/Rules/Functions/CallCallablesRuleTest.php @@ -449,6 +449,12 @@ public function testBug15251(): void $this->analyse([__DIR__ . '/../Methods/data/bug-15251.php'], []); } + #[RequiresPhp('>= 8.2.0')] + public function testBug15285(): void + { + $this->analyse([__DIR__ . '/data/bug-15285.php'], []); + } + #[RequiresPhp('>= 8.0.0')] public function testBug11935WithCheckExplicitMixed(): void { diff --git a/tests/PHPStan/Rules/Functions/data/bug-15285.php b/tests/PHPStan/Rules/Functions/data/bug-15285.php new file mode 100644 index 00000000000..f8764ff7ec4 --- /dev/null +++ b/tests/PHPStan/Rules/Functions/data/bug-15285.php @@ -0,0 +1,33 @@ += 8.2 + +declare(strict_types = 1); + +namespace Bug15285CallCallables; + +use Closure; +use RuntimeException; + +final class CartController +{ + + public function redirect(string $destination): never + { + throw new RuntimeException($destination); + } + + /** + * @param Closure(string, (Closure(): never)|null=): never $errorCallback + */ + public function addToCart(bool $isAvailable, Closure $errorCallback): void + { + if ($isAvailable) { + return; + } + + $errorCallback( + 'This offer is no longer available.', + fn (): null => $this->redirect('/product'), + ); + } + +}