From 5cc1027b7cb6cfee5ea4261bd489591f65a69c14 Mon Sep 17 00:00:00 2001 From: Tomas Votruba Date: Fri, 31 Jul 2026 18:20:37 +0200 Subject: [PATCH] [Composer] Prefer composer.json constraint over outdated installed.json version --- src/Composer/InstalledPackageResolver.php | 120 +++++++++++++++--- .../outdated_installed_json/composer.json | 9 ++ .../vendor/composer/installed.json | 24 ++++ .../Composer/InstalledPackageResolverTest.php | 19 +++ 4 files changed, 157 insertions(+), 15 deletions(-) create mode 100644 tests/Composer/Fixture/InstalledPackageResolver/outdated_installed_json/composer.json create mode 100644 tests/Composer/Fixture/InstalledPackageResolver/outdated_installed_json/vendor/composer/installed.json diff --git a/src/Composer/InstalledPackageResolver.php b/src/Composer/InstalledPackageResolver.php index 3593c9e5d4c..cd863fc7169 100644 --- a/src/Composer/InstalledPackageResolver.php +++ b/src/Composer/InstalledPackageResolver.php @@ -4,11 +4,14 @@ namespace Rector\Composer; +use Composer\Semver\Semver; +use Composer\Semver\VersionParser; use Nette\Utils\FileSystem; use Nette\Utils\Json; use Rector\Composer\ValueObject\InstalledPackage; use Rector\Exception\ShouldNotHappenException; use Rector\Skipper\FileSystem\PathNormalizer; +use UnexpectedValueException; use Webmozart\Assert\Assert; /** @@ -16,11 +19,18 @@ */ final class InstalledPackageResolver { + private const string ANY_VERSION_LOWER_BOUND = '0.0.0.0-dev'; + /** * @var null|array */ private ?array $resolvedInstalledPackages = null; + /** + * @var null|array + */ + private ?array $projectComposerJson = null; + private readonly string $projectDirectory; public function __construct(?string $projectDirectory = null) @@ -75,36 +85,116 @@ public function resolvePackageVersion(string $packageName): ?string */ private function createInstalledPackages(array $packages): array { + $packageConstraints = $this->resolvePackageConstraints(); $installedPackages = []; foreach ($packages as $package) { $name = $package['name']; - $installedPackages[$name] = new InstalledPackage($name, $package['version_normalized']); + $version = $package['version_normalized']; + + $constraint = $packageConstraints[$name] ?? null; + if (is_string($constraint)) { + // the "installed.json" can be outdated, e.g. after a branch switch; + // in such case the "composer.json" constraint has a priority + $version = $this->matchConstraintVersion($version, $constraint) ?? $version; + } + + $installedPackages[$name] = new InstalledPackage($name, $version); } return $installedPackages; } - private function resolveVendorDir(): string + /** + * @return null|string the lowest version allowed by the constraint, if the installed version is out of it + */ + private function matchConstraintVersion(string $installedVersion, string $constraint): ?string { - $projectComposerJsonFilePath = $this->projectDirectory . '/composer.json'; + try { + if (Semver::satisfies($installedVersion, $constraint)) { + return null; + } - if (\file_exists($projectComposerJsonFilePath)) { - $projectComposerContents = FileSystem::read($projectComposerJsonFilePath); - $projectComposerJson = Json::decode($projectComposerContents, true); + $lowestVersion = new VersionParser()->parseConstraints($constraint) + ->getLowerBound() + ->getVersion(); + } catch (UnexpectedValueException) { + // non-comparable version or constraint, e.g. a dev one + return null; + } - if (isset($projectComposerJson['config']['vendor-dir']) && - is_string($projectComposerJson['config']['vendor-dir']) - ) { - $realPathVendorDir = realpath($projectComposerJson['config']['vendor-dir']) ?: ''; - $normalizedRealPathVendorDir = PathNormalizer::normalize($realPathVendorDir); - $normalizedVendorDir = PathNormalizer::normalize($projectComposerJson['config']['vendor-dir']); + // the constraint allows any version, nothing to fall back to + if ($lowestVersion === self::ANY_VERSION_LOWER_BOUND) { + return null; + } - return $normalizedRealPathVendorDir === $normalizedVendorDir - ? $projectComposerJson['config']['vendor-dir'] - : $this->projectDirectory . '/' . $projectComposerJson['config']['vendor-dir']; + // the lower bound is a dev one, e.g. "10.5.0.0-dev" for the "^10.5" constraint + if (str_ends_with($lowestVersion, '-dev')) { + return substr($lowestVersion, 0, -strlen('-dev')); + } + + return $lowestVersion; + } + + /** + * @return array package name to the "composer.json" version constraint + */ + private function resolvePackageConstraints(): array + { + $projectComposerJson = $this->loadProjectComposerJson(); + + $packageConstraints = []; + + foreach (['require', 'require-dev'] as $section) { + $requiredPackages = $projectComposerJson[$section] ?? null; + if (! is_array($requiredPackages)) { + continue; } + foreach ($requiredPackages as $packageName => $constraint) { + if (is_string($packageName) && is_string($constraint)) { + $packageConstraints[$packageName] = $constraint; + } + } + } + + return $packageConstraints; + } + + /** + * @return array + */ + private function loadProjectComposerJson(): array + { + if ($this->projectComposerJson !== null) { + return $this->projectComposerJson; + } + + $projectComposerJsonFilePath = $this->projectDirectory . '/composer.json'; + if (! file_exists($projectComposerJsonFilePath)) { + return $this->projectComposerJson = []; + } + + $projectComposerContents = FileSystem::read($projectComposerJsonFilePath); + $projectComposerJson = Json::decode($projectComposerContents, true); + + return $this->projectComposerJson = is_array($projectComposerJson) ? $projectComposerJson : []; + } + + private function resolveVendorDir(): string + { + $projectComposerJson = $this->loadProjectComposerJson(); + + if (isset($projectComposerJson['config']['vendor-dir']) && + is_string($projectComposerJson['config']['vendor-dir']) + ) { + $realPathVendorDir = realpath($projectComposerJson['config']['vendor-dir']) ?: ''; + $normalizedRealPathVendorDir = PathNormalizer::normalize($realPathVendorDir); + $normalizedVendorDir = PathNormalizer::normalize($projectComposerJson['config']['vendor-dir']); + + return $normalizedRealPathVendorDir === $normalizedVendorDir + ? $projectComposerJson['config']['vendor-dir'] + : $this->projectDirectory . '/' . $projectComposerJson['config']['vendor-dir']; } return $this->projectDirectory . '/vendor'; diff --git a/tests/Composer/Fixture/InstalledPackageResolver/outdated_installed_json/composer.json b/tests/Composer/Fixture/InstalledPackageResolver/outdated_installed_json/composer.json new file mode 100644 index 00000000000..cf17f1ecde0 --- /dev/null +++ b/tests/Composer/Fixture/InstalledPackageResolver/outdated_installed_json/composer.json @@ -0,0 +1,9 @@ +{ + "require": { + "phpunit/phpunit": "^10.5", + "symfony/console": "^7.0" + }, + "require-dev": { + "nette/utils": "^3.2" + } +} diff --git a/tests/Composer/Fixture/InstalledPackageResolver/outdated_installed_json/vendor/composer/installed.json b/tests/Composer/Fixture/InstalledPackageResolver/outdated_installed_json/vendor/composer/installed.json new file mode 100644 index 00000000000..77ec6fd1fbd --- /dev/null +++ b/tests/Composer/Fixture/InstalledPackageResolver/outdated_installed_json/vendor/composer/installed.json @@ -0,0 +1,24 @@ +{ + "packages": [ + { + "name": "phpunit/phpunit", + "version": "11.5.2", + "version_normalized": "11.5.2.0" + }, + { + "name": "symfony/console", + "version": "v7.2.0", + "version_normalized": "7.2.0.0" + }, + { + "name": "nette/utils", + "version": "v4.0.5", + "version_normalized": "4.0.5.0" + }, + { + "name": "webmozart/assert", + "version": "1.11.0", + "version_normalized": "1.11.0.0" + } + ] +} diff --git a/tests/Composer/InstalledPackageResolverTest.php b/tests/Composer/InstalledPackageResolverTest.php index 81396d6edf3..015cb44b3aa 100644 --- a/tests/Composer/InstalledPackageResolverTest.php +++ b/tests/Composer/InstalledPackageResolverTest.php @@ -27,4 +27,23 @@ public function testFallbackToCurrentWorkingDirectory(): void $this->assertContainsOnlyInstancesOf(InstalledPackage::class, $installedPackages); $this->assertGreaterThan(77, count($installedPackages)); } + + public function testComposerJsonHasPriorityOverOutdatedInstalledJson(): void + { + $installedPackageResolver = new InstalledPackageResolver( + __DIR__ . '/Fixture/InstalledPackageResolver/outdated_installed_json' + ); + + // the "installed.json" is outdated and contains 11.5.2.0 + $this->assertSame('10.5.0.0', $installedPackageResolver->resolvePackageVersion('phpunit/phpunit')); + + // the installed version matches the "composer.json" constraint + $this->assertSame('7.2.0.0', $installedPackageResolver->resolvePackageVersion('symfony/console')); + + // require-dev is respected as well + $this->assertSame('3.2.0.0', $installedPackageResolver->resolvePackageVersion('nette/utils')); + + // not required in the "composer.json" at all + $this->assertSame('1.11.0.0', $installedPackageResolver->resolvePackageVersion('webmozart/assert')); + } }