Skip to content

Commit f4a9d5f

Browse files
committed
Dedicated CLI option --generate-baseline with improved DX
1 parent be8ff32 commit f4a9d5f

15 files changed

+490
-17
lines changed

.github/workflows/build.yml

+36
Original file line numberDiff line numberDiff line change
@@ -446,3 +446,39 @@ jobs:
446446
../bin/phpstan analyse -l 8 src tests && \
447447
php bin/compile && \
448448
../tmp/phpstan.phar
449+
450+
generate-baseline:
451+
name: "Generate baseline"
452+
453+
runs-on: "ubuntu-latest"
454+
455+
strategy:
456+
matrix:
457+
php-version:
458+
- "7.4"
459+
460+
steps:
461+
- name: "Checkout"
462+
uses: "actions/[email protected]"
463+
464+
- name: "Install PHP"
465+
uses: "shivammathur/[email protected]"
466+
with:
467+
coverage: "none"
468+
php-version: "${{ matrix.php-version }}"
469+
470+
- name: "Cache dependencies"
471+
uses: "actions/[email protected]"
472+
with:
473+
path: "~/.composer/cache"
474+
key: "php-${{ matrix.php-version }}-composer-${{ hashFiles('**/composer.json') }}"
475+
restore-keys: "php-${{ matrix.php-version }}-composer-"
476+
477+
- name: "Install dependencies"
478+
run: "composer update --no-interaction --no-progress --no-suggest"
479+
480+
- name: "Generate baseline"
481+
run: |
482+
cp phpstan-baseline.neon phpstan-baseline-orig.neon && \
483+
vendor/bin/phing phpstan-generate-baseline && \
484+
diff phpstan-baseline.neon phpstan-baseline-orig.neon

build.xml

+42
Original file line numberDiff line numberDiff line change
@@ -385,4 +385,46 @@
385385
</exec>
386386
</target>
387387

388+
<target name="phpstan-generate-baseline">
389+
<property name="phpstan.config" value="build/phpstan-generated.neon"/>
390+
<touch file="${phpstan.config}"/>
391+
<append
392+
destFile="${phpstan.config}"
393+
text="includes: [ phpstan.neon"
394+
append="false"
395+
></append>
396+
<if>
397+
<equals arg1="${isPHP74}" arg2="true" />
398+
<then>
399+
<append
400+
destFile="${phpstan.config}"
401+
text=", ignore-gte-php7.4-errors.neon"
402+
></append>
403+
</then>
404+
</if>
405+
<append
406+
destFile="${phpstan.config}"
407+
text=" ]"
408+
></append>
409+
<exec
410+
executable="php"
411+
logoutput="true"
412+
passthru="true"
413+
checkreturn="true"
414+
>
415+
<arg value="-d"/>
416+
<arg value="memory_limit=512M"/>
417+
<arg path="bin/phpstan"/>
418+
<arg value="analyse"/>
419+
<arg value="-c"/>
420+
<arg path="${phpstan.config}"/>
421+
<arg value="-l"/>
422+
<arg value="8"/>
423+
<arg path="build/PHPStan"/>
424+
<arg path="src"/>
425+
<arg path="tests"/>
426+
<arg value="--generate-baseline"/>
427+
</exec>
428+
</target>
429+
388430
</project>

phpstan-baseline.neon

-3
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
2-
31
parameters:
42
ignoreErrors:
53
-
@@ -197,4 +195,3 @@ parameters:
197195
count: 1
198196
path: tests/PHPStan/Node/FileNodeTest.php
199197

200-

src/Command/AnalyseCommand.php

+118
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,19 @@
22

33
namespace PHPStan\Command;
44

5+
use PHPStan\Command\ErrorFormatter\BaselineNeonErrorFormatter;
56
use PHPStan\Command\ErrorFormatter\ErrorFormatter;
7+
use PHPStan\Command\Symfony\SymfonyOutput;
8+
use PHPStan\Command\Symfony\SymfonyStyle;
9+
use PHPStan\File\ParentDirectoryRelativePathHelper;
610
use Symfony\Component\Console\Input\InputArgument;
711
use Symfony\Component\Console\Input\InputInterface;
812
use Symfony\Component\Console\Input\InputOption;
13+
use Symfony\Component\Console\Input\StringInput;
914
use Symfony\Component\Console\Output\OutputInterface;
15+
use Symfony\Component\Console\Output\StreamOutput;
16+
use function file_put_contents;
17+
use function stream_get_contents;
1018

1119
class AnalyseCommand extends \Symfony\Component\Console\Command\Command
1220
{
@@ -44,6 +52,7 @@ protected function configure(): void
4452
new InputOption('debug', null, InputOption::VALUE_NONE, 'Show debug information - which file is analysed, do not catch internal errors'),
4553
new InputOption('autoload-file', 'a', InputOption::VALUE_REQUIRED, 'Project\'s additional autoload file path'),
4654
new InputOption('error-format', null, InputOption::VALUE_REQUIRED, 'Format in which to print the result of the analysis', 'table'),
55+
new InputOption('generate-baseline', null, InputOption::VALUE_OPTIONAL, 'Path to a file where the baseline should be saved', false),
4756
new InputOption('memory-limit', null, InputOption::VALUE_REQUIRED, 'Memory limit for analysis'),
4857
new InputOption('xdebug', null, InputOption::VALUE_NONE, 'Allow running with XDebug for debugging purposes'),
4958
]);
@@ -79,6 +88,14 @@ protected function execute(InputInterface $input, OutputInterface $output): int
7988
$pathsFile = $input->getOption('paths-file');
8089
$allowXdebug = $input->getOption('xdebug');
8190

91+
/** @var string|false|null $generateBaselineFile */
92+
$generateBaselineFile = $input->getOption('generate-baseline');
93+
if ($generateBaselineFile === false) {
94+
$generateBaselineFile = null;
95+
} elseif ($generateBaselineFile === null) {
96+
$generateBaselineFile = 'phpstan-baseline.neon';
97+
}
98+
8299
if (
83100
!is_array($paths)
84101
|| (!is_string($memoryLimit) && $memoryLimit !== null)
@@ -101,6 +118,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int
101118
$autoloadFile,
102119
$this->composerAutoloaderProjectPaths,
103120
$configuration,
121+
$generateBaselineFile,
104122
$level,
105123
$allowXdebug,
106124
true
@@ -129,6 +147,19 @@ protected function execute(InputInterface $input, OutputInterface $output): int
129147
return 1;
130148
}
131149

150+
if ($errorFormat === 'baselineNeon') {
151+
$errorOutput = $inceptionResult->getErrorOutput();
152+
$errorOutput->writeLineFormatted('⚠️ You\'re using an obsolete option <fg=cyan>--error-format baselineNeon</>. ⚠️️');
153+
$errorOutput->writeLineFormatted('');
154+
$errorOutput->writeLineFormatted(' There\'s a new and much better option <fg=cyan>--generate-baseline</>. Here are the advantages:');
155+
$errorOutput->writeLineFormatted(' 1) The current baseline file does not have to be commented-out');
156+
$errorOutput->writeLineFormatted(' nor emptied when generating the new baseline. It\'s excluded automatically.');
157+
$errorOutput->writeLineFormatted(' 2) Output no longer has to be redirected to a file, PHPStan saves the baseline');
158+
$errorOutput->writeLineFormatted(' to a specified path (defaults to <fg=cyan>phpstan-baseline.neon</>).');
159+
$errorOutput->writeLineFormatted(' 3) Baseline contains correct relative paths if saved to a subdirectory.');
160+
$errorOutput->writeLineFormatted('');
161+
}
162+
132163
/** @var ErrorFormatter $errorFormatter */
133164
$errorFormatter = $container->getService($errorFormatterServiceName);
134165

@@ -140,6 +171,21 @@ protected function execute(InputInterface $input, OutputInterface $output): int
140171
throw new \PHPStan\ShouldNotHappenException();
141172
}
142173

174+
$generateBaselineFile = $inceptionResult->getGenerateBaselineFile();
175+
if ($generateBaselineFile !== null) {
176+
$baselineExtension = pathinfo($generateBaselineFile, PATHINFO_EXTENSION);
177+
if ($baselineExtension === '') {
178+
$inceptionResult->getStdOutput()->getStyle()->error(sprintf('Baseline filename must have an extension, %s provided instead.', pathinfo($generateBaselineFile, PATHINFO_BASENAME)));
179+
return $inceptionResult->handleReturn(1);
180+
}
181+
182+
if ($baselineExtension !== 'neon') {
183+
$inceptionResult->getStdOutput()->getStyle()->error(sprintf('Baseline filename extension must be .neon, .%s was used instead.', $baselineExtension));
184+
185+
return $inceptionResult->handleReturn(1);
186+
}
187+
}
188+
143189
$analysisResult = $application->analyse(
144190
$inceptionResult->getFiles(),
145191
$inceptionResult->isOnlyFiles(),
@@ -151,9 +197,81 @@ protected function execute(InputInterface $input, OutputInterface $output): int
151197
$input
152198
);
153199

200+
if ($generateBaselineFile !== null) {
201+
if (!$analysisResult->hasErrors()) {
202+
$inceptionResult->getStdOutput()->getStyle()->error('No errors were found during the analysis. Baseline could not be generated.');
203+
204+
return $inceptionResult->handleReturn(1);
205+
}
206+
207+
$baselineFileDirectory = dirname($generateBaselineFile);
208+
$baselineErrorFormatter = new BaselineNeonErrorFormatter(new ParentDirectoryRelativePathHelper($baselineFileDirectory));
209+
210+
$streamOutput = $this->createStreamOutput();
211+
$errorConsoleStyle = new ErrorsConsoleStyle(new StringInput(''), $streamOutput);
212+
$output = new SymfonyOutput($streamOutput, new SymfonyStyle($errorConsoleStyle));
213+
$baselineErrorFormatter->formatErrors($analysisResult, $output);
214+
215+
$stream = $streamOutput->getStream();
216+
rewind($stream);
217+
$baselineContents = stream_get_contents($stream);
218+
if ($baselineContents === false) {
219+
throw new \PHPStan\ShouldNotHappenException();
220+
}
221+
222+
if (!is_dir($baselineFileDirectory)) {
223+
$mkdirResult = @mkdir($baselineFileDirectory, 0644, true);
224+
if ($mkdirResult === false) {
225+
$inceptionResult->getStdOutput()->writeLineFormatted(sprintf('Failed to create directory "%s".', $baselineFileDirectory));
226+
227+
return $inceptionResult->handleReturn(1);
228+
}
229+
}
230+
231+
$writeResult = @file_put_contents($generateBaselineFile, $baselineContents);
232+
if ($writeResult === false) {
233+
$inceptionResult->getStdOutput()->writeLineFormatted(sprintf('Failed to write the baseline to file "%s".', $generateBaselineFile));
234+
235+
return $inceptionResult->handleReturn(1);
236+
}
237+
238+
$errorsCount = 0;
239+
$unignorableCount = 0;
240+
foreach ($analysisResult->getFileSpecificErrors() as $fileSpecificError) {
241+
if (!$fileSpecificError->canBeIgnored()) {
242+
$unignorableCount++;
243+
continue;
244+
}
245+
246+
$errorsCount++;
247+
}
248+
249+
$message = sprintf('Baseline generated with %d %s.', $errorsCount, $errorsCount === 1 ? 'error' : 'errors');
250+
251+
if (
252+
$unignorableCount === 0
253+
&& count($analysisResult->getNotFileSpecificErrors()) === 0
254+
) {
255+
$inceptionResult->getStdOutput()->getStyle()->success($message);
256+
} else {
257+
$inceptionResult->getStdOutput()->getStyle()->warning($message . "\nSome errors could not be put into baseline. Re-run PHPStan and fix them.");
258+
}
259+
260+
return $inceptionResult->handleReturn(0);
261+
}
262+
154263
return $inceptionResult->handleReturn(
155264
$errorFormatter->formatErrors($analysisResult, $inceptionResult->getStdOutput())
156265
);
157266
}
158267

268+
private function createStreamOutput(): StreamOutput
269+
{
270+
$resource = fopen('php://memory', 'w', false);
271+
if ($resource === false) {
272+
throw new \PHPStan\ShouldNotHappenException();
273+
}
274+
return new StreamOutput($resource);
275+
}
276+
159277
}

src/Command/ClearResultCacheCommand.php

+1
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int
5959
$autoloadFile,
6060
$this->composerAutoloaderProjectPaths,
6161
$configuration,
62+
null,
6263
'0',
6364
false,
6465
false

src/Command/CommandHelper.php

+34-6
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ public static function begin(
3939
?string $autoloadFile,
4040
array $composerAutoloaderProjectPaths,
4141
?string $projectConfigFile,
42+
?string $generateBaselineFile,
4243
?string $level,
4344
bool $allowXdebug,
4445
bool $manageMemoryLimitFile = true
@@ -95,6 +96,11 @@ public static function begin(
9596
} else {
9697
$projectConfigFile = $currentWorkingDirectoryFileHelper->absolutizePath($projectConfigFile);
9798
}
99+
100+
if ($generateBaselineFile !== null) {
101+
$generateBaselineFile = $currentWorkingDirectoryFileHelper->normalizePath($currentWorkingDirectoryFileHelper->absolutizePath($generateBaselineFile));
102+
}
103+
98104
$defaultLevelUsed = false;
99105
if ($projectConfigFile === null && $level === null) {
100106
$level = self::DEFAULT_LEVEL;
@@ -138,8 +144,10 @@ public static function begin(
138144
}
139145

140146
$loader = (new LoaderFactory(
147+
$currentWorkingDirectoryFileHelper,
141148
$containerFactory->getRootDirectory(),
142-
$containerFactory->getCurrentWorkingDirectory()
149+
$containerFactory->getCurrentWorkingDirectory(),
150+
$generateBaselineFile
143151
))->createLoader();
144152

145153
try {
@@ -217,8 +225,21 @@ public static function begin(
217225
}
218226
}
219227

228+
if ($projectConfigFile !== null) {
229+
$allCustomConfigFiles = self::getConfigFiles(
230+
$currentWorkingDirectoryFileHelper,
231+
new NeonAdapter(),
232+
new PhpAdapter(),
233+
$projectConfigFile,
234+
$loaderParameters,
235+
$generateBaselineFile
236+
);
237+
} else {
238+
$allCustomConfigFiles = [];
239+
}
240+
220241
try {
221-
$container = $containerFactory->create($tmpDir, $additionalConfigFiles, $paths, $composerAutoloaderProjectPaths, $analysedPathsFromConfig, $projectConfigFile !== null ? self::getConfigFiles(new NeonAdapter(), new PhpAdapter(), $projectConfigFile, $loaderParameters) : [], $level ?? self::DEFAULT_LEVEL);
242+
$container = $containerFactory->create($tmpDir, $additionalConfigFiles, $paths, $composerAutoloaderProjectPaths, $analysedPathsFromConfig, $allCustomConfigFiles, $level ?? self::DEFAULT_LEVEL, $generateBaselineFile);
222243
} catch (\Nette\DI\InvalidConfigurationException | \Nette\Utils\AssertionException $e) {
223244
$errorOutput->writeLineFormatted('<error>Invalid configuration:</error>');
224245
$errorOutput->writeLineFormatted($e->getMessage());
@@ -348,7 +369,8 @@ public static function begin(
348369
$container,
349370
$defaultLevelUsed,
350371
$memoryLimitFile,
351-
$projectConfigFile
372+
$projectConfigFile,
373+
$generateBaselineFile
352374
);
353375
}
354376

@@ -386,7 +408,7 @@ private static function detectDuplicateIncludedFiles(
386408
$phpAdapter = new PhpAdapter();
387409
$allConfigFiles = [];
388410
foreach ($configFiles as $configFile) {
389-
$allConfigFiles = array_merge($allConfigFiles, self::getConfigFiles($neonAdapter, $phpAdapter, $configFile, $loaderParameters));
411+
$allConfigFiles = array_merge($allConfigFiles, self::getConfigFiles($fileHelper, $neonAdapter, $phpAdapter, $configFile, $loaderParameters, null));
390412
}
391413

392414
$normalized = array_map(static function (string $file) use ($fileHelper): string {
@@ -416,15 +438,21 @@ private static function detectDuplicateIncludedFiles(
416438
* @param \Nette\DI\Config\Adapters\PhpAdapter $phpAdapter
417439
* @param string $configFile
418440
* @param array<string, string> $loaderParameters
441+
* @param string|null $generateBaselineFile
419442
* @return string[]
420443
*/
421444
private static function getConfigFiles(
445+
FileHelper $fileHelper,
422446
NeonAdapter $neonAdapter,
423447
PhpAdapter $phpAdapter,
424448
string $configFile,
425-
array $loaderParameters
449+
array $loaderParameters,
450+
?string $generateBaselineFile
426451
): array
427452
{
453+
if ($generateBaselineFile === $fileHelper->normalizePath($configFile)) {
454+
return [];
455+
}
428456
if (!is_file($configFile) || !is_readable($configFile)) {
429457
return [];
430458
}
@@ -440,7 +468,7 @@ private static function getConfigFiles(
440468
$includes = Helpers::expand($data['includes'], $loaderParameters);
441469
foreach ($includes as $include) {
442470
$include = self::expandIncludedFile($include, $configFile);
443-
$allConfigFiles = array_merge($allConfigFiles, self::getConfigFiles($neonAdapter, $phpAdapter, $include, $loaderParameters));
471+
$allConfigFiles = array_merge($allConfigFiles, self::getConfigFiles($fileHelper, $neonAdapter, $phpAdapter, $include, $loaderParameters, $generateBaselineFile));
444472
}
445473
}
446474

src/Command/DumpDependenciesCommand.php

+1
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int
7575
$autoloadFile,
7676
$this->composerAutoloaderProjectPaths,
7777
$configurationFile,
78+
null,
7879
'0', // irrelevant but prevents an error when a config file is passed
7980
$allowXdebug,
8081
true

0 commit comments

Comments
 (0)