From 0d3fa42172e56f953295eb5a8bb532c805707b4e Mon Sep 17 00:00:00 2001 From: Tomas Votruba Date: Mon, 26 Oct 2026 11:40:04 +0100 Subject: [PATCH] Add OrderedRectorInterface to order dependent rules in a single pass A rule implementing OrderedRectorInterface declares precededRule(), the rule that must run before it. RectorRegistry topologically sorts active rules so each dependent rule runs after its predecessor within one file pass, instead of relying on repeated FileProcessor passes to converge. --- src/Application/RectorRegistry.php | 72 ++++++++++++++++++- .../Rector/OrderedRectorInterface.php | 15 ++++ tests/Application/RectorRegistryTest.php | 16 +++++ .../Class_/OrderedClassRector.php | 44 ++++++++++++ 4 files changed, 146 insertions(+), 1 deletion(-) create mode 100644 src/Contract/Rector/OrderedRectorInterface.php create mode 100644 tests/PhpParser/NodeTraverser/Class_/OrderedClassRector.php diff --git a/src/Application/RectorRegistry.php b/src/Application/RectorRegistry.php index c251d01d43f..cb1772af53b 100644 --- a/src/Application/RectorRegistry.php +++ b/src/Application/RectorRegistry.php @@ -6,7 +6,9 @@ use PhpParser\Node; use Rector\Configuration\ConfigurationRuleFilter; +use Rector\Contract\Rector\OrderedRectorInterface; use Rector\Contract\Rector\RectorInterface; +use Rector\Exception\ShouldNotHappenException; use Rector\Skipper\Skipper\Skipper; use Rector\ValueObject\Application\File; use Rector\VersionBonding\ComposerPackageConstraintFilter; @@ -112,9 +114,77 @@ private function prepareRectors(): void $activeRectors = $this->phpVersionedFilter->filter($this->rectors); $activeRectors = $this->composerPackageConstraintFilter->filter($activeRectors); + $activeRectors = $this->configurationRuleFilter->filter($activeRectors); - $this->activeRectors = $this->configurationRuleFilter->filter($activeRectors); + $this->activeRectors = $this->sortByPrecededRule($activeRectors); $this->areRectorsPrepared = true; } + + /** + * Rules implementing OrderedRectorInterface are moved after the rule they depend on, + * so a chain of dependent rules converges in a single file pass. + * + * @param RectorInterface[] $rectors + * @return RectorInterface[] + */ + private function sortByPrecededRule(array $rectors): array + { + $rectorByClass = []; + foreach ($rectors as $rector) { + $rectorByClass[$rector::class] = $rector; + } + + $precededClassByClass = []; + foreach ($rectors as $rector) { + if (! $rector instanceof OrderedRectorInterface) { + continue; + } + + $precededClass = $rector->precededRule(); + + // ignore a predecessor filtered out of the active set + if (isset($rectorByClass[$precededClass])) { + $precededClassByClass[$rector::class] = $precededClass; + } + } + + if ($precededClassByClass === []) { + return $rectors; + } + + $sortedRectors = []; + $emittedClasses = []; + $remainingRectors = $rectors; + + while ($remainingRectors !== []) { + $hasProgress = false; + + foreach ($remainingRectors as $key => $rector) { + $precededClass = $precededClassByClass[$rector::class] ?? null; + if ($precededClass !== null && ! isset($emittedClasses[$precededClass])) { + continue; + } + + $sortedRectors[] = $rector; + $emittedClasses[$rector::class] = true; + unset($remainingRectors[$key]); + $hasProgress = true; + } + + if (! $hasProgress) { + $remainingClasses = []; + foreach ($remainingRectors as $rector) { + $remainingClasses[] = $rector::class; + } + + throw new ShouldNotHappenException(sprintf( + 'Circular rule ordering detected among: %s', + implode(', ', $remainingClasses) + )); + } + } + + return $sortedRectors; + } } diff --git a/src/Contract/Rector/OrderedRectorInterface.php b/src/Contract/Rector/OrderedRectorInterface.php new file mode 100644 index 00000000000..dba82fb1f0e --- /dev/null +++ b/src/Contract/Rector/OrderedRectorInterface.php @@ -0,0 +1,15 @@ + + */ + public function precededRule(): string; +} diff --git a/tests/Application/RectorRegistryTest.php b/tests/Application/RectorRegistryTest.php index 294afab94c2..6c21584b750 100644 --- a/tests/Application/RectorRegistryTest.php +++ b/tests/Application/RectorRegistryTest.php @@ -7,6 +7,7 @@ use PhpParser\Node\Stmt\Class_; use Rector\Application\RectorRegistry; use Rector\Testing\PHPUnit\AbstractLazyTestCase; +use Rector\Tests\PhpParser\NodeTraverser\Class_\OrderedClassRector; use Rector\Tests\PhpParser\NodeTraverser\Class_\RuleUsingClassRector; use Rector\Tests\PhpParser\NodeTraverser\ClassLike\RuleUsingClassLikeRector; use Rector\Tests\PhpParser\NodeTraverser\Function_\RuleUsingFunctionRector; @@ -72,4 +73,19 @@ public function testForNodeWhenAllRectorsMatch(): void $this->assertEquals([$this->ruleUsingClassRector, $this->ruleUsingClassLikeRector], $rectors); } + + public function testForNodeSortsByPrecededRule(): void + { + $orderedClassRector = new OrderedClassRector(); + + // registered before the rule it depends on + $this->rectorRegistry->refreshRectors([ + $orderedClassRector, + $this->ruleUsingClassRector, + ]); + + $rectors = $this->rectorRegistry->forNode(new Class_('test')); + + $this->assertEquals([$this->ruleUsingClassRector, $orderedClassRector], $rectors); + } } diff --git a/tests/PhpParser/NodeTraverser/Class_/OrderedClassRector.php b/tests/PhpParser/NodeTraverser/Class_/OrderedClassRector.php new file mode 100644 index 00000000000..b7a5fd10d12 --- /dev/null +++ b/tests/PhpParser/NodeTraverser/Class_/OrderedClassRector.php @@ -0,0 +1,44 @@ +> + */ + public function getNodeTypes(): array + { + return [Class_::class]; + } + + /** + * @param Class_ $node + */ + public function refactor(Node $node): Node + { + return $node; + } +}