|
| 1 | +<?php |
| 2 | + |
| 3 | +declare(strict_types=1); |
| 4 | + |
| 5 | +namespace LTS\PHPQA\ComposerPlugin; |
| 6 | + |
| 7 | +use Composer\Composer; |
| 8 | +use Composer\EventDispatcher\EventSubscriberInterface; |
| 9 | +use Composer\IO\IOInterface; |
| 10 | +use Composer\Plugin\PluginInterface; |
| 11 | +use Composer\Script\Event; |
| 12 | +use Composer\Script\ScriptEvents; |
| 13 | + |
| 14 | +/** |
| 15 | + * Composer plugin that guards against PHPStan version mismatches and duplicate installs. |
| 16 | + * |
| 17 | + * php-qa-ci provides PHPStan via a PHIVE-managed phar and declares "replace": {"phpstan/phpstan": "*"} |
| 18 | + * to prevent composer from installing it as a package. Projects can still install PHPStan extensions |
| 19 | + * (phpstan-doctrine, phpstan-symfony, etc.) - their dependency on phpstan/phpstan is satisfied by the replace. |
| 20 | + * |
| 21 | + * This plugin: |
| 22 | + * 1. Errors if a project has phpstan/phpstan in its own require or require-dev |
| 23 | + * 2. Validates the phar version satisfies the extension-installer's version constraint |
| 24 | + * 3. Provides clear instructions when issues are found |
| 25 | + */ |
| 26 | +final class PhpStanGuardPlugin implements PluginInterface, EventSubscriberInterface |
| 27 | +{ |
| 28 | + public function activate(Composer $composer, IOInterface $io): void |
| 29 | + { |
| 30 | + // Plugin activation - no initialization needed |
| 31 | + } |
| 32 | + |
| 33 | + public function deactivate(Composer $composer, IOInterface $io): void |
| 34 | + { |
| 35 | + // Plugin deactivation - no cleanup needed |
| 36 | + } |
| 37 | + |
| 38 | + public function uninstall(Composer $composer, IOInterface $io): void |
| 39 | + { |
| 40 | + // Plugin uninstall - no cleanup needed |
| 41 | + } |
| 42 | + |
| 43 | + /** |
| 44 | + * @return array<string, string|array{0: string, 1?: int}> |
| 45 | + */ |
| 46 | + public static function getSubscribedEvents(): array |
| 47 | + { |
| 48 | + return [ |
| 49 | + ScriptEvents::POST_INSTALL_CMD => ['validatePhpStan', -10], |
| 50 | + ScriptEvents::POST_UPDATE_CMD => ['validatePhpStan', -10], |
| 51 | + ]; |
| 52 | + } |
| 53 | + |
| 54 | + /** |
| 55 | + * Validates PHPStan setup after install/update. |
| 56 | + * |
| 57 | + * Runs with low priority (-10) to execute after PhiveUpdatePlugin has installed/updated phars. |
| 58 | + */ |
| 59 | + public function validatePhpStan(Event $event): void |
| 60 | + { |
| 61 | + $io = $event->getIO(); |
| 62 | + $composer = $event->getComposer(); |
| 63 | + |
| 64 | + $this->checkForDirectPhpStanRequirement($io, $composer); |
| 65 | + $this->validatePharVersionConstraint($io, $composer); |
| 66 | + } |
| 67 | + |
| 68 | + /** |
| 69 | + * Checks if the root project directly requires phpstan/phpstan, which it should not. |
| 70 | + */ |
| 71 | + private function checkForDirectPhpStanRequirement(IOInterface $io, Composer $composer): void |
| 72 | + { |
| 73 | + $rootPackage = $composer->getPackage(); |
| 74 | + $requires = $rootPackage->getRequires(); |
| 75 | + $devRequires = $rootPackage->getDevRequires(); |
| 76 | + |
| 77 | + if (isset($requires['phpstan/phpstan'])) { |
| 78 | + $io->writeError(''); |
| 79 | + $io->writeError('<error>╔══════════════════════════════════════════════════════════════╗</error>'); |
| 80 | + $io->writeError('<error>║ phpstan/phpstan MUST NOT be in your composer.json require ║</error>'); |
| 81 | + $io->writeError('<error>╚══════════════════════════════════════════════════════════════╝</error>'); |
| 82 | + $io->writeError(''); |
| 83 | + $io->writeError(' php-qa-ci provides PHPStan via a managed PHAR binary.'); |
| 84 | + $io->writeError(' Having phpstan/phpstan as a direct dependency causes version conflicts.'); |
| 85 | + $io->writeError(''); |
| 86 | + $io->writeError(' Fix: <comment>composer remove phpstan/phpstan</comment>'); |
| 87 | + $io->writeError(''); |
| 88 | + } |
| 89 | + |
| 90 | + if (isset($devRequires['phpstan/phpstan'])) { |
| 91 | + $io->writeError(''); |
| 92 | + $io->writeError('<error>╔════════════════════════════════════════════════════════════════════╗</error>'); |
| 93 | + $io->writeError('<error>║ phpstan/phpstan MUST NOT be in your composer.json require-dev ║</error>'); |
| 94 | + $io->writeError('<error>╚════════════════════════════════════════════════════════════════════╝</error>'); |
| 95 | + $io->writeError(''); |
| 96 | + $io->writeError(' php-qa-ci provides PHPStan via a managed PHAR binary.'); |
| 97 | + $io->writeError(' Having phpstan/phpstan as a direct dependency causes version conflicts.'); |
| 98 | + $io->writeError(''); |
| 99 | + $io->writeError(' Fix: <comment>composer remove --dev phpstan/phpstan</comment>'); |
| 100 | + $io->writeError(''); |
| 101 | + } |
| 102 | + } |
| 103 | + |
| 104 | + /** |
| 105 | + * Validates the PHAR version satisfies the extension-installer's constraint. |
| 106 | + */ |
| 107 | + private function validatePharVersionConstraint(IOInterface $io, Composer $composer): void |
| 108 | + { |
| 109 | + $config = $composer->getConfig(); |
| 110 | + $vendorDir = $config->get('vendor-dir'); |
| 111 | + |
| 112 | + if (!is_string($vendorDir)) { |
| 113 | + return; |
| 114 | + } |
| 115 | + |
| 116 | + $generatedConfigPath = $vendorDir . '/phpstan/extension-installer/src/GeneratedConfig.php'; |
| 117 | + if (!\file_exists($generatedConfigPath)) { |
| 118 | + // No extensions installed, nothing to validate |
| 119 | + return; |
| 120 | + } |
| 121 | + |
| 122 | + $pharPath = $vendorDir . '/lts/php-qa-ci/vendor-phar/phpstan.phar'; |
| 123 | + if (!\file_exists($pharPath)) { |
| 124 | + $io->writeError('<warning>PHPStan phar not found at ' . $pharPath . '. Run: composer update</warning>'); |
| 125 | + |
| 126 | + return; |
| 127 | + } |
| 128 | + |
| 129 | + // Get phar version |
| 130 | + $pharVersion = $this->getPharVersion($pharPath); |
| 131 | + if (null === $pharVersion) { |
| 132 | + $io->writeError('<warning>Could not determine PHPStan phar version</warning>'); |
| 133 | + |
| 134 | + return; |
| 135 | + } |
| 136 | + |
| 137 | + // Get extension constraint from GeneratedConfig |
| 138 | + $constraint = $this->getExtensionConstraint($generatedConfigPath); |
| 139 | + if (null === $constraint) { |
| 140 | + // No constraint means no extensions or no version requirement |
| 141 | + return; |
| 142 | + } |
| 143 | + |
| 144 | + // Parse and validate |
| 145 | + if (!$this->versionSatisfiesConstraint($pharVersion, $constraint)) { |
| 146 | + $io->writeError(''); |
| 147 | + $io->writeError('<error>╔══════════════════════════════════════════════════════════════════╗</error>'); |
| 148 | + $io->writeError('<error>║ PHPStan phar version does not satisfy extension constraints ║</error>'); |
| 149 | + $io->writeError('<error>╚══════════════════════════════════════════════════════════════════╝</error>'); |
| 150 | + $io->writeError(''); |
| 151 | + $io->writeError(' Phar version: <comment>' . $pharVersion . '</comment>'); |
| 152 | + $io->writeError(' Extension constraint: <comment>' . $constraint . '</comment>'); |
| 153 | + $io->writeError(''); |
| 154 | + $io->writeError(' The PHPStan phar bundled with php-qa-ci is too old for the'); |
| 155 | + $io->writeError(' installed PHPStan extensions.'); |
| 156 | + $io->writeError(''); |
| 157 | + $io->writeError(' Fix: Update the phar by running:'); |
| 158 | + $io->writeError(' <comment>cd vendor/lts/php-qa-ci && bash scripts/phive-install.bash update</comment>'); |
| 159 | + $io->writeError(''); |
| 160 | + } else { |
| 161 | + $io->write('<info>✓ PHPStan phar v' . $pharVersion . ' satisfies extension constraint ' . $constraint . '</info>'); |
| 162 | + } |
| 163 | + } |
| 164 | + |
| 165 | + /** |
| 166 | + * Extracts the PHPStan phar version by running it. |
| 167 | + */ |
| 168 | + private function getPharVersion(string $pharPath): ?string |
| 169 | + { |
| 170 | + $output = []; |
| 171 | + $exitCode = 0; |
| 172 | + \exec('php ' . \escapeshellarg($pharPath) . ' --version 2>/dev/null', $output, $exitCode); |
| 173 | + |
| 174 | + if (0 !== $exitCode || [] === $output) { |
| 175 | + return null; |
| 176 | + } |
| 177 | + |
| 178 | + // Output: "PHPStan - PHP Static Analysis Tool 2.1.40" |
| 179 | + foreach ($output as $line) { |
| 180 | + if (\preg_match('/(\d+\.\d+\.\d+)/', $line, $matches) === 1) { |
| 181 | + return $matches[1]; |
| 182 | + } |
| 183 | + } |
| 184 | + |
| 185 | + return null; |
| 186 | + } |
| 187 | + |
| 188 | + /** |
| 189 | + * Reads the version constraint from the extension-installer's GeneratedConfig. |
| 190 | + */ |
| 191 | + private function getExtensionConstraint(string $generatedConfigPath): ?string |
| 192 | + { |
| 193 | + $contents = \file_get_contents($generatedConfigPath); |
| 194 | + if (false === $contents) { |
| 195 | + return null; |
| 196 | + } |
| 197 | + |
| 198 | + // Match: public const PHPSTAN_VERSION_CONSTRAINT = '>=2.1.39.0-dev, <3.0.0.0-dev'; |
| 199 | + if (\preg_match("/PHPSTAN_VERSION_CONSTRAINT\s*=\s*'([^']+)'/", $contents, $matches) === 1) { |
| 200 | + return $matches[1]; |
| 201 | + } |
| 202 | + |
| 203 | + return null; |
| 204 | + } |
| 205 | + |
| 206 | + /** |
| 207 | + * Checks if a version satisfies a simple >=X, <Y constraint. |
| 208 | + */ |
| 209 | + private function versionSatisfiesConstraint(string $version, string $constraint): bool |
| 210 | + { |
| 211 | + // Parse constraint like ">=2.1.39.0-dev, <3.0.0.0-dev" |
| 212 | + $parts = \array_map('trim', \explode(',', $constraint)); |
| 213 | + |
| 214 | + foreach ($parts as $part) { |
| 215 | + if (\str_starts_with($part, '>=')) { |
| 216 | + $minVersion = \str_replace(['-dev', '-stable'], '', \substr($part, 2)); |
| 217 | + if (\version_compare($version, $minVersion, '<')) { |
| 218 | + return false; |
| 219 | + } |
| 220 | + } elseif (\str_starts_with($part, '<')) { |
| 221 | + $maxVersion = \str_replace(['-dev', '-stable'], '', \substr($part, 1)); |
| 222 | + if (\version_compare($version, $maxVersion, '>=')) { |
| 223 | + return false; |
| 224 | + } |
| 225 | + } |
| 226 | + } |
| 227 | + |
| 228 | + return true; |
| 229 | + } |
| 230 | +} |
0 commit comments