Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 21 additions & 3 deletions src/Composer/InstalledPackageResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ public function resolvePackageVersion(string $packageName): ?string
private function createInstalledPackages(array $packages): array
{
$packageConstraints = $this->resolvePackageConstraints();
$isLibrary = $this->isLibrary();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think library check is not needed, even on project, eg on "framework skeleton", phpunit range may exists to give user ability to use phpunit version based on specific php version.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The goal is to give projects real latest versions (those in installed.json),
and to give packagest the lowest safest ones. As ^7.3 can be 7.3.0 on a library, but 7.3.50 on final real project

$installedPackages = [];

foreach ($packages as $package) {
Expand All @@ -116,9 +117,15 @@ private function createInstalledPackages(array $packages): array

$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;
if ($isLibrary) {
// a library must stay compatible with the lowest version it declares,
// regardless of which one happens to be installed locally
$version = $this->resolveConstraintLowestVersion($constraint) ?? $version;
} else {
// 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);
Expand All @@ -127,6 +134,17 @@ private function createInstalledPackages(array $packages): array
return $installedPackages;
}

/**
* A library declares a compatibility range in its "composer.json"; the version-specific rules must target the
* lowest declared version, not the one that happens to be installed locally.
*/
private function isLibrary(): bool
{
$projectComposerJson = $this->loadProjectComposerJson();

return ($projectComposerJson['type'] ?? null) === 'library';

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is wrong. It should be inverted to isProject() and compared to project. Otherwise you exclude symfony-bundle etc. pp. :)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"type" may be missing, most likely in projects, so we have to go with a default here.
Could you elaborate a bit more?

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should consider something a project if type equals project. Everything else is not a project and thus should be considered a "library".

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Or in other words: the current solution does not fix the original issue. I have a type symfony-bundle and thus I still get the wrong rules applied.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I get your point, but if we do that, all projects will be upgraded only when someone fill the keyword explicitly in composer.json.

If a keyword is an issue, then other non-project keywords should be included as well.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Personally, I'm convinced there should be no check at all and the composer.json should always win. I always want to have to rules applied to "what is compatible", never ever to "what is installed".

@TomasVotruba TomasVotruba Aug 22, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's why composer.lock/installed.json is present. When project uses ^7.4, they rarely want to use only featuers in 7.4.0. Which is version mostly skipped for being to risky to use.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's exactly the point. If they specify ^7.4, the code must be compatible with 7.4.0. So the composer.json decides, no matter if your installed version is 7.4, 8.0 or 8.5.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree with @Toflar here. composer.json should always be leading, as that signals which what versions the project is compatible with. Installed versions can always be downgraded in a later stage (if another dependency adds an additional constraint for example), but will never be downgraded beyond the given range.

}

/**
* There is no vendor to read the installed versions from, so the constraints themselves are the only source
*
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"type": "library",
"require": {
"phpunit/phpunit": "^10.5 || ^11.0 || ^12.0",
"symfony/console": "^7.0"
},
"require-dev": {
"nette/utils": "^3.2"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
{
"packages": [
{
"name": "phpunit/phpunit",
"version": "12.1.0",
"version_normalized": "12.1.0.0"
},
{
"name": "symfony/console",
"version": "v7.2.0",
"version_normalized": "7.2.0.0"
},
{
"name": "nette/utils",
"version": "v3.2.0",
"version_normalized": "3.2.0.0"
},
{
"name": "webmozart/assert",
"version": "1.11.0",
"version_normalized": "1.11.0.0"
}
]
}
19 changes: 19 additions & 0 deletions tests/Composer/InstalledPackageResolverTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,25 @@ public function testComposerJsonHasPriorityOverOutdatedInstalledJson(): void
$this->assertSame('1.11.0.0', $installedPackageResolver->resolvePackageVersion('webmozart/assert'));
}

public function testLibraryTargetsLowestDeclaredVersionEvenWhenInstalledSatisfies(): void
{
$installedPackageResolver = new InstalledPackageResolver(
__DIR__ . '/Fixture/InstalledPackageResolver/library_composer_json'
);

// installed 12.1.0 satisfies "^10.5 || ^11.0 || ^12.0", yet a library targets the lowest declared version
$this->assertSame('10.5.0.0', $installedPackageResolver->resolvePackageVersion('phpunit/phpunit'));

// installed 7.2.0 satisfies "^7.0", still lowered to the declared floor
$this->assertSame('7.0.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", the installed version stands
$this->assertSame('1.11.0.0', $installedPackageResolver->resolvePackageVersion('webmozart/assert'));
}

public function testStandaloneComposerJsonResolvesVersionsWithoutVendor(): void
{
$installedPackageResolver = new InstalledPackageResolver(
Expand Down
Loading