From dea27638dc49f2d021e962841c307548276df513 Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Tue, 20 Oct 2026 21:18:14 +0700 Subject: [PATCH 1/2] Proper fix to handle ClassReflection refresh on ClassLike changed --- .../workflows/rector_drupal_rector_dev.yaml | 1 + .../workflows/rector_laravel_rector_dev.yaml | 1 + src/Application/ChangedNodeScopeRefresher.php | 48 ++++++++++++- .../LazyContainerFactory.php | 4 ++ .../Scope/AstClassReflectionFactory.php | 68 +++++++++++++++++++ .../ClassReflectionRefreshTest.php | 28 ++++++++ .../Fixture/add_trait_once.php.inc | 26 +++++++ .../add_trait_with_imported_parent.php.inc | 32 +++++++++ .../skip_already_used_imported_trait.php.inc | 10 +++ .../Source/AddTraitByReflectionRector.php | 58 ++++++++++++++++ .../Source/SomeParentClass.php | 9 +++ .../Source/SomeTrait.php | 9 +++ .../config/configured_rule.php | 9 +++ 13 files changed, 302 insertions(+), 1 deletion(-) create mode 100644 src/NodeTypeResolver/PHPStan/Scope/AstClassReflectionFactory.php create mode 100644 tests/Issues/ClassReflectionRefresh/ClassReflectionRefreshTest.php create mode 100644 tests/Issues/ClassReflectionRefresh/Fixture/add_trait_once.php.inc create mode 100644 tests/Issues/ClassReflectionRefresh/Fixture/add_trait_with_imported_parent.php.inc create mode 100644 tests/Issues/ClassReflectionRefresh/Fixture/skip_already_used_imported_trait.php.inc create mode 100644 tests/Issues/ClassReflectionRefresh/Source/AddTraitByReflectionRector.php create mode 100644 tests/Issues/ClassReflectionRefresh/Source/SomeParentClass.php create mode 100644 tests/Issues/ClassReflectionRefresh/Source/SomeTrait.php create mode 100644 tests/Issues/ClassReflectionRefresh/config/configured_rule.php diff --git a/.github/workflows/rector_drupal_rector_dev.yaml b/.github/workflows/rector_drupal_rector_dev.yaml index a9af3b11c94..17cf18162ba 100644 --- a/.github/workflows/rector_drupal_rector_dev.yaml +++ b/.github/workflows/rector_drupal_rector_dev.yaml @@ -12,6 +12,7 @@ env: jobs: rector_drupal_rector_dev: runs-on: ubuntu-latest + timeout-minutes: 4 steps: - diff --git a/.github/workflows/rector_laravel_rector_dev.yaml b/.github/workflows/rector_laravel_rector_dev.yaml index 9d0304c5ced..c776099df86 100644 --- a/.github/workflows/rector_laravel_rector_dev.yaml +++ b/.github/workflows/rector_laravel_rector_dev.yaml @@ -13,6 +13,7 @@ env: jobs: rector_laravel_rector_dev: runs-on: ubuntu-latest + timeout-minutes: 4 steps: - diff --git a/src/Application/ChangedNodeScopeRefresher.php b/src/Application/ChangedNodeScopeRefresher.php index d79eb149b81..ddd1b1852ad 100644 --- a/src/Application/ChangedNodeScopeRefresher.php +++ b/src/Application/ChangedNodeScopeRefresher.php @@ -27,17 +27,22 @@ use PhpParser\Node\Stmt\Declare_; use PhpParser\Node\Stmt\Else_; use PhpParser\Node\Stmt\ElseIf_; +use PhpParser\Node\Stmt\Enum_; use PhpParser\Node\Stmt\Expression; use PhpParser\Node\Stmt\If_; +use PhpParser\Node\Stmt\Interface_; use PhpParser\Node\Stmt\Property; use PhpParser\Node\Stmt\Static_; use PhpParser\Node\Stmt\Use_; use PhpParser\Node\UseItem; use PHPStan\Analyser\MutatingScope; +use PHPStan\Reflection\ClassReflection; use Rector\Exception\ShouldNotHappenException; use Rector\NodeAnalyzer\ScopeAnalyzer; use Rector\NodeTypeResolver\Node\AttributeKey; +use Rector\NodeTypeResolver\PHPStan\Scope\AstClassReflectionFactory; use Rector\NodeTypeResolver\PHPStan\Scope\PHPStanNodeScopeResolver; +use Rector\NodeTypeResolver\PHPStan\Scope\ScopeFactory; use Rector\PhpDocParser\NodeTraverser\SimpleCallableNodeTraverser; /** @@ -48,7 +53,9 @@ public function __construct( private PHPStanNodeScopeResolver $phpStanNodeScopeResolver, private ScopeAnalyzer $scopeAnalyzer, - private SimpleCallableNodeTraverser $simpleCallableNodeTraverser + private SimpleCallableNodeTraverser $simpleCallableNodeTraverser, + private AstClassReflectionFactory $astClassReflectionFactory, + private ScopeFactory $scopeFactory ) { } @@ -81,6 +88,8 @@ public function refresh(Node $node, string $filePath, ?MutatingScope $mutatingSc // a class-like scope is already inside the class, so PHPStan cannot enter it again and would fail // on the class node, leaving its methods with the class scope; process the class stmts directly instead if ($node instanceof ClassLike && $mutatingScope->isInClass()) { + $mutatingScope = $this->refreshClassReflection($node, $mutatingScope); + // the class node itself is not part of its stmts, e.g. an Enum_ replaced by a new Class_ has no scope yet $node->setAttribute(AttributeKey::SCOPE, $mutatingScope); $this->phpStanNodeScopeResolver->processNodes($node->stmts, $filePath, $mutatingScope); @@ -91,6 +100,43 @@ public function refresh(Node $node, string $filePath, ?MutatingScope $mutatingSc $this->phpStanNodeScopeResolver->processNodes($stmts, $filePath, $mutatingScope); } + /** + * The in-class scope holds the class reflection of the original file, which does not know about + * members the rule just added or removed; re-enter the class with the reflection of the changed node + */ + private function refreshClassReflection(ClassLike $classLike, MutatingScope $mutatingScope): MutatingScope + { + if (! $classLike instanceof Class_ && ! $classLike instanceof Interface_ && ! $classLike instanceof Enum_) { + return $mutatingScope; + } + + if ($classLike instanceof Class_ && $classLike->isAnonymous()) { + return $mutatingScope; + } + + $classReflection = $this->astClassReflectionFactory->create($classLike, $mutatingScope); + if (! $classReflection instanceof ClassReflection) { + return $mutatingScope; + } + + $currentClassReflection = $mutatingScope->getClassReflection(); + if (! $currentClassReflection instanceof ClassReflection || $currentClassReflection->getName() !== $classReflection->getName()) { + return $mutatingScope; + } + + $classOutsideScope = $this->scopeFactory->createFromFile($mutatingScope->getFile()); + if ($mutatingScope->isDeclareStrictTypes()) { + $classOutsideScope = $classOutsideScope->enterDeclareStrictTypes(); + } + + $namespace = $mutatingScope->getNamespace(); + if ($namespace !== null) { + $classOutsideScope = $classOutsideScope->enterNamespace($namespace); + } + + return $classOutsideScope->enterClass($classReflection); + } + /** * @return Stmt[] */ diff --git a/src/DependencyInjection/LazyContainerFactory.php b/src/DependencyInjection/LazyContainerFactory.php index b4aa5b4ac42..37af84700bc 100644 --- a/src/DependencyInjection/LazyContainerFactory.php +++ b/src/DependencyInjection/LazyContainerFactory.php @@ -9,10 +9,12 @@ use PhpParser\Lexer; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\ScopeFactory; +use PHPStan\BetterReflection\Reflector\Reflector; use PHPStan\Parser\Parser; use PHPStan\Php\PhpVersionFactory; use PHPStan\PhpDoc\TypeNodeResolver; use PHPStan\PhpDocParser\ParserConfig; +use PHPStan\Reflection\ClassReflectionFactory; use PHPStan\Reflection\ReflectionProvider; use Rector\Application\FileProcessor; use Rector\Application\Provider\CurrentFileProvider; @@ -110,6 +112,8 @@ final class LazyContainerFactory NodeScopeResolver::class, ReflectionProvider::class, PhpVersionFactory::class, + Reflector::class, + ClassReflectionFactory::class, ]; /** diff --git a/src/NodeTypeResolver/PHPStan/Scope/AstClassReflectionFactory.php b/src/NodeTypeResolver/PHPStan/Scope/AstClassReflectionFactory.php new file mode 100644 index 00000000000..4901d174cd1 --- /dev/null +++ b/src/NodeTypeResolver/PHPStan/Scope/AstClassReflectionFactory.php @@ -0,0 +1,68 @@ +getFile(); + $namespace = $mutatingScope->getNamespace(); + + $betterReflectionClass = (new NodeToReflection())->__invoke( + $this->reflector, + $classLike, + new LocatedSource(null, null, $filePath), + $namespace !== null ? new Namespace_(new Name($namespace)) : null + ); + + if (! $betterReflectionClass instanceof ReflectionClass) { + return null; + } + + $reflectionClassAdapter = $betterReflectionClass instanceof ReflectionEnum + ? new ReflectionEnumAdapter($betterReflectionClass) + : new ReflectionClassAdapter($betterReflectionClass); + + // the member caches are keyed by the class reflection cache key, + // so each rebuilt reflection needs its own key not to read members of the former one + return $this->classReflectionFactory->create( + $betterReflectionClass->getName(), + $reflectionClassAdapter, + null, + null, + null, + uniqid($filePath . ':', true) + ); + } +} diff --git a/tests/Issues/ClassReflectionRefresh/ClassReflectionRefreshTest.php b/tests/Issues/ClassReflectionRefresh/ClassReflectionRefreshTest.php new file mode 100644 index 00000000000..6d2281f9d9b --- /dev/null +++ b/tests/Issues/ClassReflectionRefresh/ClassReflectionRefreshTest.php @@ -0,0 +1,28 @@ +doTestFile($filePath); + } + + public static function provideData(): Iterator + { + return self::yieldFilesFromDirectory(__DIR__ . '/Fixture'); + } + + public function provideConfigFilePath(): string + { + return __DIR__ . '/config/configured_rule.php'; + } +} diff --git a/tests/Issues/ClassReflectionRefresh/Fixture/add_trait_once.php.inc b/tests/Issues/ClassReflectionRefresh/Fixture/add_trait_once.php.inc new file mode 100644 index 00000000000..dae3faf52ce --- /dev/null +++ b/tests/Issues/ClassReflectionRefresh/Fixture/add_trait_once.php.inc @@ -0,0 +1,26 @@ + +----- + diff --git a/tests/Issues/ClassReflectionRefresh/Fixture/add_trait_with_imported_parent.php.inc b/tests/Issues/ClassReflectionRefresh/Fixture/add_trait_with_imported_parent.php.inc new file mode 100644 index 00000000000..ea21734913f --- /dev/null +++ b/tests/Issues/ClassReflectionRefresh/Fixture/add_trait_with_imported_parent.php.inc @@ -0,0 +1,32 @@ + +----- + diff --git a/tests/Issues/ClassReflectionRefresh/Fixture/skip_already_used_imported_trait.php.inc b/tests/Issues/ClassReflectionRefresh/Fixture/skip_already_used_imported_trait.php.inc new file mode 100644 index 00000000000..80669bd0bfb --- /dev/null +++ b/tests/Issues/ClassReflectionRefresh/Fixture/skip_already_used_imported_trait.php.inc @@ -0,0 +1,10 @@ +> + */ + public function getNodeTypes(): array + { + return [Class_::class]; + } + + /** + * @param Class_ $node + */ + public function refactor(Node $node): ?Class_ + { + $classReflection = $this->reflectionResolver->resolveClassReflection($node); + if (! $classReflection instanceof ClassReflection) { + return null; + } + + if ($classReflection->hasTraitUse(SomeTrait::class)) { + return null; + } + + $node->stmts = array_merge([new TraitUse([new FullyQualified(SomeTrait::class)])], $node->stmts); + + return $node; + } +} diff --git a/tests/Issues/ClassReflectionRefresh/Source/SomeParentClass.php b/tests/Issues/ClassReflectionRefresh/Source/SomeParentClass.php new file mode 100644 index 00000000000..2cef5b3aeba --- /dev/null +++ b/tests/Issues/ClassReflectionRefresh/Source/SomeParentClass.php @@ -0,0 +1,9 @@ +withRules([AddTraitByReflectionRector::class]); From 860f5d697e1b304d755129dd05686f9a19f24416 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Tue, 20 Oct 2026 14:24:14 +0000 Subject: [PATCH 2/2] [ci-review] Rector Rectify --- .../PHPStan/Scope/AstClassReflectionFactory.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/NodeTypeResolver/PHPStan/Scope/AstClassReflectionFactory.php b/src/NodeTypeResolver/PHPStan/Scope/AstClassReflectionFactory.php index 4901d174cd1..32e57899a2b 100644 --- a/src/NodeTypeResolver/PHPStan/Scope/AstClassReflectionFactory.php +++ b/src/NodeTypeResolver/PHPStan/Scope/AstClassReflectionFactory.php @@ -39,7 +39,7 @@ public function create(Class_|Interface_|Enum_ $classLike, MutatingScope $mutati $filePath = $mutatingScope->getFile(); $namespace = $mutatingScope->getNamespace(); - $betterReflectionClass = (new NodeToReflection())->__invoke( + $betterReflectionClass = new NodeToReflection()->__invoke( $this->reflector, $classLike, new LocatedSource(null, null, $filePath),