From 435663ae13c955036bb6a6d8cf9bd76dd3a7931c Mon Sep 17 00:00:00 2001 From: staabm <120441+staabm@users.noreply.github.com> Date: Tue, 16 Jun 2026 21:53:24 +0000 Subject: [PATCH] Report functions and methods that unconditionally call themselves on every code path - Add InfiniteRecursionFinder which walks a body's statements in order and reports a self-call only when it is guaranteed to be evaluated, bailing out on any branching construct (if/switch/loop/try/match-arms/short-circuit), so a hidden base case never produces a false positive. - Add InfiniteMethodRecursionRule (level 4) for MethodReturnStatementsNode: detects `$this->m()` in instance methods, `self::m()`/`static::m()`/`C::m()` in static methods, and `new self()`/`new static()`/`new C()` inside a constructor (re-entering itself), while ignoring first-class callable syntax, generators and self-calls hidden inside closures. - Add InfiniteFunctionRecursionRule (level 4) for FunctionReturnStatementsNode, resolving the called function name through ReflectionProvider so namespaced self-calls are matched correctly. - Generate methodCalls-4.json for the levels integration test (three existing fixtures contain genuine unconditional self-calls) and add a base case to the incidental infinite recursion in the trait-aliases fixture. --- .../InfiniteFunctionRecursionRule.php | 81 +++++ src/Rules/InfiniteRecursionFinder.php | 332 ++++++++++++++++++ .../Methods/InfiniteMethodRecursionRule.php | 98 ++++++ .../PHPStan/Analyser/traits/trait-aliases.php | 4 +- tests/PHPStan/Levels/data/methodCalls-4.json | 17 + .../InfiniteFunctionRecursionRuleTest.php | 42 +++ .../data/infinite-function-recursion.php | 57 +++ .../InfiniteMethodRecursionRuleTest.php | 66 ++++ .../data/infinite-method-recursion.php | 121 +++++++ 9 files changed, 817 insertions(+), 1 deletion(-) create mode 100644 src/Rules/Functions/InfiniteFunctionRecursionRule.php create mode 100644 src/Rules/InfiniteRecursionFinder.php create mode 100644 src/Rules/Methods/InfiniteMethodRecursionRule.php create mode 100644 tests/PHPStan/Levels/data/methodCalls-4.json create mode 100644 tests/PHPStan/Rules/Functions/InfiniteFunctionRecursionRuleTest.php create mode 100644 tests/PHPStan/Rules/Functions/data/infinite-function-recursion.php create mode 100644 tests/PHPStan/Rules/Methods/InfiniteMethodRecursionRuleTest.php create mode 100644 tests/PHPStan/Rules/Methods/data/infinite-method-recursion.php diff --git a/src/Rules/Functions/InfiniteFunctionRecursionRule.php b/src/Rules/Functions/InfiniteFunctionRecursionRule.php new file mode 100644 index 0000000000..14ef20040e --- /dev/null +++ b/src/Rules/Functions/InfiniteFunctionRecursionRule.php @@ -0,0 +1,81 @@ + + */ +#[RegisteredRule(level: 4)] +final class InfiniteFunctionRecursionRule implements Rule +{ + + public function __construct( + private InfiniteRecursionFinder $finder, + private ReflectionProvider $reflectionProvider, + ) + { + } + + public function getNodeType(): string + { + return FunctionReturnStatementsNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if ($node->isGenerator()) { + return []; + } + + $function = $node->getFunctionReflection(); + $functionName = strtolower($function->getName()); + $reflectionProvider = $this->reflectionProvider; + + $isSelfCall = static function (Expr $expr) use ($functionName, $reflectionProvider, $scope): bool { + if (!$expr instanceof FuncCall) { + return false; + } + if ($expr->isFirstClassCallable()) { + return false; + } + if (!$expr->name instanceof Name) { + return false; + } + if (!$reflectionProvider->hasFunction($expr->name, $scope)) { + return false; + } + + return strtolower($reflectionProvider->getFunction($expr->name, $scope)->getName()) === $functionName; + }; + + $call = $this->finder->find($node->getStatements(), $isSelfCall); + if ($call === null) { + return []; + } + + return [ + RuleErrorBuilder::message(sprintf( + 'Function %s() calls itself on every code path, leading to infinite recursion.', + $function->getName(), + )) + ->identifier('function.infiniteRecursion') + ->line($call->getStartLine()) + ->build(), + ]; + } + +} diff --git a/src/Rules/InfiniteRecursionFinder.php b/src/Rules/InfiniteRecursionFinder.php new file mode 100644 index 0000000000..fa20c291cd --- /dev/null +++ b/src/Rules/InfiniteRecursionFinder.php @@ -0,0 +1,332 @@ +processStmts($stmts, $isSelfCall); + + return $result instanceof Expr ? $result : null; + } + + /** + * @param Stmt[] $stmts + * @param callable(Expr): bool $isSelfCall + * @return Expr|self::STOP|self::CONTINUE + */ + private function processStmts(array $stmts, callable $isSelfCall) + { + foreach ($stmts as $stmt) { + $result = $this->processStmt($stmt, $isSelfCall); + if ($result instanceof Expr) { + return $result; + } + if ($result === self::STOP) { + return self::STOP; + } + } + + return self::CONTINUE; + } + + /** + * @param callable(Expr): bool $isSelfCall + * @return Expr|self::STOP|self::CONTINUE + */ + private function processStmt(Stmt $stmt, callable $isSelfCall) + { + if ($stmt instanceof Stmt\Expression) { + $call = $this->findInExpr($stmt->expr, $isSelfCall); + if ($call !== null) { + return $call; + } + + return $this->alwaysStops($stmt->expr) ? self::STOP : self::CONTINUE; + } + + if ($stmt instanceof Stmt\Return_) { + if ($stmt->expr !== null) { + $call = $this->findInExpr($stmt->expr, $isSelfCall); + if ($call !== null) { + return $call; + } + } + + return self::STOP; + } + + if ($stmt instanceof Stmt\Echo_) { + foreach ($stmt->exprs as $expr) { + $call = $this->findInExpr($expr, $isSelfCall); + if ($call !== null) { + return $call; + } + } + + return self::CONTINUE; + } + + if ($stmt instanceof Stmt\Block) { + return $this->processStmts($stmt->stmts, $isSelfCall); + } + + if ( + $stmt instanceof Stmt\Nop + || $stmt instanceof Stmt\Static_ + || $stmt instanceof Stmt\Global_ + || $stmt instanceof Stmt\InlineHTML + ) { + return self::CONTINUE; + } + + // Any other statement is either branching control flow or stops the + // normal flow before a guaranteed self-call. Either way, bail out. + return self::STOP; + } + + /** + * @param callable(Expr): bool $isSelfCall + */ + private function findInExpr(Expr $expr, callable $isSelfCall): ?Expr + { + if ($isSelfCall($expr)) { + return $expr; + } + + foreach ($this->getUnconditionalSubExprs($expr) as $subExpr) { + $found = $this->findInExpr($subExpr, $isSelfCall); + if ($found !== null) { + return $found; + } + } + + return null; + } + + /** + * Sub-expressions that are guaranteed to be evaluated when $expr is + * evaluated, regardless of any short-circuiting or branching. + * + * @return Expr[] + */ + private function getUnconditionalSubExprs(Expr $expr): array + { + if ($expr instanceof Assign || $expr instanceof AssignRef || $expr instanceof AssignOp) { + return [$expr->var, $expr->expr]; + } + + if ( + $expr instanceof BooleanAnd + || $expr instanceof BooleanOr + || $expr instanceof LogicalAnd + || $expr instanceof LogicalOr + || $expr instanceof Coalesce + ) { + return [$expr->left]; + } + + if ($expr instanceof BinaryOp) { + return [$expr->left, $expr->right]; + } + + if ($expr instanceof Ternary) { + return [$expr->cond]; + } + + if ($expr instanceof Match_) { + return [$expr->cond]; + } + + if ($expr instanceof MethodCall) { + $subExprs = [$expr->var]; + if ($expr->name instanceof Expr) { + $subExprs[] = $expr->name; + } + + return array_merge($subExprs, $this->getArgExprs($expr->args)); + } + + if ($expr instanceof NullsafeMethodCall) { + return [$expr->var]; + } + + if ($expr instanceof StaticCall) { + $subExprs = []; + if ($expr->class instanceof Expr) { + $subExprs[] = $expr->class; + } + if ($expr->name instanceof Expr) { + $subExprs[] = $expr->name; + } + + return array_merge($subExprs, $this->getArgExprs($expr->args)); + } + + if ($expr instanceof FuncCall) { + $subExprs = []; + if ($expr->name instanceof Expr) { + $subExprs[] = $expr->name; + } + + return array_merge($subExprs, $this->getArgExprs($expr->args)); + } + + if ($expr instanceof New_) { + $subExprs = []; + if ($expr->class instanceof Expr) { + $subExprs[] = $expr->class; + } + + return array_merge($subExprs, $this->getArgExprs($expr->args)); + } + + if ($expr instanceof ArrayDimFetch) { + $subExprs = [$expr->var]; + if ($expr->dim !== null) { + $subExprs[] = $expr->dim; + } + + return $subExprs; + } + + if ($expr instanceof PropertyFetch || $expr instanceof NullsafePropertyFetch) { + $subExprs = [$expr->var]; + if ($expr->name instanceof Expr) { + $subExprs[] = $expr->name; + } + + return $subExprs; + } + + if ($expr instanceof StaticPropertyFetch) { + $subExprs = []; + if ($expr->class instanceof Expr) { + $subExprs[] = $expr->class; + } + if ($expr->name instanceof Expr) { + $subExprs[] = $expr->name; + } + + return $subExprs; + } + + if ( + $expr instanceof Cast + || $expr instanceof UnaryMinus + || $expr instanceof UnaryPlus + || $expr instanceof BooleanNot + || $expr instanceof BitwiseNot + || $expr instanceof Print_ + || $expr instanceof Clone_ + || $expr instanceof ErrorSuppress + || $expr instanceof Throw_ + ) { + return [$expr->expr]; + } + + if ($expr instanceof Exit_) { + return $expr->expr !== null ? [$expr->expr] : []; + } + + if ($expr instanceof Array_) { + $subExprs = []; + foreach ($expr->items as $item) { + if ($item->key !== null) { + $subExprs[] = $item->key; + } + $subExprs[] = $item->value; + } + + return $subExprs; + } + + return []; + } + + /** + * @param array $args + * @return Expr[] + */ + private function getArgExprs(array $args): array + { + $exprs = []; + foreach ($args as $arg) { + if (!$arg instanceof Arg) { + continue; + } + $exprs[] = $arg->value; + } + + return $exprs; + } + + private function alwaysStops(Expr $expr): bool + { + return $expr instanceof Exit_ || $expr instanceof Throw_; + } + +} diff --git a/src/Rules/Methods/InfiniteMethodRecursionRule.php b/src/Rules/Methods/InfiniteMethodRecursionRule.php new file mode 100644 index 0000000000..8708eba50e --- /dev/null +++ b/src/Rules/Methods/InfiniteMethodRecursionRule.php @@ -0,0 +1,98 @@ + + */ +#[RegisteredRule(level: 4)] +final class InfiniteMethodRecursionRule implements Rule +{ + + public function __construct(private InfiniteRecursionFinder $finder) + { + } + + public function getNodeType(): string + { + return MethodReturnStatementsNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if ($node->isGenerator()) { + return []; + } + + $method = $node->getMethodReflection(); + $methodName = $method->getName(); + $className = $node->getClassReflection()->getName(); + $isStatic = $method->isStatic(); + $isConstructor = strtolower($methodName) === '__construct'; + + $isSelfCall = static function (Expr $expr) use ($methodName, $className, $isStatic, $isConstructor): bool { + // A constructor that unconditionally instantiates its own class + // re-enters itself, which is also infinite recursion. + if ($expr instanceof New_) { + return $isConstructor + && $expr->class instanceof Name + && in_array(strtolower($expr->class->toString()), ['self', 'static', strtolower($className)], true); + } + + if ($expr instanceof MethodCall) { + return !$isStatic + && !$expr->isFirstClassCallable() + && $expr->var instanceof Variable + && $expr->var->name === 'this' + && $expr->name instanceof Identifier + && strtolower($expr->name->name) === strtolower($methodName); + } + + if ($expr instanceof StaticCall) { + return $isStatic + && !$expr->isFirstClassCallable() + && $expr->class instanceof Name + && in_array(strtolower($expr->class->toString()), ['self', 'static', strtolower($className)], true) + && $expr->name instanceof Identifier + && strtolower($expr->name->name) === strtolower($methodName); + } + + return false; + }; + + $call = $this->finder->find($node->getStatements(), $isSelfCall); + if ($call === null) { + return []; + } + + return [ + RuleErrorBuilder::message(sprintf( + 'Method %s::%s() calls itself on every code path, leading to infinite recursion.', + $node->getClassReflection()->getDisplayName(), + $methodName, + )) + ->identifier('method.infiniteRecursion') + ->line($call->getStartLine()) + ->build(), + ]; + } + +} diff --git a/tests/PHPStan/Analyser/traits/trait-aliases.php b/tests/PHPStan/Analyser/traits/trait-aliases.php index 31f1d42480..c1d92b484a 100644 --- a/tests/PHPStan/Analyser/traits/trait-aliases.php +++ b/tests/PHPStan/Analyser/traits/trait-aliases.php @@ -22,7 +22,9 @@ trait BarTrait public function fooMethod(): void { // some code ... - $this->fooMethod(); + if (rand(0, 1) === 1) { + $this->fooMethod(); + } $this->parentFooMethod(); } diff --git a/tests/PHPStan/Levels/data/methodCalls-4.json b/tests/PHPStan/Levels/data/methodCalls-4.json new file mode 100644 index 0000000000..d81715efdc --- /dev/null +++ b/tests/PHPStan/Levels/data/methodCalls-4.json @@ -0,0 +1,17 @@ +[ + { + "message": "Method Levels\\MethodCalls\\Foo::doFoo() calls itself on every code path, leading to infinite recursion.", + "line": 10, + "ignorable": true + }, + { + "message": "Method Levels\\MethodCalls\\Bar::doBar() calls itself on every code path, leading to infinite recursion.", + "line": 25, + "ignorable": true + }, + { + "message": "Method Levels\\MethodCalls\\ExtraArguments::doFoo() calls itself on every code path, leading to infinite recursion.", + "line": 236, + "ignorable": true + } +] \ No newline at end of file diff --git a/tests/PHPStan/Rules/Functions/InfiniteFunctionRecursionRuleTest.php b/tests/PHPStan/Rules/Functions/InfiniteFunctionRecursionRuleTest.php new file mode 100644 index 0000000000..c455ba9c4b --- /dev/null +++ b/tests/PHPStan/Rules/Functions/InfiniteFunctionRecursionRuleTest.php @@ -0,0 +1,42 @@ + + */ +class InfiniteFunctionRecursionRuleTest extends RuleTestCase +{ + + protected function getRule(): Rule + { + return new InfiniteFunctionRecursionRule(new InfiniteRecursionFinder(), $this->createReflectionProvider()); + } + + public function testRule(): void + { + $this->analyse([__DIR__ . '/data/infinite-function-recursion.php'], [ + [ + 'Function InfiniteFunctionRecursion\getWorld() calls itself on every code path, leading to infinite recursion.', + 7, + ], + [ + 'Function InfiniteFunctionRecursion\withSideEffect() calls itself on every code path, leading to infinite recursion.', + 14, + ], + [ + 'Function InfiniteFunctionRecursion\concat() calls itself on every code path, leading to infinite recursion.', + 19, + ], + [ + 'Function InfiniteFunctionRecursion\insideArgument() calls itself on every code path, leading to infinite recursion.', + 24, + ], + ]); + } + +} diff --git a/tests/PHPStan/Rules/Functions/data/infinite-function-recursion.php b/tests/PHPStan/Rules/Functions/data/infinite-function-recursion.php new file mode 100644 index 0000000000..889e2efb42 --- /dev/null +++ b/tests/PHPStan/Rules/Functions/data/infinite-function-recursion.php @@ -0,0 +1,57 @@ += 8.1 + +namespace InfiniteFunctionRecursion; + +function getWorld(): string +{ + return getWorld(); +} + +function withSideEffect(): string +{ + echo 'x'; + + return withSideEffect(); +} + +function concat(): string +{ + return concat() . 'x'; +} + +function insideArgument(): string +{ + return strtoupper(insideArgument()); +} + +function withBaseCase(int $i): int +{ + if ($i <= 0) { + return 0; + } + + return withBaseCase($i - 1); +} + +function ternaryBaseCase(int $i): int +{ + return $i <= 0 ? 0 : ternaryBaseCase($i - 1); +} + +function callsOther(): string +{ + return getWorld(); +} + +function firstClassCallable(): callable +{ + return firstClassCallable(...); +} + +/** + * @return \Generator + */ +function generator(): \Generator +{ + yield getWorld(); +} diff --git a/tests/PHPStan/Rules/Methods/InfiniteMethodRecursionRuleTest.php b/tests/PHPStan/Rules/Methods/InfiniteMethodRecursionRuleTest.php new file mode 100644 index 0000000000..6a9ab49d0a --- /dev/null +++ b/tests/PHPStan/Rules/Methods/InfiniteMethodRecursionRuleTest.php @@ -0,0 +1,66 @@ + + */ +class InfiniteMethodRecursionRuleTest extends RuleTestCase +{ + + protected function getRule(): Rule + { + return new InfiniteMethodRecursionRule(new InfiniteRecursionFinder()); + } + + public function testRule(): void + { + $this->analyse([__DIR__ . '/data/infinite-method-recursion.php'], [ + [ + 'Method InfiniteMethodRecursion\HelloWorld::getWorld() calls itself on every code path, leading to infinite recursion.', + 13, + ], + [ + 'Method InfiniteMethodRecursion\HelloWorld::withSideEffect() calls itself on every code path, leading to infinite recursion.', + 20, + ], + [ + 'Method InfiniteMethodRecursion\HelloWorld::concat() calls itself on every code path, leading to infinite recursion.', + 25, + ], + [ + 'Method InfiniteMethodRecursion\HelloWorld::coalesceLeft() calls itself on every code path, leading to infinite recursion.', + 30, + ], + [ + 'Method InfiniteMethodRecursion\HelloWorld::assignThenReturn() calls itself on every code path, leading to infinite recursion.', + 35, + ], + [ + 'Method InfiniteMethodRecursion\HelloWorld::insideArgument() calls itself on every code path, leading to infinite recursion.', + 42, + ], + [ + 'Method InfiniteMethodRecursion\HelloWorld::staticSelf() calls itself on every code path, leading to infinite recursion.', + 47, + ], + [ + 'Method InfiniteMethodRecursion\HelloWorld::staticLateSelf() calls itself on every code path, leading to infinite recursion.', + 52, + ], + [ + 'Method InfiniteMethodRecursion\HelloWorld::staticClassName() calls itself on every code path, leading to infinite recursion.', + 57, + ], + [ + 'Method InfiniteMethodRecursion\HelloWorld::__construct() calls itself on every code path, leading to infinite recursion.', + 105, + ], + ]); + } + +} diff --git a/tests/PHPStan/Rules/Methods/data/infinite-method-recursion.php b/tests/PHPStan/Rules/Methods/data/infinite-method-recursion.php new file mode 100644 index 0000000000..b8e9bf91ad --- /dev/null +++ b/tests/PHPStan/Rules/Methods/data/infinite-method-recursion.php @@ -0,0 +1,121 @@ += 8.1 + +namespace InfiniteMethodRecursion; + +class HelloWorld +{ + + /** @var string */ + private $world = ''; + + public function getWorld(): string + { + return $this->getWorld(); + } + + public function withSideEffect(): string + { + echo 'x'; + + return $this->withSideEffect(); + } + + public function concat(): string + { + return $this->concat() . 'x'; + } + + public function coalesceLeft(): ?string + { + return $this->coalesceLeft() ?? 'x'; + } + + public function assignThenReturn(): string + { + $x = $this->assignThenReturn(); + + return $x; + } + + public function insideArgument(): string + { + return strtoupper($this->insideArgument()); + } + + public static function staticSelf(): string + { + return self::staticSelf(); + } + + public static function staticLateSelf(): string + { + return static::staticLateSelf(); + } + + public static function staticClassName(): string + { + return HelloWorld::staticClassName(); + } + + public function withBaseCase(int $i): int + { + if ($i <= 0) { + return 0; + } + + return $this->withBaseCase($i - 1); + } + + public function ternaryBaseCase(int $i): int + { + return $i <= 0 ? 0 : $this->ternaryBaseCase($i - 1); + } + + public function coalesceRight(?string $other): string + { + return $other ?? $this->coalesceRight($other); + } + + public function callsOtherMethod(): string + { + return $this->getWorld(); + } + + public function insideClosure(): string + { + $cb = function (): string { + return $this->insideClosure(); + }; + + return $cb(); + } + + public function firstClassCallable(): callable + { + return $this->firstClassCallable(...); + } + + public function throwsFirst(): string + { + throw new \Exception(); + } + + public function __construct() + { + new self(); + } + + public function notConstructorNewSelf(): self + { + return new self(); + } + + /** + * @return \Generator + */ + public function generator(): \Generator + { + yield $this->getWorld(); + } + +}