From d0879cf0eba68ba9819dc62a4435d43f2da1fe63 Mon Sep 17 00:00:00 2001 From: Sander Muller Date: Sat, 19 Sep 2026 12:56:09 +0200 Subject: [PATCH] Answer per node class what resolveDependencies() can reach 84.4 percent of the calls produce nothing at all: 648270 of 767955 on a phpstan-src src/ analysis return no reflections and no file paths, after walking all 29 branches of the chain to find out. The most frequent nodes are Expr\Variable, Arg, MethodCallExpressionNode and VariableAssignNode, and no branch matches any of them. Every question the method asks about a node depends only on its class, so the answer is computed once per class and reused: whether it can carry a @var tag, whether a branch matches it, whether it can produce an exported node, and whether it changes the PHPDoc name scope. There are 152 distinct node classes in the run. resolveDependencies() goes from 1.528s to 1.131s, 26 percent, on a single-process src/ analysis of about 61s. The analysis output is identical. Each of the three node-class lists mirrors a chain of instanceof checks somewhere else, and a branch added without an entry in its list would silently stop producing dependencies - a stale result cache rather than a failing test. NodeProfileMirrorsBranchesTest reads the lists back out of the source they mirror. It found four dead entries in the first version of this commit: a class member reaches ExportedNodeResolver through exportClassStatement(), never through resolve(). Co-Authored-By: Claude Opus 5 (1M context) --- src/Dependency/DependencyResolver.php | 144 ++++++++++++++++-- .../NodeProfileMirrorsBranchesTest.php | 115 ++++++++++++++ 2 files changed, 246 insertions(+), 13 deletions(-) create mode 100644 tests/PHPStan/Dependency/NodeProfileMirrorsBranchesTest.php diff --git a/src/Dependency/DependencyResolver.php b/src/Dependency/DependencyResolver.php index cee316199de..b6eb82dd0e4 100644 --- a/src/Dependency/DependencyResolver.php +++ b/src/Dependency/DependencyResolver.php @@ -36,6 +36,7 @@ use PHPStan\Type\Type; use function array_key_exists; use function count; +use function get_class; use function in_array; use function is_file; use function spl_object_id; @@ -44,9 +45,74 @@ final class DependencyResolver { + private const PROFILE_VAR_TAGS = 1; + + private const PROFILE_CHAIN = 2; + + private const PROFILE_EXPORT = 4; + + private const PROFILE_NAME_SCOPE = 8; + + /** Node classes the branch chain in collectNodeDependencies() reacts to */ + private const CHAIN_NODE_TYPES = [ + Node\Stmt\Class_::class, + Node\Stmt\Interface_::class, + Node\Stmt\Enum_::class, + InClassMethodNode::class, + InPropertyHookNode::class, + ClassPropertyNode::class, + InFunctionNode::class, + Closure::class, + Node\Expr\ArrowFunction::class, + Node\Expr\FuncCall::class, + Node\Expr\MethodCall::class, + Node\Expr\PropertyFetch::class, + Node\Expr\StaticCall::class, + Node\Expr\ClassConstFetch::class, + Node\Expr\ConstFetch::class, + Node\Expr\StaticPropertyFetch::class, + Node\Expr\New_::class, + Node\Stmt\Trait_::class, + Node\Stmt\TraitUse::class, + Node\Expr\Instanceof_::class, + Node\Expr\Include_::class, + Node\Stmt\Catch_::class, + ArrayDimFetch::class, + Foreach_::class, + Array_::class, + StaticMethodCallableNode::class, + MethodCallableNode::class, + FunctionCallableNode::class, + InstantiationCallableNode::class, + ]; + + /** + * Node classes ExportedNodeResolver::resolve() reacts to. A class member is not among them: it is + * exported as part of the class declaring it, through exportClassStatement(), never on its own. + */ + private const EXPORT_NODE_TYPES = [ + Node\Stmt\Class_::class, + Node\Stmt\Interface_::class, + Node\Stmt\Enum_::class, + Node\Stmt\Trait_::class, + Node\Stmt\Function_::class, + Node\Stmt\Const_::class, + Node\Expr\FuncCall::class, + ]; + + /** Node classes ExportedNameScopeTracker::enterNode() reacts to */ + private const NAME_SCOPE_NODE_TYPES = [ + Node\Stmt\Namespace_::class, + Node\Stmt\Use_::class, + Node\Stmt\GroupUse::class, + ]; + /** @var array> reflections keyed by spl_object_id() */ private array $classDependencies = []; + /** @var array */ + private array $nodeProfiles = []; + private ExportedNameScopeTracker $nameScopeTracker; private ?string $nameScopeFile = null; @@ -72,7 +138,12 @@ public function resolveDependencies(Node $node, Scope $scope): NodeDependencies $this->nameScopeFile = $file; $this->nameScopeTracker->reset(); } - $this->nameScopeTracker->enterNode($node); + $nodeClass = get_class($node); + $nodeProfile = $this->nodeProfiles[$nodeClass] ??= $this->resolveNodeProfile($node); + + if (($nodeProfile & self::PROFILE_NAME_SCOPE) !== 0) { + $this->nameScopeTracker->enterNode($node); + } // Keyed by spl_object_id(), so that a reflection collected again - every level of a class hierarchy // repeats the interfaces it inherits, and the classes a node references share most of their @@ -80,19 +151,29 @@ public function resolveDependencies(Node $node, Scope $scope): NodeDependencies $dependenciesReflections = []; $dependenciesFilePaths = []; - if ( - $node instanceof Node\Stmt - && !$node instanceof VirtualNode - && !$node instanceof Node\Stmt\ClassLike - && !$node instanceof Node\Stmt\ClassMethod - && !$node instanceof Node\Stmt\Function_ - && !$node instanceof Node\Stmt\Property - && !$node instanceof Node\Stmt\ClassConst - && !$node instanceof Node\Stmt\Const_ - ) { + if (($nodeProfile & self::PROFILE_VAR_TAGS) !== 0 && $node instanceof Node\Stmt) { $this->extractStmtVarTags($node, $scope, $dependenciesReflections); } + if (($nodeProfile & self::PROFILE_CHAIN) !== 0) { + $this->collectNodeDependencies($node, $scope, $dependenciesReflections, $dependenciesFilePaths); + } + + $exportedNode = ($nodeProfile & self::PROFILE_EXPORT) !== 0 + ? $this->exportedNodeResolver->resolve($node, $this->nameScopeTracker->getNameScope()) + : null; + + return new NodeDependencies($this->fileHelper, $dependenciesReflections, $exportedNode, $dependenciesFilePaths); + } + + /** + * The node-kind branches. Only entered when resolveNodeProfile() says a branch can match. + * + * @param array $dependenciesReflections + * @param list $dependenciesFilePaths + */ + private function collectNodeDependencies(Node $node, Scope $scope, array &$dependenciesReflections, array &$dependenciesFilePaths): void + { if ($node instanceof Node\Stmt\Class_) { if (isset($node->namespacedName)) { $this->addClassToDependencies($node->namespacedName->toString(), $dependenciesReflections); @@ -562,8 +643,6 @@ public function resolveDependencies(Node $node, Scope $scope): NodeDependencies } elseif ($node instanceof InstantiationCallableNode) { $dependenciesReflections += $this->resolveDependencies(new Node\Expr\New_($node->getClass()), $scope)->getReflections(); } - - return new NodeDependencies($this->fileHelper, $dependenciesReflections, $this->exportedNodeResolver->resolve($node, $this->nameScopeTracker->getNameScope()), $dependenciesFilePaths); } public function resolveUsedTraitDependencies(InClassNode $inClassNode): NodeDependencies @@ -606,6 +685,45 @@ private function getClassNamesFromClassString(Type $type): array return $classNames; } + /** + * Which parts of resolveDependencies() a node of this class can reach. Depends only on the class, + * so it is computed once per class and reused for every node of it. + */ + private function resolveNodeProfile(Node $node): int + { + $profile = 0; + if ( + $node instanceof Node\Stmt + && !$node instanceof VirtualNode + && !$node instanceof Node\Stmt\ClassLike + && !$node instanceof Node\Stmt\ClassMethod + && !$node instanceof Node\Stmt\Function_ + && !$node instanceof Node\Stmt\Property + && !$node instanceof Node\Stmt\ClassConst + && !$node instanceof Node\Stmt\Const_ + ) { + $profile |= self::PROFILE_VAR_TAGS; + } + + $lists = [ + self::PROFILE_CHAIN => self::CHAIN_NODE_TYPES, + self::PROFILE_EXPORT => self::EXPORT_NODE_TYPES, + self::PROFILE_NAME_SCOPE => self::NAME_SCOPE_NODE_TYPES, + ]; + foreach ($lists as $bit => $nodeTypes) { + foreach ($nodeTypes as $nodeType) { + if (!$node instanceof $nodeType) { + continue; + } + + $profile |= $bit; + break; + } + } + + return $profile; + } + /** * Extracts the classes referenced from a variable-level var-tag PHPDoc attached to a statement. * diff --git a/tests/PHPStan/Dependency/NodeProfileMirrorsBranchesTest.php b/tests/PHPStan/Dependency/NodeProfileMirrorsBranchesTest.php new file mode 100644 index 00000000000..f953d064edc --- /dev/null +++ b/tests/PHPStan/Dependency/NodeProfileMirrorsBranchesTest.php @@ -0,0 +1,115 @@ + + */ + public static function dataLists(): iterable + { + yield 'the branch chain' => [ + 'CHAIN_NODE_TYPES', + __DIR__ . '/../../../src/Dependency/DependencyResolver.php', + 'collectNodeDependencies', + ]; + yield 'the exported nodes' => [ + 'EXPORT_NODE_TYPES', + __DIR__ . '/../../../src/Dependency/ExportedNodeResolver.php', + 'resolve', + ]; + yield 'the PHPDoc name scope' => [ + 'NAME_SCOPE_NODE_TYPES', + __DIR__ . '/../../../src/Dependency/ExportedNameScopeTracker.php', + 'enterNode', + ]; + } + + #[DataProvider('dataLists')] + public function testListMirrorsTheBranches(string $constantName, string $file, string $methodName): void + { + $reflection = new ReflectionClass(DependencyResolver::class); + /** @var list $listed */ + $listed = $reflection->getConstant($constantName); + sort($listed); + + $matched = $this->nodeClassesMatchedIn($file, $methodName); + + $this->assertSame( + $listed, + $matched, + $constantName . ' does not match the node classes ' . $methodName . '() reacts to.', + ); + } + + /** + * Every class the method's `$node instanceof X` checks name, in source order, deduplicated. + * + * @return list + */ + private function nodeClassesMatchedIn(string $file, string $methodName): array + { + $contents = file_get_contents($file); + $this->assertNotFalse($contents); + + $parser = (new ParserFactory())->createForHostVersion(); + $stmts = $parser->parse($contents); + $this->assertNotNull($stmts); + + $traverser = new NodeTraverser(); + $traverser->addVisitor(new NameResolver()); + $stmts = $traverser->traverse($stmts); + + $method = null; + foreach ((new NodeFinder())->findInstanceOf($stmts, Node\Stmt\ClassMethod::class) as $classMethod) { + if ($classMethod->name->toString() !== $methodName) { + continue; + } + + $method = $classMethod; + break; + } + + $this->assertNotNull($method, $methodName . '() not found in ' . $file); + + $classNames = []; + foreach ((new NodeFinder())->findInstanceOf([$method], Node\Expr\Instanceof_::class) as $instanceof) { + if (!$instanceof->expr instanceof Node\Expr\Variable || $instanceof->expr->name !== 'node') { + continue; + } + if (!$instanceof->class instanceof Node\Name) { + continue; + } + + $className = $instanceof->class->toString(); + $classNames[$className] = $className; + } + + $classNames = array_values($classNames); + sort($classNames); + + return $classNames; + } + +}