From 69bbbe06382a04f1357052921585fdc620fb3f95 Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Wed, 6 Aug 2025 11:47:35 +0200 Subject: [PATCH 1/3] POC --- src/Rules/RuleLevelHelper.php | 26 ++++++++---- .../Functions/ClosureReturnTypeRuleTest.php | 11 ++++- .../Rules/Functions/data/bug-12008.php | 42 +++++++++++++++++++ 3 files changed, 70 insertions(+), 9 deletions(-) create mode 100644 tests/PHPStan/Rules/Functions/data/bug-12008.php diff --git a/src/Rules/RuleLevelHelper.php b/src/Rules/RuleLevelHelper.php index 1b46ccfbe7..05a33df59d 100644 --- a/src/Rules/RuleLevelHelper.php +++ b/src/Rules/RuleLevelHelper.php @@ -6,6 +6,7 @@ use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\AutowiredParameter; use PHPStan\DependencyInjection\AutowiredService; +use PHPStan\Reflection\ParametersAcceptor; use PHPStan\Reflection\ReflectionProvider; use PHPStan\Type\BenevolentUnionType; use PHPStan\Type\CallableType; @@ -16,6 +17,7 @@ use PHPStan\Type\MixedType; use PHPStan\Type\NeverType; use PHPStan\Type\NullType; +use PHPStan\Type\SimultaneousTypeTraverser; use PHPStan\Type\StrictMixedType; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; @@ -87,15 +89,18 @@ private function transformCommonType(Type $type): Type private function transformAcceptedType(Type $acceptingType, Type $acceptedType): array { $checkForUnion = $this->checkUnionTypes; - $acceptedType = TypeTraverser::map($acceptedType, function (Type $acceptedType, callable $traverse) use ($acceptingType, &$checkForUnion): Type { + $acceptedType = SimultaneousTypeTraverser::map($acceptedType, $acceptingType, function (Type $acceptedType, Type $acceptingType, callable $traverse) use (&$checkForUnion): Type { if ($acceptedType instanceof CallableType) { if ($acceptedType->isCommonCallable()) { return $acceptedType; } + if (!$acceptingType instanceof ParametersAcceptor) { + return $acceptedType; + } return new CallableType( $acceptedType->getParameters(), - $traverse($this->transformCommonType($acceptedType->getReturnType())), + $traverse($this->transformCommonType($acceptedType->getReturnType()), $acceptingType->getReturnType()), $acceptedType->isVariadic(), $acceptedType->getTemplateTypeMap(), $acceptedType->getResolvedTemplateTypeMap(), @@ -109,9 +114,13 @@ private function transformAcceptedType(Type $acceptingType, Type $acceptedType): return $acceptedType; } + if (!$acceptingType instanceof ParametersAcceptor) { + return $acceptedType; + } + return new ClosureType( $acceptedType->getParameters(), - $traverse($this->transformCommonType($acceptedType->getReturnType())), + $traverse($this->transformCommonType($acceptedType->getReturnType()), $acceptingType->getReturnType()), $acceptedType->isVariadic(), $acceptedType->getTemplateTypeMap(), $acceptedType->getResolvedTemplateTypeMap(), @@ -128,21 +137,22 @@ private function transformAcceptedType(Type $acceptingType, Type $acceptedType): if ( !$this->checkNullables - && !$acceptingType instanceof NullType - && !$acceptedType instanceof NullType && !$acceptedType instanceof BenevolentUnionType + && !$acceptedType instanceof NullType + && TypeCombinator::containsNull($acceptedType) + && !TypeCombinator::containsNull($acceptingType) ) { - return $traverse(TypeCombinator::removeNull($acceptedType)); + return $traverse(TypeCombinator::removeNull($acceptedType), $acceptingType); } if ($this->checkBenevolentUnionTypes) { if ($acceptedType instanceof BenevolentUnionType) { $checkForUnion = true; - return $traverse(TypeUtils::toStrictUnion($acceptedType)); + return $traverse(TypeUtils::toStrictUnion($acceptedType), $acceptingType); } } - return $traverse($this->transformCommonType($acceptedType)); + return $traverse($this->transformCommonType($acceptedType), $acceptingType); }); return [$acceptedType, $checkForUnion]; diff --git a/tests/PHPStan/Rules/Functions/ClosureReturnTypeRuleTest.php b/tests/PHPStan/Rules/Functions/ClosureReturnTypeRuleTest.php index 38d4ec65d8..350efa7848 100644 --- a/tests/PHPStan/Rules/Functions/ClosureReturnTypeRuleTest.php +++ b/tests/PHPStan/Rules/Functions/ClosureReturnTypeRuleTest.php @@ -13,9 +13,11 @@ class ClosureReturnTypeRuleTest extends RuleTestCase { + private bool $checkNullables = true; + protected function getRule(): Rule { - return new ClosureReturnTypeRule(new FunctionReturnTypeCheck(new RuleLevelHelper(self::createReflectionProvider(), true, false, true, false, false, false, true))); + return new ClosureReturnTypeRule(new FunctionReturnTypeCheck(new RuleLevelHelper(self::createReflectionProvider(), $this->checkNullables, false, true, false, false, false, true))); } public function testClosureReturnTypeRule(): void @@ -128,6 +130,13 @@ public function testBug7220(): void $this->analyse([__DIR__ . '/data/bug-7220.php'], []); } + public function testBug12008(): void + { + $this->checkNullables = false; + + $this->analyse([__DIR__ . '/data/bug-12008.php'], []); + } + public function testBugFunctionMethodConstants(): void { $this->analyse([__DIR__ . '/data/bug-anonymous-function-method-constant.php'], []); diff --git a/tests/PHPStan/Rules/Functions/data/bug-12008.php b/tests/PHPStan/Rules/Functions/data/bug-12008.php new file mode 100644 index 0000000000..47e3ef08c7 --- /dev/null +++ b/tests/PHPStan/Rules/Functions/data/bug-12008.php @@ -0,0 +1,42 @@ + $records + */ + public function __construct( + public iterable $records, + ) { + } +} + +class HelloWorld +{ + private function respondToApiRequest(Closure|null $data): never { + exit; + } + + /** + * @param list $products + */ + public function run(array $products): never { + $this->respondToApiRequest(function () use ($products) { + return new Pagination(array_map( + fn (ProductOverview $product) => [ + 'id' => $product->getId(), + ], + $products, + )); + }); + } +} From 993eae2d87904d298a074791c5a2ea4bd31b055d Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Sat, 18 Oct 2025 16:33:19 +0200 Subject: [PATCH 2/3] Try to improve traverseSimultaneously --- src/Type/IntersectionType.php | 10 +++++++--- src/Type/ObjectType.php | 14 ++++++++++++-- src/Type/ObjectWithoutClassType.php | 14 ++++++++++++-- src/Type/StaticType.php | 14 ++++++++++++-- 4 files changed, 43 insertions(+), 9 deletions(-) diff --git a/src/Type/IntersectionType.php b/src/Type/IntersectionType.php index a065b77612..4c659467a0 100644 --- a/src/Type/IntersectionType.php +++ b/src/Type/IntersectionType.php @@ -1260,13 +1260,17 @@ public function traverse(callable $cb): Type public function traverseSimultaneously(Type $right, callable $cb): Type { - if ($this->isArray()->yes() && $right->isArray()->yes()) { + if ($this->isArray()->yes() && !$right->isArray()->no()) { + $rightArray = $right->isArray()->maybe() + ? TypeCombinator::intersect($right, new ArrayType(new MixedType(), new MixedType())) + : $right; + $changed = false; $newTypes = []; foreach ($this->types as $innerType) { - $newKeyType = $cb($innerType->getIterableKeyType(), $right->getIterableKeyType()); - $newValueType = $cb($innerType->getIterableValueType(), $right->getIterableValueType()); + $newKeyType = $cb($innerType->getIterableKeyType(), $rightArray->getIterableKeyType()); + $newValueType = $cb($innerType->getIterableValueType(), $rightArray->getIterableValueType()); if ($newKeyType === $innerType->getIterableKeyType() && $newValueType === $innerType->getIterableValueType()) { $newTypes[] = $innerType; continue; diff --git a/src/Type/ObjectType.php b/src/Type/ObjectType.php index de1e120173..e06350fe78 100644 --- a/src/Type/ObjectType.php +++ b/src/Type/ObjectType.php @@ -1690,11 +1690,21 @@ public function traverse(callable $cb): Type public function traverseSimultaneously(Type $right, callable $cb): Type { - if ($this->subtractedType === null) { + if (!$right instanceof SubtractableType) { + return $this; + } + + $rightSubtractable = $right->getSubtractedType(); + if ($this->subtractedType === null || $rightSubtractable === null) { return $this; } - return new self($this->className); + $newSubtractedType = $cb($this->subtractedType, $rightSubtractable); + if ($newSubtractedType !== $this->subtractedType) { + return new self($this->className, $newSubtractedType); + } + + return $this; } public function getNakedClassReflection(): ?ClassReflection diff --git a/src/Type/ObjectWithoutClassType.php b/src/Type/ObjectWithoutClassType.php index 9823dcaf80..6805e9b26e 100644 --- a/src/Type/ObjectWithoutClassType.php +++ b/src/Type/ObjectWithoutClassType.php @@ -170,11 +170,21 @@ public function traverse(callable $cb): Type public function traverseSimultaneously(Type $right, callable $cb): Type { - if ($this->subtractedType === null) { + if (!$right instanceof SubtractableType) { return $this; } - return new self(); + $rightSubtractable = $right->getSubtractedType(); + if ($this->subtractedType === null || $rightSubtractable === null) { + return $this; + } + + $newSubtractedType = $cb($this->subtractedType, $rightSubtractable); + if ($newSubtractedType !== $this->subtractedType) { + return new self($newSubtractedType); + } + + return $this; } public function tryRemove(Type $typeToRemove): ?Type diff --git a/src/Type/StaticType.php b/src/Type/StaticType.php index 1f659eb4f5..875a7951cb 100644 --- a/src/Type/StaticType.php +++ b/src/Type/StaticType.php @@ -745,11 +745,21 @@ public function traverse(callable $cb): Type public function traverseSimultaneously(Type $right, callable $cb): Type { - if ($this->subtractedType === null) { + if (!$right instanceof SubtractableType) { return $this; } - return new self($this->classReflection); + $rightSubtractable = $right->getSubtractedType(); + if ($this->subtractedType === null || $rightSubtractable === null) { + return $this; + } + + $newSubtractedType = $cb($this->subtractedType, $rightSubtractable); + if ($newSubtractedType !== $this->subtractedType) { + return new self($this->classReflection, $newSubtractedType); + } + + return $this; } public function subtract(Type $type): Type From 4b75cc5d7caaddddd799234c9906be93ab7987c4 Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Sat, 18 Oct 2025 16:45:57 +0200 Subject: [PATCH 3/3] Fix phpstan error --- src/Parser/TypeTraverserInstanceofVisitor.php | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/Parser/TypeTraverserInstanceofVisitor.php b/src/Parser/TypeTraverserInstanceofVisitor.php index 3ad2fa0f11..f915483060 100644 --- a/src/Parser/TypeTraverserInstanceofVisitor.php +++ b/src/Parser/TypeTraverserInstanceofVisitor.php @@ -13,6 +13,11 @@ final class TypeTraverserInstanceofVisitor extends NodeVisitorAbstract public const ATTRIBUTE_NAME = 'insideTypeTraverserMap'; + private const TYPE_TRAVERSER_CLASSES = [ + 'phpstan\\type\\typetraverser', + 'phpstan\\type\\simultaneoustypetraverser', + ]; + private int $depth = 0; #[Override] @@ -33,7 +38,7 @@ public function enterNode(Node $node): ?Node if ( $node instanceof Node\Expr\StaticCall && $node->class instanceof Node\Name - && $node->class->toLowerString() === 'phpstan\\type\\typetraverser' + && \in_array($node->class->toLowerString(), self::TYPE_TRAVERSER_CLASSES, true) && $node->name instanceof Node\Identifier && $node->name->toLowerString() === 'map' ) { @@ -49,7 +54,7 @@ public function leaveNode(Node $node): ?Node if ( $node instanceof Node\Expr\StaticCall && $node->class instanceof Node\Name - && $node->class->toLowerString() === 'phpstan\\type\\typetraverser' + && \in_array($node->class->toLowerString(), self::TYPE_TRAVERSER_CLASSES, true) && $node->name instanceof Node\Identifier && $node->name->toLowerString() === 'map' ) {