From fe1037d7a93dba7a63c950340c8c15b57aa6f720 Mon Sep 17 00:00:00 2001 From: Tomas Votruba Date: Fri, 16 Oct 2026 11:46:17 +0200 Subject: [PATCH 1/3] [Rector] Add NoScopeFetchBeforeCheapGuardRule and NarrowReturnToUnionParamRule to rector set --- config/rector-rules.neon | 2 + .../Rector/NarrowReturnToUnionParamRule.php | 200 ++++++++++++++++++ .../NoScopeFetchBeforeCheapGuardRule.php | 152 +++++++++++++ .../Fixture/BroadReturnOfUnionParam.php | 21 ++ .../Fixture/SkipBroadReturn.php | 51 +++++ .../NarrowReturnToUnionParamRuleTest.php | 43 ++++ .../Fixture/ScopeFetchBeforeGuard.php | 22 ++ .../Fixture/SkipGuardBeforeScopeFetch.php | 27 +++ .../NoScopeFetchBeforeCheapGuardRuleTest.php | 43 ++++ 9 files changed, 561 insertions(+) create mode 100644 src/Rules/Rector/NarrowReturnToUnionParamRule.php create mode 100644 src/Rules/Rector/NoScopeFetchBeforeCheapGuardRule.php create mode 100644 tests/Rules/Rector/NarrowReturnToUnionParamRule/Fixture/BroadReturnOfUnionParam.php create mode 100644 tests/Rules/Rector/NarrowReturnToUnionParamRule/Fixture/SkipBroadReturn.php create mode 100644 tests/Rules/Rector/NarrowReturnToUnionParamRule/NarrowReturnToUnionParamRuleTest.php create mode 100644 tests/Rules/Rector/NoScopeFetchBeforeCheapGuardRule/Fixture/ScopeFetchBeforeGuard.php create mode 100644 tests/Rules/Rector/NoScopeFetchBeforeCheapGuardRule/Fixture/SkipGuardBeforeScopeFetch.php create mode 100644 tests/Rules/Rector/NoScopeFetchBeforeCheapGuardRule/NoScopeFetchBeforeCheapGuardRuleTest.php diff --git a/config/rector-rules.neon b/config/rector-rules.neon index e42b0127..a23d5867 100644 --- a/config/rector-rules.neon +++ b/config/rector-rules.neon @@ -11,6 +11,8 @@ rules: - Symplify\PHPStanRules\Rules\Rector\AvoidFeatureSetAttributeInRectorRule - Symplify\PHPStanRules\Rules\Rector\RectorCheaperGuardsFirstRule - Symplify\PHPStanRules\Rules\Rector\PreferGenericTemplateOnSameUnionTypeRule + - Symplify\PHPStanRules\Rules\Rector\NoScopeFetchBeforeCheapGuardRule + - Symplify\PHPStanRules\Rules\Rector\NarrowReturnToUnionParamRule - Symplify\PHPStanRules\Rules\ForbiddenTraitRule - Symplify\PHPStanRules\Rules\ForbiddenSwitchRule diff --git a/src/Rules/Rector/NarrowReturnToUnionParamRule.php b/src/Rules/Rector/NarrowReturnToUnionParamRule.php new file mode 100644 index 00000000..b2ca6121 --- /dev/null +++ b/src/Rules/Rector/NarrowReturnToUnionParamRule.php @@ -0,0 +1,200 @@ + + * @see \Symplify\PHPStanRules\Tests\Rules\Rector\NarrowReturnToUnionParamRule\NarrowReturnToUnionParamRuleTest + */ +final class NarrowReturnToUnionParamRule implements Rule +{ + public const ERROR_MESSAGE = 'Return type "%s" is too broad for a returned union param. Declare the explicit union return type "%s" instead.'; + + public function getNodeType(): string + { + return ClassMethod::class; + } + + /** + * @param ClassMethod $node + * @return list + */ + public function processNode(Node $node, Scope $scope): array + { + $returnName = $this->resolveSingleClassReturnName($node->returnType); + if ($returnName === null) { + return []; + } + + foreach ($node->params as $param) { + $paramNames = $this->resolveClassUnionNames($param->type); + if ($paramNames === null) { + continue; + } + + // return type is one of the union members, not a broad shared parent - already narrow enough + if (in_array($returnName, $paramNames, true)) { + continue; + } + + if (! $param->var instanceof Variable || ! is_string($param->var->name)) { + continue; + } + + if (! $this->methodReturnsVariable($node, $param->var->name)) { + continue; + } + + $shortNames = array_map(static function (string $name): string { + $parts = explode('\\', $name); + return end($parts); + }, $paramNames); + + if ($this->isNullableReturnType($node->returnType)) { + $shortNames[] = 'null'; + } + + return [ + RuleErrorBuilder::message(sprintf(self::ERROR_MESSAGE, $returnName, implode('|', $shortNames))) + ->identifier('rector.explicitUnionReturnType') + ->line($node->getStartLine()) + ->build(), + ]; + } + + return []; + } + + private function methodReturnsVariable(ClassMethod $classMethod, string $variableName): bool + { + if ($classMethod->stmts === null) { + return false; + } + + $foundReturn = new NodeFinder() + ->findFirst( + $classMethod->stmts, + static fn (Node $subNode): bool => $subNode instanceof Return_ + && $subNode->expr instanceof Variable + && $subNode->expr->name === $variableName + ); + + return $foundReturn instanceof Return_; + } + + private function isNullableReturnType(null|Identifier|Name|ComplexType $type): bool + { + if ($type instanceof NullableType) { + return true; + } + + if (! $type instanceof UnionType) { + return false; + } + + return array_any($type->types, fn (Identifier|Name|IntersectionType $memberType): bool => $memberType instanceof Identifier && $memberType->toLowerString() === 'null'); + } + + /** + * Returns the single class return type name (nullable allowed), or null when the return type is + * missing, scalar, or a union of 2+ class types - the latter is already an explicit union. + */ + private function resolveSingleClassReturnName(null|Identifier|Name|ComplexType $type): ?string + { + if ($type instanceof NullableType) { + $type = $type->type; + } + + if ($type instanceof Name) { + return $type->toString(); + } + + if (! $type instanceof UnionType) { + return null; + } + + $names = []; + foreach ($type->types as $memberType) { + if ($memberType instanceof Identifier) { + if ($memberType->toLowerString() === 'null') { + continue; + } + + return null; + } + + if (! $memberType instanceof Name) { + return null; + } + + $names[] = $memberType->toString(); + } + + if (count($names) !== 1) { + return null; + } + + return $names[0]; + } + + /** + * Returns sorted class-type member names (null stripped), or null when the type is not a + * union of 2+ class types - scalars, array and intersections disqualify it. + * + * @return list|null + */ + private function resolveClassUnionNames(null|Identifier|Name|ComplexType $type): ?array + { + if (! $type instanceof UnionType) { + return null; + } + + $names = []; + foreach ($type->types as $memberType) { + if ($memberType instanceof Identifier) { + if ($memberType->toLowerString() === 'null') { + continue; + } + + return null; + } + + if (! $memberType instanceof Name) { + return null; + } + + $names[] = $memberType->toString(); + } + + if (count($names) < 2) { + return null; + } + + $names = array_unique($names); + sort($names); + + return $names; + } +} diff --git a/src/Rules/Rector/NoScopeFetchBeforeCheapGuardRule.php b/src/Rules/Rector/NoScopeFetchBeforeCheapGuardRule.php new file mode 100644 index 00000000..8bf09513 --- /dev/null +++ b/src/Rules/Rector/NoScopeFetchBeforeCheapGuardRule.php @@ -0,0 +1,152 @@ + + * @see \Symplify\PHPStanRules\Tests\Rules\Rector\NoScopeFetchBeforeCheapGuardRule\NoScopeFetchBeforeCheapGuardRuleTest + */ +final class NoScopeFetchBeforeCheapGuardRule implements Rule +{ + public const ERROR_MESSAGE = 'Move the "return null" guard above ScopeFetcher::fetch(), as it does not use the scope - most nodes then bail out before scope resolution.'; + + public function getNodeType(): string + { + return ClassMethod::class; + } + + /** + * @param ClassMethod $node + * @return list + */ + public function processNode(Node $node, Scope $scope): array + { + if ($node->name->toString() !== 'refactor') { + return []; + } + + $stmts = $node->stmts; + if ($stmts === null) { + return []; + } + + foreach ($stmts as $key => $stmt) { + $scopeVariableName = $this->matchScopeFetchVariableName($stmt); + if ($scopeVariableName === null) { + continue; + } + + $nextStmt = $stmts[$key + 1] ?? null; + if (! $nextStmt instanceof If_) { + continue; + } + + if (! $this->isReturnNullGuard($nextStmt)) { + continue; + } + + if ($this->usesVariable($nextStmt->cond, $scopeVariableName)) { + continue; + } + + return [ + RuleErrorBuilder::message(self::ERROR_MESSAGE) + ->identifier('rector.scopeFetchBeforeCheapGuard') + ->line($stmt->getStartLine()) + ->build(), + ]; + } + + return []; + } + + private function matchScopeFetchVariableName(Stmt $stmt): ?string + { + if (! $stmt instanceof Expression) { + return null; + } + + if (! $stmt->expr instanceof Assign) { + return null; + } + + $assign = $stmt->expr; + if (! $assign->var instanceof Variable || ! is_string($assign->var->name)) { + return null; + } + + if (! $assign->expr instanceof StaticCall) { + return null; + } + + $staticCall = $assign->expr; + if (! $staticCall->class instanceof Name) { + return null; + } + + if ($staticCall->class->toString() !== ScopeFetcher::class) { + return null; + } + + if (! $staticCall->name instanceof Identifier || $staticCall->name->toString() !== 'fetch') { + return null; + } + + return $assign->var->name; + } + + private function isReturnNullGuard(If_ $if): bool + { + if (count($if->stmts) !== 1) { + return false; + } + + $onlyStmt = $if->stmts[0]; + if (! $onlyStmt instanceof Return_) { + return false; + } + + return ! $onlyStmt->expr instanceof Expr || $this->isNullConstant($onlyStmt->expr); + } + + private function isNullConstant(Expr $expr): bool + { + return $expr instanceof ConstFetch && $expr->name->toLowerString() === 'null'; + } + + private function usesVariable(Expr $expr, string $variableName): bool + { + $foundVariable = new NodeFinder() + ->findFirst( + [$expr], + static fn (Node $subNode): bool => $subNode instanceof Variable && $subNode->name === $variableName + ); + + return $foundVariable instanceof Variable; + } +} diff --git a/tests/Rules/Rector/NarrowReturnToUnionParamRule/Fixture/BroadReturnOfUnionParam.php b/tests/Rules/Rector/NarrowReturnToUnionParamRule/Fixture/BroadReturnOfUnionParam.php new file mode 100644 index 00000000..dbbd82ba --- /dev/null +++ b/tests/Rules/Rector/NarrowReturnToUnionParamRule/Fixture/BroadReturnOfUnionParam.php @@ -0,0 +1,21 @@ +var; + } + + if (! $expr instanceof Variable) { + return null; + } + + return $expr; + } +} diff --git a/tests/Rules/Rector/NarrowReturnToUnionParamRule/NarrowReturnToUnionParamRuleTest.php b/tests/Rules/Rector/NarrowReturnToUnionParamRule/NarrowReturnToUnionParamRuleTest.php new file mode 100644 index 00000000..b4daec30 --- /dev/null +++ b/tests/Rules/Rector/NarrowReturnToUnionParamRule/NarrowReturnToUnionParamRuleTest.php @@ -0,0 +1,43 @@ +> $expectedErrorsWithLines + */ + #[DataProvider('provideData')] + public function testRule(string $filePath, array $expectedErrorsWithLines): void + { + $this->analyse([$filePath], $expectedErrorsWithLines); + } + + /** + * @return Iterator, mixed>> + */ + public static function provideData(): Iterator + { + yield [__DIR__ . '/Fixture/SkipBroadReturn.php', []]; + + yield [__DIR__ . '/Fixture/BroadReturnOfUnionParam.php', [ + [ + sprintf(NarrowReturnToUnionParamRule::ERROR_MESSAGE, 'PhpParser\Node', 'MethodCall|New_|null'), + 13, + ], + ]]; + } + + protected function getRule(): Rule + { + return new NarrowReturnToUnionParamRule(); + } +} diff --git a/tests/Rules/Rector/NoScopeFetchBeforeCheapGuardRule/Fixture/ScopeFetchBeforeGuard.php b/tests/Rules/Rector/NoScopeFetchBeforeCheapGuardRule/Fixture/ScopeFetchBeforeGuard.php new file mode 100644 index 00000000..49ff7683 --- /dev/null +++ b/tests/Rules/Rector/NoScopeFetchBeforeCheapGuardRule/Fixture/ScopeFetchBeforeGuard.php @@ -0,0 +1,22 @@ +returnType instanceof Node) { + return null; + } + + return $node; + } +} diff --git a/tests/Rules/Rector/NoScopeFetchBeforeCheapGuardRule/Fixture/SkipGuardBeforeScopeFetch.php b/tests/Rules/Rector/NoScopeFetchBeforeCheapGuardRule/Fixture/SkipGuardBeforeScopeFetch.php new file mode 100644 index 00000000..f336674a --- /dev/null +++ b/tests/Rules/Rector/NoScopeFetchBeforeCheapGuardRule/Fixture/SkipGuardBeforeScopeFetch.php @@ -0,0 +1,27 @@ +returnType instanceof Node) { + return null; + } + + $scope = ScopeFetcher::fetch($node); + if (! $scope->isInClass()) { + return null; + } + + return $node; + } +} diff --git a/tests/Rules/Rector/NoScopeFetchBeforeCheapGuardRule/NoScopeFetchBeforeCheapGuardRuleTest.php b/tests/Rules/Rector/NoScopeFetchBeforeCheapGuardRule/NoScopeFetchBeforeCheapGuardRuleTest.php new file mode 100644 index 00000000..c4426b9d --- /dev/null +++ b/tests/Rules/Rector/NoScopeFetchBeforeCheapGuardRule/NoScopeFetchBeforeCheapGuardRuleTest.php @@ -0,0 +1,43 @@ +> $expectedErrorsWithLines + */ + #[DataProvider('provideData')] + public function testRule(string $filePath, array $expectedErrorsWithLines): void + { + $this->analyse([$filePath], $expectedErrorsWithLines); + } + + /** + * @return Iterator, mixed>> + */ + public static function provideData(): Iterator + { + yield [__DIR__ . '/Fixture/SkipGuardBeforeScopeFetch.php', []]; + + yield [__DIR__ . '/Fixture/ScopeFetchBeforeGuard.php', [ + [ + NoScopeFetchBeforeCheapGuardRule::ERROR_MESSAGE, + 15, + ], + ]]; + } + + protected function getRule(): Rule + { + return new NoScopeFetchBeforeCheapGuardRule(); + } +} From ba9f65a85810f80d8a60c9c1eb50071889a2cad2 Mon Sep 17 00:00:00 2001 From: Tomas Votruba Date: Fri, 16 Oct 2026 11:50:20 +0200 Subject: [PATCH 2/3] Make rules PHP 7.4 compatible and use Node::class in test --- .../Rector/NarrowReturnToUnionParamRule.php | 30 +++++++++++-------- .../NoScopeFetchBeforeCheapGuardRule.php | 10 +++---- .../NarrowReturnToUnionParamRuleTest.php | 3 +- 3 files changed, 24 insertions(+), 19 deletions(-) diff --git a/src/Rules/Rector/NarrowReturnToUnionParamRule.php b/src/Rules/Rector/NarrowReturnToUnionParamRule.php index b2ca6121..d3a637bb 100644 --- a/src/Rules/Rector/NarrowReturnToUnionParamRule.php +++ b/src/Rules/Rector/NarrowReturnToUnionParamRule.php @@ -5,10 +5,8 @@ namespace Symplify\PHPStanRules\Rules\Rector; use PhpParser\Node; -use PhpParser\Node\ComplexType; use PhpParser\Node\Expr\Variable; use PhpParser\Node\Identifier; -use PhpParser\Node\IntersectionType; use PhpParser\Node\Name; use PhpParser\Node\NullableType; use PhpParser\Node\Stmt\ClassMethod; @@ -93,18 +91,18 @@ private function methodReturnsVariable(ClassMethod $classMethod, string $variabl return false; } - $foundReturn = new NodeFinder() - ->findFirst( - $classMethod->stmts, - static fn (Node $subNode): bool => $subNode instanceof Return_ - && $subNode->expr instanceof Variable - && $subNode->expr->name === $variableName - ); + $nodeFinder = new NodeFinder(); + $foundReturn = $nodeFinder->findFirst( + $classMethod->stmts, + static fn (Node $subNode): bool => $subNode instanceof Return_ + && $subNode->expr instanceof Variable + && $subNode->expr->name === $variableName + ); return $foundReturn instanceof Return_; } - private function isNullableReturnType(null|Identifier|Name|ComplexType $type): bool + private function isNullableReturnType(?Node $type): bool { if ($type instanceof NullableType) { return true; @@ -114,14 +112,20 @@ private function isNullableReturnType(null|Identifier|Name|ComplexType $type): b return false; } - return array_any($type->types, fn (Identifier|Name|IntersectionType $memberType): bool => $memberType instanceof Identifier && $memberType->toLowerString() === 'null'); + foreach ($type->types as $memberType) { + if ($memberType instanceof Identifier && $memberType->toLowerString() === 'null') { + return true; + } + } + + return false; } /** * Returns the single class return type name (nullable allowed), or null when the return type is * missing, scalar, or a union of 2+ class types - the latter is already an explicit union. */ - private function resolveSingleClassReturnName(null|Identifier|Name|ComplexType $type): ?string + private function resolveSingleClassReturnName(?Node $type): ?string { if ($type instanceof NullableType) { $type = $type->type; @@ -165,7 +169,7 @@ private function resolveSingleClassReturnName(null|Identifier|Name|ComplexType $ * * @return list|null */ - private function resolveClassUnionNames(null|Identifier|Name|ComplexType $type): ?array + private function resolveClassUnionNames(?Node $type): ?array { if (! $type instanceof UnionType) { return null; diff --git a/src/Rules/Rector/NoScopeFetchBeforeCheapGuardRule.php b/src/Rules/Rector/NoScopeFetchBeforeCheapGuardRule.php index 8bf09513..4fdafca5 100644 --- a/src/Rules/Rector/NoScopeFetchBeforeCheapGuardRule.php +++ b/src/Rules/Rector/NoScopeFetchBeforeCheapGuardRule.php @@ -141,11 +141,11 @@ private function isNullConstant(Expr $expr): bool private function usesVariable(Expr $expr, string $variableName): bool { - $foundVariable = new NodeFinder() - ->findFirst( - [$expr], - static fn (Node $subNode): bool => $subNode instanceof Variable && $subNode->name === $variableName - ); + $nodeFinder = new NodeFinder(); + $foundVariable = $nodeFinder->findFirst( + [$expr], + static fn (Node $subNode): bool => $subNode instanceof Variable && $subNode->name === $variableName + ); return $foundVariable instanceof Variable; } diff --git a/tests/Rules/Rector/NarrowReturnToUnionParamRule/NarrowReturnToUnionParamRuleTest.php b/tests/Rules/Rector/NarrowReturnToUnionParamRule/NarrowReturnToUnionParamRuleTest.php index b4daec30..e08c20d4 100644 --- a/tests/Rules/Rector/NarrowReturnToUnionParamRule/NarrowReturnToUnionParamRuleTest.php +++ b/tests/Rules/Rector/NarrowReturnToUnionParamRule/NarrowReturnToUnionParamRuleTest.php @@ -5,6 +5,7 @@ namespace Symplify\PHPStanRules\Tests\Rules\Rector\NarrowReturnToUnionParamRule; use Iterator; +use PhpParser\Node; use PHPStan\Rules\Rule; use PHPStan\Testing\RuleTestCase; use PHPUnit\Framework\Attributes\DataProvider; @@ -30,7 +31,7 @@ public static function provideData(): Iterator yield [__DIR__ . '/Fixture/BroadReturnOfUnionParam.php', [ [ - sprintf(NarrowReturnToUnionParamRule::ERROR_MESSAGE, 'PhpParser\Node', 'MethodCall|New_|null'), + sprintf(NarrowReturnToUnionParamRule::ERROR_MESSAGE, Node::class, 'MethodCall|New_|null'), 13, ], ]]; From 97568dbb4d783bc068bed772e67a4224d913cc46 Mon Sep 17 00:00:00 2001 From: Tomas Votruba Date: Fri, 16 Oct 2026 11:52:26 +0200 Subject: [PATCH 3/3] Rename param to match type in NarrowReturnToUnionParamRule --- .../Rector/NarrowReturnToUnionParamRule.php | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/src/Rules/Rector/NarrowReturnToUnionParamRule.php b/src/Rules/Rector/NarrowReturnToUnionParamRule.php index d3a637bb..8b2ea456 100644 --- a/src/Rules/Rector/NarrowReturnToUnionParamRule.php +++ b/src/Rules/Rector/NarrowReturnToUnionParamRule.php @@ -102,17 +102,17 @@ private function methodReturnsVariable(ClassMethod $classMethod, string $variabl return $foundReturn instanceof Return_; } - private function isNullableReturnType(?Node $type): bool + private function isNullableReturnType(?Node $node): bool { - if ($type instanceof NullableType) { + if ($node instanceof NullableType) { return true; } - if (! $type instanceof UnionType) { + if (! $node instanceof UnionType) { return false; } - foreach ($type->types as $memberType) { + foreach ($node->types as $memberType) { if ($memberType instanceof Identifier && $memberType->toLowerString() === 'null') { return true; } @@ -125,22 +125,22 @@ private function isNullableReturnType(?Node $type): bool * Returns the single class return type name (nullable allowed), or null when the return type is * missing, scalar, or a union of 2+ class types - the latter is already an explicit union. */ - private function resolveSingleClassReturnName(?Node $type): ?string + private function resolveSingleClassReturnName(?Node $node): ?string { - if ($type instanceof NullableType) { - $type = $type->type; + if ($node instanceof NullableType) { + $node = $node->type; } - if ($type instanceof Name) { - return $type->toString(); + if ($node instanceof Name) { + return $node->toString(); } - if (! $type instanceof UnionType) { + if (! $node instanceof UnionType) { return null; } $names = []; - foreach ($type->types as $memberType) { + foreach ($node->types as $memberType) { if ($memberType instanceof Identifier) { if ($memberType->toLowerString() === 'null') { continue; @@ -169,14 +169,14 @@ private function resolveSingleClassReturnName(?Node $type): ?string * * @return list|null */ - private function resolveClassUnionNames(?Node $type): ?array + private function resolveClassUnionNames(?Node $node): ?array { - if (! $type instanceof UnionType) { + if (! $node instanceof UnionType) { return null; } $names = []; - foreach ($type->types as $memberType) { + foreach ($node->types as $memberType) { if ($memberType instanceof Identifier) { if ($memberType->toLowerString() === 'null') { continue;