Skip to content

Commit 5ff3950

Browse files
committed
Allow a dependency to name the Grav generation it is for
A plugin supporting both 1.7 and 2.0 frequently needs a different version of the same dependency on each, and had no way to say so. A `dependencies` entry now takes an optional `grav` key using the same vocabulary as `compatibility.grav`; entries without one apply everywhere, so every existing blueprint behaves exactly as before. A qualified entry beats an unqualified one for the same package rather than merging with it, because the merge keeps the higher of two versions and would discard a deliberately lower requirement for this generation. Older core reads only `name` and `version`, so it would apply every entry unconditionally and merge to the highest version. The documented rule is to declare a `grav` dependency naming a core new enough to understand this and list it first, so such an install stops with "please update Grav" instead. An audit of the 180 blueprints in the workspace found 164 of 167 already list grav first, and the only two genuinely at risk are legacy themes that would not use this.
1 parent 396bd98 commit 5ff3950

3 files changed

Lines changed: 138 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
# v2.0.24
22
## 09/03/2026
33

4+
1. [](#new)
5+
* **A dependency can now name the generation of Grav it is for.** A plugin that supports both 1.7 and 2.0 often needs a different version of the same dependency on each, so a `dependencies` entry takes an optional `grav` key: `- { name: form, version: '>=9.1.0', grav: '2.0' }`. Entries without it apply everywhere, so existing blueprints are unchanged. See [Plugin Compatibility](https://learn.getgrav.org/20/plugins/plugin-compatibility#requiring-different-versions-per-grav-generation)
6+
47
1. [](#bugfix)
58
* Installing a package whose dependency is not in the GPM index now says so and carries on, instead of stopping the command with a PHP fatal error. A plugin that still asks for the Grav 1.7 admin plugin was enough to trigger it [getgrav/grav-premium-issues#618](https://github.com/getgrav/grav-premium-issues/issues/618)
69
* A plugin that asks for the `admin` plugin now has that read as Admin 2 on Grav 2, so a plugin written for both 1.7 and 2.0 installs instead of failing on a dependency that cannot exist there. The version it asks for is not carried over, because it describes the old admin's numbering [getgrav/grav-premium-issues#618](https://github.com/getgrav/grav-premium-issues/issues/618)

system/src/Grav/Common/GPM/GPM.php

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1339,6 +1339,79 @@ private function firstVersionIsLower($firstVersion, $secondVersion)
13391339
* @param array $dependencies The dependencies array
13401340
* @return array
13411341
*/
1342+
/**
1343+
* Keep only the dependency entries that apply to the Grav running now.
1344+
*
1345+
* An entry may carry an optional `grav` key naming the generation(s) it is
1346+
* for, spelled the way `compatibility.grav` spells them:
1347+
*
1348+
* dependencies:
1349+
* - { name: grav, version: '>=2.0.25' }
1350+
* - { name: form, version: '>=7.0.0', grav: '1.7' }
1351+
* - { name: form, version: '>=9.1.0', grav: '2.0' }
1352+
*
1353+
* An entry with no `grav` key applies everywhere, so every blueprint
1354+
* written before this existed behaves exactly as it did.
1355+
*
1356+
* Where a package has both a qualified entry for this generation and an
1357+
* unqualified one, the qualified entry wins outright rather than being
1358+
* merged with it. Merging takes the higher of two versions, which would
1359+
* silently ignore a deliberately lower requirement for this generation.
1360+
*
1361+
* Note for anyone adding a qualified entry: a Grav older than the one that
1362+
* introduced this reads `name` and `version` and ignores `grav` entirely,
1363+
* so it would apply every entry unconditionally and merge them to the
1364+
* highest version. Declare a `grav` dependency naming a core new enough to
1365+
* understand this, and list it before any qualified entry, so such an
1366+
* install stops with "please update Grav" instead.
1367+
*
1368+
* @param array $dependencies Raw blueprint `dependencies` entries.
1369+
* @return array
1370+
*/
1371+
protected function filterDependenciesForGeneration($dependencies): array
1372+
{
1373+
$generation = self::gravGeneration();
1374+
1375+
$applies = [];
1376+
$qualified = [];
1377+
foreach ((array)$dependencies as $dependency) {
1378+
if (!is_array($dependency)) {
1379+
$applies[] = $dependency;
1380+
continue;
1381+
}
1382+
1383+
$for = $dependency['grav'] ?? null;
1384+
if ($for === null || $for === '' || $for === []) {
1385+
$applies[] = $dependency;
1386+
continue;
1387+
}
1388+
1389+
foreach ((array)$for as $named) {
1390+
if ((int)$named === (int)$generation) {
1391+
$applies[] = $dependency;
1392+
if (isset($dependency['name'])) {
1393+
$qualified[$dependency['name']] = true;
1394+
}
1395+
break;
1396+
}
1397+
}
1398+
}
1399+
1400+
if (!$qualified) {
1401+
return $applies;
1402+
}
1403+
1404+
// Drop the unqualified entries for any package that also named this
1405+
// generation explicitly.
1406+
return array_values(array_filter($applies, static function ($dependency) use ($qualified) {
1407+
if (!is_array($dependency) || !isset($dependency['name'])) {
1408+
return true;
1409+
}
1410+
1411+
return !isset($qualified[$dependency['name']]) || isset($dependency['grav']);
1412+
}));
1413+
}
1414+
13421415
private function calculateMergedDependenciesOfPackage($packageName, $dependencies)
13431416
{
13441417
$packageData = $this->findPackage($packageName);
@@ -1350,7 +1423,7 @@ private function calculateMergedDependenciesOfPackage($packageName, $dependencie
13501423
return $dependencies;
13511424
}
13521425

1353-
foreach ($packageData->dependencies as $dependency) {
1426+
foreach ($this->filterDependenciesForGeneration($packageData->dependencies) as $dependency) {
13541427
$dependencyName = $dependency['name'] ?? null;
13551428
if (!$dependencyName) {
13561429
continue;

tests/unit/Grav/Common/GPM/GPMTest.php

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -351,6 +351,67 @@ public function testDeclaresGravCompatibility(): void
351351
self::assertSame($isGrav2 ? '2.0' : '1.7', GPM::gravGeneration());
352352
}
353353

354+
/**
355+
* A dependency entry may name the Grav generation(s) it applies to. Entries
356+
* for another generation are skipped; entries with no `grav` key apply
357+
* everywhere, so existing blueprints are unaffected.
358+
*/
359+
public function testDependenciesCanBeQualifiedByGravGeneration(): void
360+
{
361+
$isGrav2 = ((int)GRAV_VERSION) >= 2;
362+
$mine = $isGrav2 ? '2.0' : '1.7';
363+
$other = $isGrav2 ? '1.7' : '2.0';
364+
365+
$this->gpm->data = [
366+
'dual' => (object)[
367+
'dependencies' => [
368+
['name' => 'always', 'version' => '>=1.0.0'],
369+
['name' => 'only-mine', 'version' => '>=2.0.0', 'grav' => $mine],
370+
['name' => 'only-other', 'version' => '>=3.0.0', 'grav' => $other],
371+
['name' => 'listed-both', 'version' => '>=4.0.0', 'grav' => ['1.7', '2.0']],
372+
]
373+
],
374+
];
375+
376+
$merged = $this->invokeMerge('dual');
377+
378+
self::assertSame('>=1.0.0', $merged['always'] ?? null, 'an unqualified entry always applies');
379+
self::assertSame('>=2.0.0', $merged['only-mine'] ?? null, 'an entry for this generation applies');
380+
self::assertArrayNotHasKey('only-other', $merged, 'an entry for another generation is skipped');
381+
self::assertSame('>=4.0.0', $merged['listed-both'] ?? null, 'a list naming this generation applies');
382+
}
383+
384+
/**
385+
* A qualified entry beats an unqualified one for the same package instead
386+
* of being merged with it — merging keeps the higher version, which would
387+
* throw away a deliberately lower requirement for this generation.
388+
*/
389+
public function testQualifiedDependencyBeatsUnqualifiedOne(): void
390+
{
391+
$isGrav2 = ((int)GRAV_VERSION) >= 2;
392+
$mine = $isGrav2 ? '2.0' : '1.7';
393+
394+
$this->gpm->data = [
395+
'pkg' => (object)[
396+
'dependencies' => [
397+
['name' => 'form', 'version' => '>=9.0.0'],
398+
['name' => 'form', 'version' => '>=7.0.0', 'grav' => $mine],
399+
]
400+
],
401+
];
402+
403+
self::assertSame('>=7.0.0', $this->invokeMerge('pkg')['form'] ?? null);
404+
}
405+
406+
/** Run the private merge for one package and return the resulting map. */
407+
private function invokeMerge(string $package): array
408+
{
409+
$method = new ReflectionMethod(GPM::class, 'calculateMergedDependenciesOfPackage');
410+
$method->setAccessible(true);
411+
412+
return $method->invoke($this->gpm, $package, []);
413+
}
414+
354415
/**
355416
* A plugin supporting both generations that asks for `admin` means "the
356417
* admin panel". On Grav 2 that is admin2, so the requirement is met there

0 commit comments

Comments
 (0)