From b8713b58419bf1f63820bbac37da94aa7d08d0dd Mon Sep 17 00:00:00 2001 From: Sander Muller Date: Wed, 1 Jul 2026 11:41:45 +0200 Subject: [PATCH] PackageDependencyResolver: memoize resolvePackage per file resolvePackage() maps a file to its owning Composer package by scanning every install path with str_starts_with(). It is called from NodeDependencies::getPackageDependencies() once per dependency file of every analysed file, so the same vendor files are resolved over and over, and each call is linear in the number of installed packages. The install-path map is already memoized, but the per-file lookup was not, so on projects with a large dependency tree this scan dominates cold analysis. Memoize the result per file, including the null result for files that belong to no project package (PHPStan's own bundled dependencies), which otherwise pay a full scan on every call. The resolver is a singleton service, so the cache persists across all files in a worker. Same approach as 200a20379 (DependencyResolver: reduce duplicate work). The result is unchanged: the cache is keyed by file, and a file's owning package cannot change during a run. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Dependency/PackageDependencyResolver.php | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/Dependency/PackageDependencyResolver.php b/src/Dependency/PackageDependencyResolver.php index b707e7a5f1..698e5867be 100644 --- a/src/Dependency/PackageDependencyResolver.php +++ b/src/Dependency/PackageDependencyResolver.php @@ -29,6 +29,9 @@ final class PackageDependencyResolver /** @var array|null normalized install path => package name, longest path first */ private ?array $installPathToPackage = null; + /** @var array file => resolved package (or null for none) */ + private array $resolvedPackages = []; + /** @param string[] $composerAutoloaderProjectPaths */ public function __construct( private array $composerAutoloaderProjectPaths, @@ -38,6 +41,19 @@ public function __construct( } public function resolvePackage(string $file): ?string + { + // This is called once per dependency file of every analysed file, so the same vendor files + // get resolved over and over. Memoize per file: without it this scans every install path + // (linear in the number of installed packages) on each call, which dominates cold analysis + // of projects with many dependencies. + if (array_key_exists($file, $this->resolvedPackages)) { + return $this->resolvedPackages[$file]; + } + + return $this->resolvedPackages[$file] = $this->doResolvePackage($file); + } + + private function doResolvePackage(string $file): ?string { // Normalize with a forward slash regardless of platform: normalizePath() defaults to // DIRECTORY_SEPARATOR, so on Windows the paths would use '\' while the prefix check below