Skip to content

Commit f175bbf

Browse files
Joseph Edmondsclaude
andcommitted
feat: PHPStan guard plugin and replace to prevent duplicate installs
- Add "replace": {"phpstan/phpstan": "*"} to prevent composer from installing phpstan/phpstan as a package in consuming projects. PHPStan is provided via PHIVE-managed phar. - New PhpStanGuardPlugin composer plugin that: - Errors if project has phpstan/phpstan in require or require-dev - Validates phar version satisfies extension-installer constraints - Provides clear fix instructions when issues are found Projects can still install PHPStan extensions (phpstan-doctrine, phpstan-symfony, etc.) - their dependency on phpstan/phpstan is satisfied by the replace declaration. Ref: #3 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 78905a9 commit f175bbf

2 files changed

Lines changed: 235 additions & 1 deletion

File tree

composer.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@
2323
"require-dev": {
2424
"roave/security-advisories": "dev-master"
2525
},
26+
"replace": {
27+
"phpstan/phpstan": "*"
28+
},
2629
"suggest": {
2730
"nunomaduro/larastan": "Laravel specific checks (PHPStan Wrapper)",
2831
"phploc/phploc": "get some stats, not currently compatible with latest symfony",
@@ -77,7 +80,8 @@
7780
"extra": {
7881
"class": [
7982
"LTS\\PHPQA\\ComposerPlugin\\PhiveUpdatePlugin",
80-
"LTS\\PHPQA\\ComposerPlugin\\SkillsDeployPlugin"
83+
"LTS\\PHPQA\\ComposerPlugin\\SkillsDeployPlugin",
84+
"LTS\\PHPQA\\ComposerPlugin\\PhpStanGuardPlugin"
8185
]
8286
}
8387
}
Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
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

Comments
 (0)