diff --git a/system/src/Grav/Common/GPM/Local/Package.php b/system/src/Grav/Common/GPM/Local/Package.php
index cfecb7101..594db0997 100644
--- a/system/src/Grav/Common/GPM/Local/Package.php
+++ b/system/src/Grav/Common/GPM/Local/Package.php
@@ -39,6 +39,7 @@ class Package extends BasePackage
$this->data->set('description_html', $html_description);
$this->data->set('description_plain', strip_tags((string) $html_description));
$this->data->set('symlink', is_link(USER_DIR . $package_type . DS . $this->__get('slug')));
+ $this->data->set('compatibility', $this->resolveCompatibility($data));
}
/**
@@ -48,4 +49,55 @@ class Package extends BasePackage
{
return (bool)$this->settings['enabled'];
}
+
+ /**
+ * Resolve the compatibility metadata for this package.
+ *
+ * Reads explicit `compatibility.grav` / `compatibility.api` from the blueprint.
+ * When absent, infers Grav compatibility from the `dependencies` array.
+ *
+ * @param Data $data Blueprint data
+ * @return array{grav: string[], api: string[]}
+ */
+ protected function resolveCompatibility(Data $data): array
+ {
+ $raw = $data->get('compatibility');
+
+ if (is_array($raw) && isset($raw['grav']) && is_array($raw['grav'])) {
+ return [
+ 'grav' => array_map('strval', $raw['grav']),
+ 'api' => isset($raw['api']) && is_array($raw['api']) ? array_map('strval', $raw['api']) : [],
+ ];
+ }
+
+ return $this->inferCompatibility($data->get('dependencies') ?? []);
+ }
+
+ /**
+ * Infer Grav compatibility from the dependencies array.
+ *
+ * @param array $dependencies
+ * @return array{grav: string[], api: string[]}
+ */
+ protected function inferCompatibility(array $dependencies): array
+ {
+ foreach ($dependencies as $dep) {
+ if (!is_array($dep) || ($dep['name'] ?? '') !== 'grav') {
+ continue;
+ }
+ $version = $dep['version'] ?? '';
+
+ if (!preg_match('/(\d+\.\d+(?:\.\d+)?)/', $version, $m)) {
+ continue;
+ }
+
+ if (version_compare($m[1], '1.8', '>=')) {
+ return ['grav' => ['1.8'], 'api' => []];
+ }
+
+ return ['grav' => ['1.7'], 'api' => []];
+ }
+
+ return ['grav' => ['1.7'], 'api' => []];
+ }
}
diff --git a/system/src/Grav/Common/Upgrade/SafeUpgradeService.php b/system/src/Grav/Common/Upgrade/SafeUpgradeService.php
index 930b72d3c..b4d5ed951 100644
--- a/system/src/Grav/Common/Upgrade/SafeUpgradeService.php
+++ b/system/src/Grav/Common/Upgrade/SafeUpgradeService.php
@@ -184,10 +184,19 @@ class SafeUpgradeService
$warnings[] = 'Potential Monolog logger API incompatibilities detected.';
}
+ $incompatible = ['blocking' => [], 'warnings' => [], 'target' => ''];
+ if ($isMajorMinorUpgrade && $targetVersion !== null) {
+ $incompatible = $this->detectIncompatiblePackages($targetVersion);
+ if (!empty($incompatible['blocking'])) {
+ $warnings[] = 'Some enabled plugins/themes have not been marked as compatible with Grav ' . $incompatible['target'] . '.';
+ }
+ }
+
return [
'plugins_pending' => $pending,
'psr_log_conflicts' => $psrLogConflicts,
'monolog_conflicts' => $monologConflicts,
+ 'incompatible_packages' => $incompatible,
'warnings' => $warnings,
'is_major_minor_upgrade' => $isMajorMinorUpgrade,
];
@@ -836,6 +845,174 @@ class SafeUpgradeService
return $conflicts;
}
+ /**
+ * Detect installed plugins/themes not compatible with the target Grav version.
+ *
+ * @param string $targetVersion Target Grav version (e.g. '1.8.0')
+ * @return array{blocking: array, warnings: array, target: string}
+ */
+ protected function detectIncompatiblePackages(string $targetVersion): array
+ {
+ $parts = explode('.', $targetVersion);
+ $targetMajorMinor = ($parts[0] ?? '1') . '.' . ($parts[1] ?? '7');
+
+ $blocking = [];
+ $warnings = [];
+
+ $pluginDirs = glob($this->rootPath . '/user/plugins/*', GLOB_ONLYDIR) ?: [];
+ foreach ($pluginDirs as $dir) {
+ $slug = basename($dir);
+ $compat = $this->readBlueprintCompatibility($dir);
+
+ if (in_array($targetMajorMinor, $compat['grav'], true)) {
+ continue;
+ }
+
+ $version = $this->readBlueprintVersion($dir) ?? 'unknown';
+ $enabled = $this->isPluginEnabled($slug);
+
+ $entry = [
+ 'type' => 'plugin',
+ 'version' => $version,
+ 'compatibility' => $compat,
+ 'enabled' => $enabled,
+ ];
+
+ if ($enabled) {
+ $blocking[$slug] = $entry;
+ } else {
+ $warnings[$slug] = $entry;
+ }
+ }
+
+ $themeDirs = glob($this->rootPath . '/user/themes/*', GLOB_ONLYDIR) ?: [];
+ foreach ($themeDirs as $dir) {
+ $slug = basename($dir);
+ $compat = $this->readBlueprintCompatibility($dir);
+
+ if (in_array($targetMajorMinor, $compat['grav'], true)) {
+ continue;
+ }
+
+ $version = $this->readBlueprintVersion($dir) ?? 'unknown';
+ $active = $this->isThemeEnabled($slug);
+
+ $entry = [
+ 'type' => 'theme',
+ 'version' => $version,
+ 'compatibility' => $compat,
+ 'enabled' => $active,
+ ];
+
+ if ($active) {
+ $blocking[$slug] = $entry;
+ } else {
+ $warnings[$slug] = $entry;
+ }
+ }
+
+ return [
+ 'blocking' => $blocking,
+ 'warnings' => $warnings,
+ 'target' => $targetMajorMinor,
+ ];
+ }
+
+ /**
+ * Read the compatible Grav versions from a package's blueprints.yaml.
+ *
+ * @param string $dir Package directory
+ * @return array{grav: string[], api: string[]}
+ */
+ protected function readBlueprintCompatibility(string $dir): array
+ {
+ $file = $dir . '/blueprints.yaml';
+ if (!is_file($file)) {
+ return ['grav' => [], 'api' => []];
+ }
+
+ try {
+ $contents = @file_get_contents($file);
+ if ($contents === false) {
+ return ['grav' => [], 'api' => []];
+ }
+ $data = Yaml::parse($contents);
+ if (!is_array($data)) {
+ return ['grav' => [], 'api' => []];
+ }
+
+ if (isset($data['compatibility']['grav']) && is_array($data['compatibility']['grav'])) {
+ return [
+ 'grav' => array_map('strval', $data['compatibility']['grav']),
+ 'api' => isset($data['compatibility']['api']) && is_array($data['compatibility']['api'])
+ ? array_map('strval', $data['compatibility']['api'])
+ : [],
+ ];
+ }
+
+ return $this->inferCompatibleVersions($data['dependencies'] ?? []);
+ } catch (\Throwable $e) {
+ return ['grav' => [], 'api' => []];
+ }
+ }
+
+ /**
+ * Infer compatible Grav versions from a package's dependency list.
+ *
+ * @param array $dependencies
+ * @return array{grav: string[], api: string[]}
+ */
+ protected function inferCompatibleVersions(array $dependencies): array
+ {
+ foreach ($dependencies as $dep) {
+ if (!is_array($dep) || ($dep['name'] ?? '') !== 'grav') {
+ continue;
+ }
+ $version = $dep['version'] ?? '';
+
+ if (!preg_match('/(\d+\.\d+(?:\.\d+)?)/', $version, $m)) {
+ continue;
+ }
+
+ if (version_compare($m[1], '1.8', '>=')) {
+ return ['grav' => ['1.8'], 'api' => []];
+ }
+
+ return ['grav' => ['1.7'], 'api' => []];
+ }
+
+ return ['grav' => ['1.7'], 'api' => []];
+ }
+
+ /**
+ * Read the version string from a package's blueprints.yaml.
+ *
+ * @param string $dir Package directory
+ * @return string|null
+ */
+ protected function readBlueprintVersion(string $dir): ?string
+ {
+ $file = $dir . '/blueprints.yaml';
+ if (!is_file($file)) {
+ return null;
+ }
+
+ try {
+ $contents = @file_get_contents($file);
+ if ($contents === false) {
+ return null;
+ }
+ $data = Yaml::parse($contents);
+ if (is_array($data) && isset($data['version'])) {
+ return (string)$data['version'];
+ }
+ } catch (\Throwable $e) {
+ // ignore
+ }
+
+ return null;
+ }
+
/**
* Ensure directories flagged for ignoring get hydrated from the current installation.
*
diff --git a/system/src/Grav/Console/Gpm/IndexCommand.php b/system/src/Grav/Console/Gpm/IndexCommand.php
index d655ac5f8..5669271d6 100644
--- a/system/src/Grav/Console/Gpm/IndexCommand.php
+++ b/system/src/Grav/Console/Gpm/IndexCommand.php
@@ -141,7 +141,7 @@ class IndexCommand extends GpmCommand
if (!empty($packages)) {
$io->section('Packages table');
$table = new Table($io);
- $table->setHeaders(['Count', 'Name', 'Slug', 'Version', 'Installed', 'Enabled']);
+ $table->setHeaders(['Count', 'Name', 'Slug', 'Version', 'Compat', 'Installed', 'Enabled']);
$index = 0;
foreach ($packages as $slug => $package) {
@@ -150,6 +150,7 @@ class IndexCommand extends GpmCommand
'Name' => '' . Utils::truncate($package->name, 20, false, ' ', '...') . ' ',
'Slug' => $slug,
'Version'=> $this->version($package),
+ 'Compat' => $this->compatBadges($package),
'Installed' => $this->installed($package),
'Enabled' => $this->enabled($package),
];
@@ -231,6 +232,38 @@ class IndexCommand extends GpmCommand
return $result;
}
+ /**
+ * @param Package $package
+ * @return string
+ */
+ private function compatBadges(Package $package): string
+ {
+ $type = ucfirst(preg_replace('/s$/', '', $package->package_type));
+ $method = 'is' . $type . 'Installed';
+ $installed = $this->gpm->{$method}($package->slug);
+
+ if ($installed) {
+ $local = $this->gpm->{'getInstalled' . $type}($package->slug);
+ $compat = $local->compatibility ?? null;
+ } else {
+ $compat = $package->compatibility ?? null;
+ }
+
+ if (!is_array($compat) || empty($compat['grav'])) {
+ return '1.7';
+ }
+
+ $badges = [];
+ if (in_array('1.7', $compat['grav'], true)) {
+ $badges[] = '1.7';
+ }
+ if (in_array('1.8', $compat['grav'], true)) {
+ $badges[] = '1.8';
+ }
+
+ return implode(' ', $badges);
+ }
+
/**
* @param Packages $data
* @return Packages
diff --git a/system/src/Grav/Console/Gpm/InfoCommand.php b/system/src/Grav/Console/Gpm/InfoCommand.php
index bf440eaec..56d2a017e 100644
--- a/system/src/Grav/Console/Gpm/InfoCommand.php
+++ b/system/src/Grav/Console/Gpm/InfoCommand.php
@@ -131,9 +131,36 @@ class InfoCommand extends GpmCommand
}
}
+ // Display compatibility badges
$type = rtrim((string) $foundPackage->package_type, 's');
- $updatable = $this->gpm->{'is' . $type . 'Updatable'}($foundPackage->slug);
$installed = $this->gpm->{'is' . $type . 'Installed'}($foundPackage->slug);
+ if ($installed) {
+ $local = $this->gpm->{'getInstalled' . $type}($foundPackage->slug);
+ $compat = $local->compatibility ?? null;
+ } else {
+ $compat = $foundPackage->compatibility ?? null;
+ }
+
+ $compatStr = '';
+ if (is_array($compat) && !empty($compat['grav'])) {
+ $badges = [];
+ if (in_array('1.7', $compat['grav'], true)) {
+ $badges[] = '1.7';
+ }
+ if (in_array('1.8', $compat['grav'], true)) {
+ $badges[] = '1.8';
+ }
+ $compatStr = implode(' ', $badges);
+ } else {
+ $compatStr = '1.7';
+ }
+ $io->writeln('' . str_pad('Compatibility', 12) . ': ' . $compatStr);
+
+ if (is_array($compat) && !empty($compat['api'])) {
+ $io->writeln('' . str_pad('API', 12) . ': ' . implode(', ', $compat['api']));
+ }
+
+ $updatable = $this->gpm->{'is' . $type . 'Updatable'}($foundPackage->slug);
// display current version if installed and different
if ($installed && $updatable) {
diff --git a/system/src/Grav/Console/Gpm/SelfupgradeCommand.php b/system/src/Grav/Console/Gpm/SelfupgradeCommand.php
index e2c5030b0..b29364022 100644
--- a/system/src/Grav/Console/Gpm/SelfupgradeCommand.php
+++ b/system/src/Grav/Console/Gpm/SelfupgradeCommand.php
@@ -396,6 +396,9 @@ class SelfupgradeCommand extends GpmCommand
$conflicts = $preflight['psr_log_conflicts'] ?? [];
$monologConflicts = $preflight['monolog_conflicts'] ?? [];
$warnings = $preflight['warnings'] ?? [];
+ $incompatible = $preflight['incompatible_packages'] ?? [];
+ $incompatibleBlocking = $incompatible['blocking'] ?? [];
+ $incompatibleTarget = $incompatible['target'] ?? '';
$isMajorMinorUpgrade = $preflight['is_major_minor_upgrade'] ?? null;
if ($isMajorMinorUpgrade === null && $this->upgrader) {
$local = $this->upgrader->getLocalVersion();
@@ -420,17 +423,22 @@ class SelfupgradeCommand extends GpmCommand
}
}
- if ($blocking && empty($pending)) {
+ // Filter out the incompatible-packages blocker (handled separately below)
+ $filteredBlocking = array_filter($blocking, static function ($reason) {
+ return !stripos($reason, 'not been marked as compatible');
+ });
+
+ if ($filteredBlocking && empty($pending)) {
$io->newLine();
$io->writeln('Upgrade blocked:');
- foreach ($blocking as $reason) {
+ foreach ($filteredBlocking as $reason) {
$io->writeln(' - ' . $reason);
}
return false;
}
- if (empty($pending) && empty($conflicts) && empty($monologConflicts)) {
+ if (empty($pending) && empty($conflicts) && empty($monologConflicts) && empty($incompatibleBlocking)) {
return true;
}
@@ -467,6 +475,58 @@ class SelfupgradeCommand extends GpmCommand
$io->writeln(' › Proceeding despite pending plugin/theme updates.');
}
+ // Handle incompatible packages
+ if ($incompatibleBlocking) {
+ $io->newLine();
+ $io->writeln('The following enabled plugins/themes are not marked as compatible with Grav ' . $incompatibleTarget . ':');
+ foreach ($incompatibleBlocking as $slug => $info) {
+ $type = $info['type'] ?? 'plugin';
+ $ver = $info['version'] ?? 'unknown';
+ $gravCompat = implode(', ', $info['compatibility']['grav'] ?? ['?']);
+ $io->writeln(sprintf(' - %s (%s v%s) — compatible with: %s', $slug, $type, $ver, $gravCompat));
+ }
+ $io->writeln(' › Plugins/themes must be marked as compatible with Grav ' . $incompatibleTarget . ' before upgrading.');
+ $io->writeln(' Either update the plugins, or disable them to proceed.');
+
+ $choice = $this->all_yes ? 'abort' : $io->choice(
+ 'How would you like to proceed?',
+ ['disable', 'continue', 'abort'],
+ 'abort'
+ );
+
+ if ($choice === 'abort') {
+ $io->writeln('Aborting self-upgrade. Update or disable incompatible plugins first.');
+
+ return false;
+ }
+
+ /** @var \Grav\Common\Recovery\RecoveryManager $recovery */
+ $recovery = Grav::instance()['recovery'];
+
+ if ($choice === 'disable') {
+ foreach (array_keys($incompatibleBlocking) as $slug) {
+ $recovery->disablePlugin($slug, ['message' => 'Disabled before upgrade — not marked as compatible with Grav ' . $incompatibleTarget]);
+ $io->writeln(sprintf(' - Disabled %s.', $slug));
+ }
+ $io->writeln('Continuing with incompatible plugins disabled.');
+ } else {
+ Install::allowIncompatibleOverride(true);
+ $io->writeln(' › Proceeding despite incompatible plugins/themes.');
+ }
+ }
+
+ // Show incompatible warnings (disabled packages) — informational only
+ $incompatibleWarnings = $incompatible['warnings'] ?? [];
+ if ($incompatibleWarnings) {
+ $io->newLine();
+ $io->writeln('Disabled plugins/themes not yet compatible with Grav ' . $incompatibleTarget . ' (will not block upgrade):');
+ foreach ($incompatibleWarnings as $slug => $info) {
+ $type = $info['type'] ?? 'plugin';
+ $ver = $info['version'] ?? 'unknown';
+ $io->writeln(sprintf(' - %s (%s v%s)', $slug, $type, $ver));
+ }
+ }
+
$handled = $this->handleConflicts(
$conflicts,
static function (SymfonyStyle $io, array $conflicts): void {
diff --git a/system/src/Grav/Console/Gpm/UpdateCommand.php b/system/src/Grav/Console/Gpm/UpdateCommand.php
index d8e9fbfe5..15ea1c196 100644
--- a/system/src/Grav/Console/Gpm/UpdateCommand.php
+++ b/system/src/Grav/Console/Gpm/UpdateCommand.php
@@ -211,13 +211,29 @@ class UpdateCommand extends GpmCommand
$package->available = $package->version;
}
+ // Build compatibility badges
+ $compat = $package->compatibility ?? null;
+ $compatStr = '';
+ if (is_array($compat) && !empty($compat['grav'])) {
+ $badges = [];
+ if (in_array('1.7', $compat['grav'], true)) {
+ $badges[] = '1.7';
+ }
+ if (in_array('1.8', $compat['grav'], true)) {
+ $badges[] = '1.8';
+ }
+ $compatStr = ' ' . implode(' ', $badges);
+ }
+
$io->writeln(
// index
str_pad((string)$index++, 2, '0', STR_PAD_LEFT) . '. ' .
// name
'' . str_pad((string) $package->name, 15) . ' ' .
// version
- "[v{$package->version} -> v{$package->available}]"
+ "[v{$package->version} -> v{$package->available}]" .
+ // compat badges
+ $compatStr
);
$slugs[] = $slug;
}
diff --git a/system/src/Grav/Installer/Install.php b/system/src/Grav/Installer/Install.php
index afed9c968..858c6153d 100644
--- a/system/src/Grav/Installer/Install.php
+++ b/system/src/Grav/Installer/Install.php
@@ -165,6 +165,8 @@ final class Install
private static $forceSafeUpgrade = null;
/** @var bool */
private static $allowPendingOverride = false;
+ /** @var bool */
+ private static $allowIncompatibleOverride = false;
/** @var int|null */
private static $snapshotLimit = null;
/** @var callable|null */
@@ -208,6 +210,15 @@ final class Install
}
}
+ public static function allowIncompatibleOverride(?bool $state = true): void
+ {
+ if ($state === null) {
+ self::$allowIncompatibleOverride = false;
+ } else {
+ self::$allowIncompatibleOverride = (bool)$state;
+ }
+ }
+
private function ensureLocation(): void
{
if (null === $this->location) {
@@ -954,6 +965,7 @@ ERR;
'psr_log_conflicts' => [],
'monolog_conflicts' => [],
'plugins_pending' => [],
+ 'incompatible_packages' => [],
'is_major_minor_upgrade' => $this->isMajorMinorUpgrade($targetVersion),
'blocking' => [],
];
@@ -970,6 +982,15 @@ ERR;
}
}
+ if ($report['is_major_minor_upgrade']) {
+ $report['incompatible_packages'] = $this->detectIncompatiblePackages($targetVersion);
+
+ if (!empty($report['incompatible_packages']['blocking']) && !self::$allowIncompatibleOverride) {
+ $target = $report['incompatible_packages']['target'];
+ $report['blocking'][] = 'Some enabled plugins/themes have not been marked as compatible with Grav ' . $target . '. Disable them before continuing.';
+ }
+ }
+
$elapsed = microtime(true) - $start;
$this->relayProgress('initializing', sprintf('Preflight checks complete in %.3fs.', $elapsed), null);
@@ -1295,6 +1316,148 @@ ERR;
return true;
}
+ /**
+ * Detect installed plugins/themes not compatible with the target Grav version.
+ *
+ * @param string $targetVersion Target Grav version (e.g. '1.8.0')
+ * @return array{blocking: array, warnings: array, target: string}
+ */
+ private function detectIncompatiblePackages(string $targetVersion): array
+ {
+ $parts = explode('.', $targetVersion);
+ $targetMajorMinor = ($parts[0] ?? '1') . '.' . ($parts[1] ?? '7');
+
+ $blocking = [];
+ $warnings = [];
+ $scanRoot = GRAV_ROOT ?: getcwd();
+
+ // Scan plugins
+ $pluginDirs = glob($scanRoot . '/user/plugins/*', GLOB_ONLYDIR) ?: [];
+ foreach ($pluginDirs as $dir) {
+ $slug = basename($dir);
+ $compat = $this->readBlueprintCompatibility($dir);
+
+ if (in_array($targetMajorMinor, $compat['grav'], true)) {
+ continue;
+ }
+
+ $version = $this->readBlueprintVersion($dir) ?? 'unknown';
+ $enabled = $this->isPluginEnabled($slug);
+
+ $entry = [
+ 'type' => 'plugin',
+ 'version' => $version,
+ 'compatibility' => $compat,
+ 'enabled' => $enabled,
+ ];
+
+ if ($enabled) {
+ $blocking[$slug] = $entry;
+ } else {
+ $warnings[$slug] = $entry;
+ }
+ }
+
+ // Scan themes
+ $themeDirs = glob($scanRoot . '/user/themes/*', GLOB_ONLYDIR) ?: [];
+ foreach ($themeDirs as $dir) {
+ $slug = basename($dir);
+ $compat = $this->readBlueprintCompatibility($dir);
+
+ if (in_array($targetMajorMinor, $compat['grav'], true)) {
+ continue;
+ }
+
+ $version = $this->readBlueprintVersion($dir) ?? 'unknown';
+ $active = $this->isThemeEnabled($slug);
+
+ $entry = [
+ 'type' => 'theme',
+ 'version' => $version,
+ 'compatibility' => $compat,
+ 'enabled' => $active,
+ ];
+
+ if ($active) {
+ $blocking[$slug] = $entry;
+ } else {
+ $warnings[$slug] = $entry;
+ }
+ }
+
+ return [
+ 'blocking' => $blocking,
+ 'warnings' => $warnings,
+ 'target' => $targetMajorMinor,
+ ];
+ }
+
+ /**
+ * Read the compatible Grav versions from a package's blueprints.yaml.
+ *
+ * @param string $dir Package directory
+ * @return array{grav: string[], api: string[]}
+ */
+ private function readBlueprintCompatibility(string $dir): array
+ {
+ $file = $dir . '/blueprints.yaml';
+ if (!is_file($file)) {
+ return ['grav' => [], 'api' => []];
+ }
+
+ try {
+ $contents = @file_get_contents($file);
+ if ($contents === false) {
+ return ['grav' => [], 'api' => []];
+ }
+ $data = Yaml::parse($contents);
+ if (!is_array($data)) {
+ return ['grav' => [], 'api' => []];
+ }
+
+ if (isset($data['compatibility']['grav']) && is_array($data['compatibility']['grav'])) {
+ return [
+ 'grav' => array_map('strval', $data['compatibility']['grav']),
+ 'api' => isset($data['compatibility']['api']) && is_array($data['compatibility']['api'])
+ ? array_map('strval', $data['compatibility']['api'])
+ : [],
+ ];
+ }
+
+ return $this->inferCompatibleVersions($data['dependencies'] ?? []);
+ } catch (Throwable $e) {
+ return ['grav' => [], 'api' => []];
+ }
+ }
+
+ /**
+ * Infer compatible Grav versions from a package's dependency list.
+ *
+ * @param array $dependencies
+ * @return array{grav: string[], api: string[]}
+ */
+ private function inferCompatibleVersions(array $dependencies): array
+ {
+ foreach ($dependencies as $dep) {
+ if (!is_array($dep) || ($dep['name'] ?? '') !== 'grav') {
+ continue;
+ }
+ $version = $dep['version'] ?? '';
+
+ if (!preg_match('/(\d+\.\d+(?:\.\d+)?)/', $version, $m)) {
+ continue;
+ }
+
+ if (version_compare($m[1], '1.8', '>=')) {
+ return ['grav' => ['1.8'], 'api' => []];
+ }
+
+ return ['grav' => ['1.7'], 'api' => []];
+ }
+
+ return ['grav' => ['1.7'], 'api' => []];
+ }
+
private function isGpmPackagePublished($package): bool
{
if (is_object($package) && method_exists($package, 'getData')) {
diff --git a/tests/unit/Grav/Console/Gpm/SelfupgradeCommandTest.php b/tests/unit/Grav/Console/Gpm/SelfupgradeCommandTest.php
index bf6260add..5c4e6b2eb 100644
--- a/tests/unit/Grav/Console/Gpm/SelfupgradeCommandTest.php
+++ b/tests/unit/Grav/Console/Gpm/SelfupgradeCommandTest.php
@@ -124,6 +124,111 @@ class SelfupgradeCommandTest extends \PHPUnit\Framework\TestCase
self::assertStringContainsString('Proceeding with potential psr/log incompatibilities still active.', $output);
}
+ public function testHandlePreflightReportBlocksOnIncompatiblePackages(): void
+ {
+ $command = new TestSelfupgradeCommand();
+ [$style] = $this->injectIo($command);
+ $this->setAllYes($command, true);
+
+ $result = $command->runHandle([
+ 'plugins_pending' => [],
+ 'psr_log_conflicts' => [],
+ 'monolog_conflicts' => [],
+ 'warnings' => [],
+ 'is_major_minor_upgrade' => true,
+ 'blocking' => [],
+ 'incompatible_packages' => [
+ 'blocking' => [
+ 'old-plugin' => [
+ 'type' => 'plugin',
+ 'version' => '1.0.0',
+ 'compatibility' => ['grav' => ['1.7'], 'api' => []],
+ 'enabled' => true,
+ ],
+ ],
+ 'warnings' => [],
+ 'target' => '1.8',
+ ],
+ ]);
+
+ self::assertFalse($result);
+ $output = implode("\n", $style->messages);
+ self::assertStringContainsString('old-plugin', $output);
+ self::assertStringContainsString('not marked as compatible', $output);
+ }
+
+ public function testHandlePreflightReportDisablesIncompatibleWhenRequested(): void
+ {
+ $gravFactory = Fixtures::get('grav');
+ $grav = $gravFactory();
+ $stub = new class {
+ public $disabled = [];
+ public function disablePlugin(string $slug, array $context = []): void
+ {
+ $this->disabled[] = $slug;
+ }
+ };
+ $grav['recovery'] = $stub;
+
+ $command = new TestSelfupgradeCommand();
+ [$style] = $this->injectIo($command, ['disable']);
+
+ $result = $command->runHandle([
+ 'plugins_pending' => [],
+ 'psr_log_conflicts' => [],
+ 'monolog_conflicts' => [],
+ 'warnings' => [],
+ 'is_major_minor_upgrade' => true,
+ 'blocking' => [],
+ 'incompatible_packages' => [
+ 'blocking' => [
+ 'old-plugin' => [
+ 'type' => 'plugin',
+ 'version' => '1.0.0',
+ 'compatibility' => ['grav' => ['1.7'], 'api' => []],
+ 'enabled' => true,
+ ],
+ ],
+ 'warnings' => [],
+ 'target' => '1.8',
+ ],
+ ]);
+
+ self::assertTrue($result);
+ self::assertSame(['old-plugin'], $stub->disabled);
+ }
+
+ public function testHandlePreflightReportContinuesWithIncompatibleOverride(): void
+ {
+ $command = new TestSelfupgradeCommand();
+ [$style] = $this->injectIo($command, ['continue']);
+
+ $result = $command->runHandle([
+ 'plugins_pending' => [],
+ 'psr_log_conflicts' => [],
+ 'monolog_conflicts' => [],
+ 'warnings' => [],
+ 'is_major_minor_upgrade' => true,
+ 'blocking' => [],
+ 'incompatible_packages' => [
+ 'blocking' => [
+ 'old-plugin' => [
+ 'type' => 'plugin',
+ 'version' => '1.0.0',
+ 'compatibility' => ['grav' => ['1.7'], 'api' => []],
+ 'enabled' => true,
+ ],
+ ],
+ 'warnings' => [],
+ 'target' => '1.8',
+ ],
+ ]);
+
+ self::assertTrue($result);
+ $output = implode("\n", $style->messages);
+ self::assertStringContainsString('Proceeding despite incompatible', $output);
+ }
+
/**
* @param TestSelfupgradeCommand $command
* @param array $responses
diff --git a/tests/unit/Grav/Installer/InstallCompatibilityTest.php b/tests/unit/Grav/Installer/InstallCompatibilityTest.php
new file mode 100644
index 000000000..d4591dc51
--- /dev/null
+++ b/tests/unit/Grav/Installer/InstallCompatibilityTest.php
@@ -0,0 +1,191 @@
+tmpDir = sys_get_temp_dir() . '/grav-compat-test-' . uniqid();
+ mkdir($this->tmpDir . '/user/plugins', 0755, true);
+ mkdir($this->tmpDir . '/user/themes', 0755, true);
+ mkdir($this->tmpDir . '/user/config/plugins', 0755, true);
+ }
+
+ protected function tearDown(): void
+ {
+ if (is_dir($this->tmpDir)) {
+ Folder::delete($this->tmpDir);
+ }
+ }
+
+ public function testReadBlueprintCompatibilityExplicit(): void
+ {
+ $dir = $this->createPlugin('test-plugin', [
+ 'name' => 'Test Plugin',
+ 'version' => '1.0.0',
+ 'compatibility' => [
+ 'grav' => ['1.7', '1.8'],
+ 'api' => ['1.0'],
+ ],
+ ]);
+
+ $result = $this->callMethod('readBlueprintCompatibility', [$dir]);
+ self::assertSame(['1.7', '1.8'], $result['grav']);
+ self::assertSame(['1.0'], $result['api']);
+ }
+
+ public function testReadBlueprintCompatibilityInferredFrom18Dep(): void
+ {
+ $dir = $this->createPlugin('test-18', [
+ 'name' => 'Test 1.8',
+ 'version' => '2.0.0',
+ 'dependencies' => [
+ ['name' => 'grav', 'version' => '>=1.8.0'],
+ ],
+ ]);
+
+ $result = $this->callMethod('readBlueprintCompatibility', [$dir]);
+ self::assertSame(['1.8'], $result['grav']);
+ self::assertSame([], $result['api']);
+ }
+
+ public function testReadBlueprintCompatibilityInferredFrom17Dep(): void
+ {
+ $dir = $this->createPlugin('test-17', [
+ 'name' => 'Test 1.7',
+ 'version' => '1.0.0',
+ 'dependencies' => [
+ ['name' => 'grav', 'version' => '>=1.7.0'],
+ ],
+ ]);
+
+ $result = $this->callMethod('readBlueprintCompatibility', [$dir]);
+ self::assertSame(['1.7'], $result['grav']);
+ }
+
+ public function testReadBlueprintCompatibilityNoDependency(): void
+ {
+ $dir = $this->createPlugin('test-none', [
+ 'name' => 'Test None',
+ 'version' => '1.0.0',
+ ]);
+
+ $result = $this->callMethod('readBlueprintCompatibility', [$dir]);
+ self::assertSame(['1.7'], $result['grav']);
+ }
+
+ public function testReadBlueprintCompatibilityMissingFile(): void
+ {
+ $dir = $this->tmpDir . '/user/plugins/no-blueprint';
+ mkdir($dir, 0755, true);
+
+ $result = $this->callMethod('readBlueprintCompatibility', [$dir]);
+ self::assertSame([], $result['grav']);
+ }
+
+ public function testInferCompatibleVersionsVariousConstraints(): void
+ {
+ $install = Install::instance();
+
+ // >=1.8.0
+ $result = $this->callMethod('inferCompatibleVersions', [[['name' => 'grav', 'version' => '>=1.8.0']]]);
+ self::assertSame(['1.8'], $result['grav']);
+
+ // ~1.8
+ $result = $this->callMethod('inferCompatibleVersions', [[['name' => 'grav', 'version' => '~1.8']]]);
+ self::assertSame(['1.8'], $result['grav']);
+
+ // >=1.7.0,<2.0
+ $result = $this->callMethod('inferCompatibleVersions', [[['name' => 'grav', 'version' => '>=1.7.0,<2.0']]]);
+ self::assertSame(['1.7'], $result['grav']);
+
+ // No grav dep
+ $result = $this->callMethod('inferCompatibleVersions', [[['name' => 'form', 'version' => '>=6.0']]]);
+ self::assertSame(['1.7'], $result['grav']);
+
+ // Empty
+ $result = $this->callMethod('inferCompatibleVersions', [[]]);
+ self::assertSame(['1.7'], $result['grav']);
+ }
+
+ public function testDetectIncompatiblePackagesBlocksEnabled(): void
+ {
+ $this->createPlugin('incompatible-enabled', [
+ 'name' => 'Incompatible Enabled',
+ 'version' => '1.0.0',
+ 'compatibility' => ['grav' => ['1.7']],
+ ]);
+ // Plugin enabled by default (no config file = enabled)
+
+ $result = $this->callMethod('detectIncompatiblePackages', ['1.8.0']);
+ self::assertArrayHasKey('incompatible-enabled', $result['blocking']);
+ self::assertSame('1.8', $result['target']);
+ }
+
+ public function testDetectIncompatiblePackagesWarnsDisabled(): void
+ {
+ $this->createPlugin('incompatible-disabled', [
+ 'name' => 'Incompatible Disabled',
+ 'version' => '1.0.0',
+ 'compatibility' => ['grav' => ['1.7']],
+ ]);
+ // Disable it
+ file_put_contents(
+ $this->tmpDir . '/user/config/plugins/incompatible-disabled.yaml',
+ "enabled: false\n"
+ );
+
+ $result = $this->callMethod('detectIncompatiblePackages', ['1.8.0']);
+ self::assertArrayHasKey('incompatible-disabled', $result['warnings']);
+ self::assertEmpty($result['blocking']);
+ }
+
+ public function testDetectIncompatiblePackagesPassesCompatible(): void
+ {
+ $this->createPlugin('compatible-plugin', [
+ 'name' => 'Compatible Plugin',
+ 'version' => '2.0.0',
+ 'compatibility' => ['grav' => ['1.7', '1.8']],
+ ]);
+
+ $result = $this->callMethod('detectIncompatiblePackages', ['1.8.0']);
+ self::assertArrayNotHasKey('compatible-plugin', $result['blocking']);
+ self::assertArrayNotHasKey('compatible-plugin', $result['warnings']);
+ }
+
+ /**
+ * Create a plugin directory with a blueprints.yaml file.
+ */
+ private function createPlugin(string $slug, array $blueprint): string
+ {
+ $dir = $this->tmpDir . '/user/plugins/' . $slug;
+ mkdir($dir, 0755, true);
+
+ $yaml = \Symfony\Component\Yaml\Yaml::dump($blueprint, 4);
+ file_put_contents($dir . '/blueprints.yaml', $yaml);
+
+ return $dir;
+ }
+
+ /**
+ * Call a private/protected method on the Install singleton via reflection.
+ * We temporarily override GRAV_ROOT for the detection methods.
+ */
+ private function callMethod(string $method, array $args): mixed
+ {
+ $install = Install::instance();
+ $ref = new \ReflectionMethod($install, $method);
+ $ref->setAccessible(true);
+
+ // For methods that use GRAV_ROOT, we need to define it if not set
+ if (!defined('GRAV_ROOT')) {
+ define('GRAV_ROOT', $this->tmpDir);
+ }
+
+ return $ref->invoke($install, ...$args);
+ }
+}