From 7860798cad848f67ec1a63fa46870382d90bc1fb Mon Sep 17 00:00:00 2001 From: Tomas Votruba Date: Sun, 2 Aug 2026 23:31:00 +0200 Subject: [PATCH] [Testing] Allow a test case to resolve package versions from a standalone composer.json A rule that implements ComposerPackageConstraintInterface is filtered out unless the bonded package is installed, which includes its own test run. Extensions cannot always add the package to require-dev, so there was no way to test such a rule. AbstractRectorTestCase::provideComposerJsonFilePath() now points at a standalone "composer.json"; its "require" and "require-dev" constraints become the resolved versions, using the lowest version each one allows. --- src/Composer/InstalledPackageResolver.php | 62 ++++++++++++++++++- .../PHPUnit/AbstractRectorTestCase.php | 23 +++++++ .../standalone_composer_json/composer.json | 10 +++ .../Composer/InstalledPackageResolverTest.php | 34 ++++++++++ 4 files changed, 126 insertions(+), 3 deletions(-) create mode 100644 tests/Composer/Fixture/InstalledPackageResolver/standalone_composer_json/composer.json diff --git a/src/Composer/InstalledPackageResolver.php b/src/Composer/InstalledPackageResolver.php index 96e1f265d23..b92eed1cf99 100644 --- a/src/Composer/InstalledPackageResolver.php +++ b/src/Composer/InstalledPackageResolver.php @@ -33,14 +33,32 @@ final class InstalledPackageResolver private readonly string $projectDirectory; - public function __construct(?string $projectDirectory = null) - { + /** + * @param null|string $composerJsonFilePath a standalone "composer.json" to read the versions from, instead of the + * installed packages; used to test rules bonded to a composer package + */ + public function __construct( + ?string $projectDirectory = null, + private ?string $composerJsonFilePath = null + ) { // fallback to root project directory $this->projectDirectory = $projectDirectory ?? (string) getcwd(); Assert::directory($this->projectDirectory); } + /** + * @api used in tests to resolve the versions from a standalone "composer.json" + */ + public function changeComposerJsonFilePath(?string $composerJsonFilePath): void + { + $this->composerJsonFilePath = $composerJsonFilePath; + + // the previous file is no longer the source, drop what was read from it + $this->resolvedInstalledPackages = null; + $this->projectComposerJson = null; + } + /** * @return array */ @@ -51,6 +69,10 @@ public function resolve(): array return $this->resolvedInstalledPackages; } + if ($this->composerJsonFilePath !== null) { + return $this->resolvedInstalledPackages = $this->createPackagesFromConstraints(); + } + $installedPackagesFilePath = $this->resolveVendorDir() . '/composer/installed.json'; if (! file_exists($installedPackagesFilePath)) { throw new ShouldNotHappenException( @@ -105,6 +127,27 @@ private function createInstalledPackages(array $packages): array return $installedPackages; } + /** + * There is no vendor to read the installed versions from, so the constraints themselves are the only source + * + * @return array + */ + private function createPackagesFromConstraints(): array + { + $installedPackages = []; + + foreach ($this->resolvePackageConstraints() as $packageName => $constraint) { + $version = $this->resolveConstraintLowestVersion($constraint); + if ($version === null) { + continue; + } + + $installedPackages[$packageName] = new InstalledPackage($packageName, $version); + } + + return $installedPackages; + } + /** * @return null|string the lowest version allowed by the constraint, if the installed version is out of it */ @@ -114,7 +157,20 @@ private function matchConstraintVersion(string $installedVersion, string $constr if (Semver::satisfies($installedVersion, $constraint)) { return null; } + } catch (UnexpectedValueException) { + // non-comparable version or constraint, e.g. a dev one + return null; + } + + return $this->resolveConstraintLowestVersion($constraint); + } + /** + * @return null|string the lowest version the constraint allows, null if there is no comparable one + */ + private function resolveConstraintLowestVersion(string $constraint): ?string + { + try { $lowestVersion = new VersionParser() ->parseConstraints($constraint) ->getLowerBound() @@ -171,7 +227,7 @@ private function loadProjectComposerJson(): array return $this->projectComposerJson; } - $projectComposerJsonFilePath = $this->projectDirectory . '/composer.json'; + $projectComposerJsonFilePath = $this->composerJsonFilePath ?? $this->projectDirectory . '/composer.json'; if (! file_exists($projectComposerJsonFilePath)) { return $this->projectComposerJson = []; } diff --git a/src/Testing/PHPUnit/AbstractRectorTestCase.php b/src/Testing/PHPUnit/AbstractRectorTestCase.php index 5954d8e8832..0380f667309 100644 --- a/src/Testing/PHPUnit/AbstractRectorTestCase.php +++ b/src/Testing/PHPUnit/AbstractRectorTestCase.php @@ -12,6 +12,7 @@ use Rector\Application\ApplicationFileProcessor; use Rector\Autoloading\AdditionalAutoloader; use Rector\Autoloading\BootstrapFilesIncluder; +use Rector\Composer\InstalledPackageResolver; use Rector\Configuration\ConfigurationFactory; use Rector\Configuration\Option; use Rector\Configuration\Parameter\SimpleParameterProvider; @@ -77,6 +78,16 @@ protected function setUp(): void $cacheKey = sha1($configFile . static::class); if (! isset(self::$cacheByRuleAndConfig[$cacheKey])) { + // rules bonded to a composer package are filtered out unless the package is installed. + // the composer package constraint filter keeps the very first resolver it is given, so the binding must + // stay untouched; only the file it reads changes, on every test case, so it never leaks to the next one + if (! $rectorConfig->bound(InstalledPackageResolver::class)) { + $rectorConfig->singleton(InstalledPackageResolver::class); + } + + $rectorConfig->make(InstalledPackageResolver::class) + ->changeComposerJsonFilePath($this->provideComposerJsonFilePath()); + // reset /** @var RewindableGenerator $resettables */ $resettables = $rectorConfig->tagged(ResettableInterface::class); @@ -133,6 +144,18 @@ protected static function yieldFilesFromDirectory(string $directory, string $suf return FixtureFileFinder::yieldDirectory($directory, $suffix); } + /** + * Override to test a rule that implements @see \Rector\VersionBonding\Contract\ComposerPackageConstraintInterface + * against a package that is not installed. The versions are read from the "require" and "require-dev" sections of + * the provided "composer.json", instead of the installed packages. + * + * @api used by extensions + */ + protected function provideComposerJsonFilePath(): ?string + { + return null; + } + protected function doTestFile(string $fixtureFilePath, bool $includeFixtureDirectoryAsSource = false): void { // prepare input file contents and expected file output contents diff --git a/tests/Composer/Fixture/InstalledPackageResolver/standalone_composer_json/composer.json b/tests/Composer/Fixture/InstalledPackageResolver/standalone_composer_json/composer.json new file mode 100644 index 00000000000..7d9b905d5a3 --- /dev/null +++ b/tests/Composer/Fixture/InstalledPackageResolver/standalone_composer_json/composer.json @@ -0,0 +1,10 @@ +{ + "require": { + "symfony/framework-bundle": "^2.6", + "nette/utils": ">=3.2", + "webmozart/assert": "*" + }, + "require-dev": { + "phpunit/phpunit": "^10.5" + } +} diff --git a/tests/Composer/InstalledPackageResolverTest.php b/tests/Composer/InstalledPackageResolverTest.php index 015cb44b3aa..1a0e020f120 100644 --- a/tests/Composer/InstalledPackageResolverTest.php +++ b/tests/Composer/InstalledPackageResolverTest.php @@ -46,4 +46,38 @@ public function testComposerJsonHasPriorityOverOutdatedInstalledJson(): void // not required in the "composer.json" at all $this->assertSame('1.11.0.0', $installedPackageResolver->resolvePackageVersion('webmozart/assert')); } + + public function testStandaloneComposerJsonResolvesVersionsWithoutVendor(): void + { + $installedPackageResolver = new InstalledPackageResolver( + null, + __DIR__ . '/Fixture/InstalledPackageResolver/standalone_composer_json/composer.json' + ); + + // the package is not installed anywhere, the constraint is the only source + $this->assertSame('2.6.0.0', $installedPackageResolver->resolvePackageVersion('symfony/framework-bundle')); + + // require-dev is respected as well + $this->assertSame('10.5.0.0', $installedPackageResolver->resolvePackageVersion('phpunit/phpunit')); + + // an open constraint has no lowest version to fall back to + $this->assertNull($installedPackageResolver->resolvePackageVersion('webmozart/assert')); + + // not required in the "composer.json" at all + $this->assertNull($installedPackageResolver->resolvePackageVersion('symfony/console')); + } + + public function testChangeComposerJsonFilePathDropsPreviousVersions(): void + { + $installedPackageResolver = new InstalledPackageResolver(getcwd()); + $this->assertNull($installedPackageResolver->resolvePackageVersion('symfony/framework-bundle')); + + $installedPackageResolver->changeComposerJsonFilePath( + __DIR__ . '/Fixture/InstalledPackageResolver/standalone_composer_json/composer.json' + ); + $this->assertSame('2.6.0.0', $installedPackageResolver->resolvePackageVersion('symfony/framework-bundle')); + + $installedPackageResolver->changeComposerJsonFilePath(null); + $this->assertNull($installedPackageResolver->resolvePackageVersion('symfony/framework-bundle')); + } }