From d99be80746decd31807abc62eaf14d366fc8178a Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Fri, 18 Sep 2026 09:00:05 +0200 Subject: [PATCH] Resolve a node's file and package dependencies in one pass over unique reflections The reflections a node depends on were walked twice - once for the analysed files, once for the non-analysed files and packages - normalizing every file path both times. They are now resolved in a single pass. The reflections are also collected keyed by their object id, so each is kept once: every level of a class hierarchy repeated the interfaces it inherits, and the classes one node references share most of their ancestors, so 55% of the reflections walked were duplicates. The versioned extension check looks the symbol up by name before asking whether it is built-in, which goes through several layers of reflection for nearly every dependency while nearly none of them is such a symbol. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0147ixdtEww7ewC6Jz6isghx --- src/Analyser/FileAnalyserCallback.php | 18 ++--- .../ResultCache/ResultCacheManager.php | 4 +- src/Dependency/DependencyResolver.php | 39 +++++----- src/Dependency/NodeDependencies.php | 75 +++++++------------ src/Dependency/PackageDependencyResolver.php | 19 +++-- 5 files changed, 65 insertions(+), 90 deletions(-) diff --git a/src/Analyser/FileAnalyserCallback.php b/src/Analyser/FileAnalyserCallback.php index d769a84c02b..a9ef59d08b5 100644 --- a/src/Analyser/FileAnalyserCallback.php +++ b/src/Analyser/FileAnalyserCallback.php @@ -226,17 +226,17 @@ public function __invoke(Node $node, Scope $scope): void try { $dependencies = $this->dependencyResolver->resolveDependencies($node, $scope); - foreach ($dependencies->getFileDependencies($scope->getFile(), $this->analysedFiles) as $dependentFile) { + $fileAndPackageDependencies = $dependencies->getFileAndPackageDependencies($scope->getFile(), $this->analysedFiles, $this->packageDependencyResolver); + foreach ($fileAndPackageDependencies['analysedFiles'] as $dependentFile) { $this->fileDependencies[] = $dependentFile; } foreach ($dependencies->getFilePaths() as $dependentFile) { $this->fileDependencies[] = $dependentFile; } - $nonAnalysedDependencies = $dependencies->getNonAnalysedDependencies($scope->getFile(), $this->analysedFiles, $this->packageDependencyResolver); - foreach ($nonAnalysedDependencies['files'] as $dependentFile) { + foreach ($fileAndPackageDependencies['nonAnalysedFiles'] as $dependentFile) { $this->fileDependencies[] = $dependentFile; } - foreach ($nonAnalysedDependencies['packages'] as $package) { + foreach ($fileAndPackageDependencies['packages'] as $package) { $this->filePackageDependencies[] = $package; } if ($dependencies->getExportedNode() !== null) { @@ -254,15 +254,15 @@ public function __invoke(Node $node, Scope $scope): void return; } - $usedTraitDependencies = $this->dependencyResolver->resolveUsedTraitDependencies($node); - foreach ($usedTraitDependencies->getFileDependencies($scope->getFile(), $this->analysedFiles) as $dependentFile) { + $usedTraitDependencies = $this->dependencyResolver->resolveUsedTraitDependencies($node) + ->getFileAndPackageDependencies($scope->getFile(), $this->analysedFiles, $this->packageDependencyResolver); + foreach ($usedTraitDependencies['analysedFiles'] as $dependentFile) { $this->usedTraitFileDependencies[] = $dependentFile; } - $nonAnalysedTraitDependencies = $usedTraitDependencies->getNonAnalysedDependencies($scope->getFile(), $this->analysedFiles, $this->packageDependencyResolver); - foreach ($nonAnalysedTraitDependencies['files'] as $dependentFile) { + foreach ($usedTraitDependencies['nonAnalysedFiles'] as $dependentFile) { $this->usedTraitFileDependencies[] = $dependentFile; } - foreach ($nonAnalysedTraitDependencies['packages'] as $package) { + foreach ($usedTraitDependencies['packages'] as $package) { $this->filePackageDependencies[] = $package; } } diff --git a/src/Analyser/ResultCache/ResultCacheManager.php b/src/Analyser/ResultCache/ResultCacheManager.php index 6dc80726563..8dfc961c848 100644 --- a/src/Analyser/ResultCache/ResultCacheManager.php +++ b/src/Analyser/ResultCache/ResultCacheManager.php @@ -406,7 +406,7 @@ public function restore(array $allAnalysedFiles, bool $debug, bool $onlyFiles, ? if (in_array('scannedFiles', $diffs, true)) { // Files that are scanned but not analysed are recorded as regular file dependencies - // (NodeDependencies::getNonAnalysedDependencies()) along with their exported nodes, so the + // (NodeDependencies::getFileAndPackageDependencies()) along with their exported nodes, so the // loop over the not-analysed files below treats an edited one the way the loop above // treats an analysed file: the files depending on it are re-analysed only when its // exported nodes changed, and it is that loop which counts it as changed. What is left @@ -418,7 +418,7 @@ public function restore(array $allAnalysedFiles, bool $debug, bool $onlyFiles, ? // stands in for "declares a new symbol". $changedScannedFiles = $this->getChangedScannedFiles($data['meta'], $meta); foreach ($changedScannedFiles as $changedScannedFile => $change) { - // The scanned files getNonAnalysedDependencies() leaves out: nothing would + // The scanned files getFileAndPackageDependencies() leaves out: nothing would // re-analyse the files depending on them, so the whole cache has to go. $notTrackedReason = null; if (str_starts_with($changedScannedFile, 'phar://')) { diff --git a/src/Dependency/DependencyResolver.php b/src/Dependency/DependencyResolver.php index caaa2117b07..cee316199de 100644 --- a/src/Dependency/DependencyResolver.php +++ b/src/Dependency/DependencyResolver.php @@ -35,16 +35,16 @@ use PHPStan\Type\FileTypeMapper; use PHPStan\Type\Type; use function array_key_exists; -use function array_merge; use function count; use function in_array; use function is_file; +use function spl_object_id; #[AutowiredService] final class DependencyResolver { - /** @var array> */ + /** @var array> reflections keyed by spl_object_id() */ private array $classDependencies = []; private ExportedNameScopeTracker $nameScopeTracker; @@ -74,6 +74,9 @@ public function resolveDependencies(Node $node, Scope $scope): NodeDependencies } $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 + // ancestors - is kept only once instead of being resolved to its file and package once more. $dependenciesReflections = []; $dependenciesFilePaths = []; @@ -181,7 +184,7 @@ public function resolveDependencies(Node $node, Scope $scope): NodeDependencies if ($functionName instanceof Node\Name) { try { $functionReflection = $this->getFunctionReflection($functionName, $scope); - $dependenciesReflections[] = $functionReflection; + $dependenciesReflections[spl_object_id($functionReflection)] = $functionReflection; foreach ($functionReflection->getVariants() as $functionVariant) { foreach ($functionVariant->getParameters() as $parameter) { @@ -408,7 +411,8 @@ public function resolveDependencies(Node $node, Scope $scope): NodeDependencies !in_array($constantName->toLowerString(), ['true', 'false', 'null'], true) && $this->reflectionProvider->hasConstant($constantName, $scope) ) { - $dependenciesReflections[] = $this->reflectionProvider->getConstant($constantName, $scope); + $constantReflection = $this->reflectionProvider->getConstant($constantName, $scope); + $dependenciesReflections[spl_object_id($constantReflection)] = $constantReflection; } } elseif ($node instanceof Node\Expr\StaticPropertyFetch) { if ($node->class instanceof Node\Name) { @@ -550,21 +554,13 @@ public function resolveDependencies(Node $node, Scope $scope): NodeDependencies } } } elseif ($node instanceof StaticMethodCallableNode) { - foreach ($this->resolveDependencies(new Node\Expr\StaticCall($node->getClass(), $node->getName()), $scope)->getReflections() as $dependency) { - $dependenciesReflections[] = $dependency; - } + $dependenciesReflections += $this->resolveDependencies(new Node\Expr\StaticCall($node->getClass(), $node->getName()), $scope)->getReflections(); } elseif ($node instanceof MethodCallableNode) { - foreach ($this->resolveDependencies(new Node\Expr\MethodCall($node->getVar(), $node->getName()), $scope)->getReflections() as $dependency) { - $dependenciesReflections[] = $dependency; - } + $dependenciesReflections += $this->resolveDependencies(new Node\Expr\MethodCall($node->getVar(), $node->getName()), $scope)->getReflections(); } elseif ($node instanceof FunctionCallableNode) { - foreach ($this->resolveDependencies(new Node\Expr\FuncCall($node->getName()), $scope)->getReflections() as $dependency) { - $dependenciesReflections[] = $dependency; - } + $dependenciesReflections += $this->resolveDependencies(new Node\Expr\FuncCall($node->getName()), $scope)->getReflections(); } elseif ($node instanceof InstantiationCallableNode) { - foreach ($this->resolveDependencies(new Node\Expr\New_($node->getClass()), $scope)->getReflections() as $dependency) { - $dependenciesReflections[] = $dependency; - } + $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); @@ -653,11 +649,11 @@ private function addClassToDependencies(string $className, array &$dependenciesR $this->classDependencies[$className] = $this->buildClassDependencies($className); } - $dependenciesReflections = array_merge($dependenciesReflections, $this->classDependencies[$className]); + $dependenciesReflections += $this->classDependencies[$className]; } /** - * @return list + * @return array */ private function buildClassDependencies(string $className): array { @@ -809,7 +805,12 @@ private function buildClassDependencies(string $className): array $classReflection = $classReflection->getParentClass(); } while ($classReflection !== null); - return $dependencies; + $uniqueDependencies = []; + foreach ($dependencies as $dependency) { + $uniqueDependencies[spl_object_id($dependency)] = $dependency; + } + + return $uniqueDependencies; } private function getFunctionReflection(Node\Name $nameNode, ?Scope $scope): FunctionReflection diff --git a/src/Dependency/NodeDependencies.php b/src/Dependency/NodeDependencies.php index d79395c7e09..614ccd3298e 100644 --- a/src/Dependency/NodeDependencies.php +++ b/src/Dependency/NodeDependencies.php @@ -45,52 +45,19 @@ public function getReflections(): array } /** - * @param array $analysedFiles - * @return string[] - */ - public function getFileDependencies(string $currentFile, array $analysedFiles): array - { - $dependencies = []; - - foreach ($this->reflections as $dependencyReflection) { - $dependencyFile = $dependencyReflection->getFileName(); - if ($dependencyFile === null) { - continue; - } - if ($currentFile === $dependencyFile) { - continue; - } - - $dependencyFile = $this->fileHelper->normalizePath($dependencyFile); - - if ($currentFile === $dependencyFile) { - continue; - } - - if (!isset($analysedFiles[$dependencyFile])) { - continue; - } - - $dependencies[$dependencyFile] = $dependencyFile; - } - - return array_values($dependencies); - } - - /** - * The dependency files getFileDependencies() drops because they are not analysed, split into the - * two ways the result cache tracks them: + * The files and packages this node depends on, resolved in a single pass over its reflections: * + * - "analysedFiles": dependency files that are analysed themselves. * - "packages": files of an installed Composer package, resolved to the package name, so that a * composer.lock change re-analyses only the files depending on a package whose version changed. - * - "files": the remaining project files - listed in scanFiles/scanDirectories, excluded from the - * analysis but living in an analysed directory, or simply reached through the autoloader - + * - "nonAnalysedFiles": the remaining project files - listed in scanFiles/scanDirectories, excluded + * from the analysis but living in an analysed directory, or simply reached through the autoloader - * recorded as regular file dependencies, so that editing one of them re-analyses only the files * depending on it instead of invalidating the whole result cache. A package installed from a * path repository is in both: it is the project's own code, edited without Composer noticing. * * Files inside a PHAR belong to the running PHPStan itself and cannot change without its version - * changing, so they are left out of both. + * changing, so they are left out of "packages" and "nonAnalysedFiles". * * Built-in symbols of an extension whose stubs differ between its major versions are recorded in * "packages" too, under the extension's platform package name (ext-), so that selecting a @@ -99,12 +66,17 @@ public function getFileDependencies(string $currentFile, array $analysedFiles): * with the selected version. * * @param array $analysedFiles - * @return array{packages: list, files: list} + * @return array{analysedFiles: list, nonAnalysedFiles: list, packages: list} */ - public function getNonAnalysedDependencies(string $currentFile, array $analysedFiles, PackageDependencyResolver $packageDependencyResolver): array + public function getFileAndPackageDependencies(string $currentFile, array $analysedFiles, PackageDependencyResolver $packageDependencyResolver): array { + if ($this->reflections === []) { + return ['analysedFiles' => [], 'nonAnalysedFiles' => [], 'packages' => []]; + } + + $analysedDependencies = []; + $nonAnalysedDependencies = []; $packages = []; - $files = []; foreach ($this->reflections as $dependencyReflection) { $extensionPackage = $packageDependencyResolver->resolveVersionedExtensionPackage($dependencyReflection); @@ -116,23 +88,25 @@ public function getNonAnalysedDependencies(string $currentFile, array $analysedF if ($dependencyFile === null) { continue; } - - if (str_starts_with($dependencyFile, 'phar://')) { + if ($currentFile === $dependencyFile) { continue; } - $dependencyFile = $this->fileHelper->normalizePath($dependencyFile); + $normalizedDependencyFile = $this->fileHelper->normalizePath($dependencyFile); + if ($currentFile === $normalizedDependencyFile) { + continue; + } - if ($currentFile === $dependencyFile) { + if (isset($analysedFiles[$normalizedDependencyFile])) { + $analysedDependencies[$normalizedDependencyFile] = $normalizedDependencyFile; continue; } - if (isset($analysedFiles[$dependencyFile])) { - // already returned by getFileDependencies() + if (str_starts_with($dependencyFile, 'phar://')) { continue; } - $package = $packageDependencyResolver->resolvePackage($dependencyFile); + $package = $packageDependencyResolver->resolvePackage($normalizedDependencyFile); if ($package !== null) { $packages[$package] = $package; @@ -146,12 +120,13 @@ public function getNonAnalysedDependencies(string $currentFile, array $analysedF } } - $files[$dependencyFile] = $dependencyFile; + $nonAnalysedDependencies[$normalizedDependencyFile] = $normalizedDependencyFile; } return [ + 'analysedFiles' => array_values($analysedDependencies), + 'nonAnalysedFiles' => array_values($nonAnalysedDependencies), 'packages' => array_values($packages), - 'files' => array_values($files), ]; } diff --git a/src/Dependency/PackageDependencyResolver.php b/src/Dependency/PackageDependencyResolver.php index ada5926316a..b75f550da9c 100644 --- a/src/Dependency/PackageDependencyResolver.php +++ b/src/Dependency/PackageDependencyResolver.php @@ -146,26 +146,25 @@ public function getChangedComposerPackages(array $cachedMeta, array $currentMeta */ public function resolveVersionedExtensionPackage(ClassReflection|FunctionReflection|ConstantReflection $reflection): ?string { + // This is called for every dependency of every analysed node, and nearly none of them is one of + // these symbols, so the cheap lookup by name goes first - asking whether the symbol is built-in + // goes through several layers of reflection. $symbols = $this->getVersionedExtensionSymbols(); if ($reflection instanceof ClassReflection) { - if (!$reflection->isBuiltin()) { + $extensionName = $symbols['classes'][strtolower($reflection->getName())] ?? null; + if ($extensionName === null || !$reflection->isBuiltin()) { return null; } - $extensionName = $symbols['classes'][strtolower($reflection->getName())] ?? null; } elseif ($reflection instanceof FunctionReflection) { - if (!$reflection->isBuiltin()) { + $extensionName = $symbols['functions'][strtolower($reflection->getName())] ?? null; + if ($extensionName === null || !$reflection->isBuiltin()) { return null; } - $extensionName = $symbols['functions'][strtolower($reflection->getName())] ?? null; } else { - if (!$reflection->isBuiltin()->yes()) { + $extensionName = $symbols['constants'][$reflection->getName()] ?? null; + if ($extensionName === null || !$reflection->isBuiltin()->yes()) { return null; } - $extensionName = $symbols['constants'][$reflection->getName()] ?? null; - } - - if ($extensionName === null) { - return null; } return 'ext-' . $extensionName;