Skip to content

Commit 71af603

Browse files
committed
Add --ignore-new-errors option for baseline generation
1 parent 1af6272 commit 71af603

File tree

10 files changed

+327
-6
lines changed

10 files changed

+327
-6
lines changed

build/phpstan.neon

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ parameters:
2424
checkMissingCallableSignature: true
2525
excludePaths:
2626
- ../tests/*/data/*
27+
- ../tests/*/data-*/*
2728
- ../tests/tmp/*
2829
- ../tests/PHPStan/Analyser/nsrt/*
2930
- ../tests/PHPStan/Analyser/traits/*

phpcs.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,7 @@
209209
<exclude-pattern>tests/*/Fixture/</exclude-pattern>
210210
<exclude-pattern>tests/*/cache/</exclude-pattern>
211211
<exclude-pattern>tests/*/data/</exclude-pattern>
212+
<exclude-pattern>tests/*/data-*/</exclude-pattern>
212213
<exclude-pattern>tests/*/traits/</exclude-pattern>
213214
<exclude-pattern>tests/PHPStan/Analyser/nsrt/</exclude-pattern>
214215
<exclude-pattern>tests/e2e/anon-class/</exclude-pattern>

src/Command/AnalyseCommand.php

Lines changed: 180 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,11 @@
22

33
namespace PHPStan\Command;
44

5+
use Nette\DI\Config\Loader;
6+
use Nette\FileNotFoundException;
7+
use Nette\InvalidStateException;
58
use OndraM\CiDetector\CiDetector;
9+
use PHPStan\Analyser\Ignore\IgnoredError;
610
use PHPStan\Analyser\InternalError;
711
use PHPStan\Command\ErrorFormatter\BaselineNeonErrorFormatter;
812
use PHPStan\Command\ErrorFormatter\BaselinePhpErrorFormatter;
@@ -102,6 +106,7 @@ protected function configure(): void
102106
new InputOption('watch', null, InputOption::VALUE_NONE, 'Launch PHPStan Pro'),
103107
new InputOption('pro', null, InputOption::VALUE_NONE, 'Launch PHPStan Pro'),
104108
new InputOption('fail-without-result-cache', null, InputOption::VALUE_NONE, 'Return non-zero exit code when result cache is not used'),
109+
new InputOption('ignore-new-errors', null, InputOption::VALUE_NONE, 'Ignore new errors when generating the baseline.'),
105110
]);
106111
}
107112

@@ -136,6 +141,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int
136141
$debugEnabled = (bool) $input->getOption('debug');
137142
$fix = (bool) $input->getOption('fix') || (bool) $input->getOption('watch') || (bool) $input->getOption('pro');
138143
$failWithoutResultCache = (bool) $input->getOption('fail-without-result-cache');
144+
$ignoreNewErrors = (bool) $input->getOption('ignore-new-errors');
139145

140146
/** @var string|false|null $generateBaselineFile */
141147
$generateBaselineFile = $input->getOption('generate-baseline');
@@ -182,6 +188,11 @@ protected function execute(InputInterface $input, OutputInterface $output): int
182188
return $inceptionResult->handleReturn(1, null, $this->analysisStartTime);
183189
}
184190

191+
if ($generateBaselineFile === null && $ignoreNewErrors) {
192+
$inceptionResult->getStdOutput()->getStyle()->error('You must pass the --generate-baseline option alongside --ignore-new-errors.');
193+
return $inceptionResult->handleReturn(1, null, $this->analysisStartTime);
194+
}
195+
185196
$errorOutput = $inceptionResult->getErrorOutput();
186197
$errorFormat = $input->getOption('error-format');
187198

@@ -411,7 +422,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int
411422
return $inceptionResult->handleReturn(1, $analysisResult->getPeakMemoryUsageBytes(), $this->analysisStartTime);
412423
}
413424

414-
return $this->generateBaseline($generateBaselineFile, $inceptionResult, $analysisResult, $output, $allowEmptyBaseline, $baselineExtension, $failWithoutResultCache);
425+
return $this->generateBaseline($generateBaselineFile, $inceptionResult, $analysisResult, $output, $allowEmptyBaseline, $baselineExtension, $failWithoutResultCache, $ignoreNewErrors, $container);
415426
}
416427

417428
/** @var ErrorFormatter $errorFormatter */
@@ -587,8 +598,13 @@ private function getMessageFromInternalError(FileHelper $fileHelper, InternalErr
587598
return $message;
588599
}
589600

590-
private function generateBaseline(string $generateBaselineFile, InceptionResult $inceptionResult, AnalysisResult $analysisResult, OutputInterface $output, bool $allowEmptyBaseline, string $baselineExtension, bool $failWithoutResultCache): int
601+
private function generateBaseline(string $generateBaselineFile, InceptionResult $inceptionResult, AnalysisResult $analysisResult, OutputInterface $output, bool $allowEmptyBaseline, string $baselineExtension, bool $failWithoutResultCache, bool $ignoreNewErrors, Container $container): int
591602
{
603+
$baselineFileDirectory = dirname($generateBaselineFile);
604+
$fileHelper = $container->getByType(FileHelper::class);
605+
$baselinePathHelper = new ParentDirectoryRelativePathHelper($baselineFileDirectory);
606+
$analysisResult = $this->processFileSpecificErrorsFromAnalysisResult($analysisResult, $ignoreNewErrors, $generateBaselineFile, $inceptionResult, $fileHelper, $baselinePathHelper);
607+
592608
if (!$allowEmptyBaseline && !$analysisResult->hasErrors()) {
593609
$inceptionResult->getStdOutput()->getStyle()->error('No errors were found during the analysis. Baseline could not be generated.');
594610
$inceptionResult->getStdOutput()->writeLineFormatted('To allow generating empty baselines, pass <fg=cyan>--allow-empty-baseline</> option.');
@@ -599,7 +615,6 @@ private function generateBaseline(string $generateBaselineFile, InceptionResult
599615
$streamOutput = $this->createStreamOutput();
600616
$errorConsoleStyle = new ErrorsConsoleStyle(new StringInput(''), $streamOutput);
601617
$baselineOutput = new SymfonyOutput($streamOutput, new SymfonyStyle($errorConsoleStyle));
602-
$baselineFileDirectory = dirname($generateBaselineFile);
603618
$baselinePathHelper = new ParentDirectoryRelativePathHelper($baselineFileDirectory);
604619

605620
if ($baselineExtension === 'php') {
@@ -674,6 +689,63 @@ private function generateBaseline(string $generateBaselineFile, InceptionResult
674689
return $inceptionResult->handleReturn($exitCode, $analysisResult->getPeakMemoryUsageBytes(), $this->analysisStartTime);
675690
}
676691

692+
private function processFileSpecificErrorsFromAnalysisResult(AnalysisResult $analysisResult, bool $ignoreNewErrors, string $generateBaselineFile, InceptionResult $inceptionResult, FileHelper $fileHelper, RelativePathHelper $baselinePathHelper): AnalysisResult
693+
{
694+
$fileSpecificErrors = $analysisResult->getFileSpecificErrors();
695+
if (!$ignoreNewErrors) {
696+
return $analysisResult;
697+
}
698+
699+
$baselineIgnoreErrors = $this->getCurrentBaselineIgnoreErrors($generateBaselineFile, $inceptionResult);
700+
$ignoreErrorsByFile = $this->mapIgnoredErrors($baselineIgnoreErrors, $fileHelper);
701+
702+
foreach ($fileSpecificErrors as $errorIndex => $error) {
703+
$filePath = $baselinePathHelper->getRelativePath($error->getFilePath());
704+
if (isset($ignoreErrorsByFile[$filePath])) {
705+
foreach ($ignoreErrorsByFile[$filePath] as $ignoreError) {
706+
$ignore = $ignoreError['ignoreError'];
707+
$shouldIgnore = IgnoredError::shouldIgnore($fileHelper, $error, $ignore['message'] ?? null, $ignore['identifier'] ?? null, null);
708+
if ($shouldIgnore) {
709+
continue 2;
710+
}
711+
}
712+
}
713+
714+
$traitFilePath = $error->getTraitFilePath();
715+
if ($traitFilePath !== null) {
716+
$normalizedTraitFilePath = $baselinePathHelper->getRelativePath($traitFilePath);
717+
if (isset($ignoreErrorsByFile[$normalizedTraitFilePath])) {
718+
foreach ($ignoreErrorsByFile[$normalizedTraitFilePath] as $ignoreError) {
719+
$ignore = $ignoreError['ignoreError'];
720+
$shouldIgnore = IgnoredError::shouldIgnore($fileHelper, $error, $ignore['message'] ?? null, $ignore['identifier'] ?? null, null);
721+
if ($shouldIgnore) {
722+
continue 2;
723+
}
724+
}
725+
}
726+
}
727+
728+
// the error was not matched in the baseline, making it a new error, new errors should be ignored here
729+
unset($fileSpecificErrors[$errorIndex]);
730+
}
731+
732+
$fileSpecificErrors = array_values($fileSpecificErrors);
733+
734+
return new AnalysisResult(
735+
$fileSpecificErrors,
736+
$analysisResult->getNotFileSpecificErrors(),
737+
$analysisResult->getInternalErrorObjects(),
738+
$analysisResult->getWarnings(),
739+
$analysisResult->getCollectedData(),
740+
$analysisResult->isDefaultLevelUsed(),
741+
$analysisResult->getProjectConfigFile(),
742+
$analysisResult->isResultCacheSaved(),
743+
$analysisResult->getPeakMemoryUsageBytes(),
744+
$analysisResult->isResultCacheUsed(),
745+
$analysisResult->getChangedProjectExtensionFilesOutsideOfAnalysedPaths(),
746+
);
747+
}
748+
677749
/**
678750
* @param string[] $files
679751
*/
@@ -716,4 +788,109 @@ private function runDiagnoseExtensions(Container $container, Output $errorOutput
716788
}
717789
}
718790

791+
private function getCurrentBaselineIgnoreErrors(string $generateBaselineFile, InceptionResult $inceptionResult): mixed
792+
{
793+
$loader = new Loader();
794+
try {
795+
$currentBaselineConfig = $loader->load($generateBaselineFile);
796+
$baselineIgnoreErrors = $currentBaselineConfig['parameters']['ignoreErrors'] ?? [];
797+
} catch (FileNotFoundException) {
798+
// currently no baseline file -> empty config
799+
$baselineIgnoreErrors = [];
800+
} catch (InvalidStateException $invalidStateException) {
801+
$inceptionResult->getErrorOutput()->writeLineFormatted($invalidStateException->getMessage());
802+
throw $invalidStateException;
803+
}
804+
return $baselineIgnoreErrors;
805+
}
806+
807+
/**
808+
* @param (string|mixed[])[] $baselineIgnoreErrors
809+
* @return mixed[][]
810+
* @throws ShouldNotHappenException
811+
*/
812+
private function mapIgnoredErrors(array $baselineIgnoreErrors, FileHelper $fileHelper): array
813+
{
814+
$ignoreErrorsByFile = [];
815+
816+
$expandedIgnoreErrors = [];
817+
foreach ($baselineIgnoreErrors as $ignoreError) {
818+
if (!is_array($ignoreError)) {
819+
throw new ShouldNotHappenException('Baseline should not have ignore error strings');
820+
}
821+
822+
if (!isset($ignoreError['message']) && !isset($ignoreError['messages']) && !isset($ignoreError['identifier'])) {
823+
continue;
824+
}
825+
if (isset($ignoreError['messages'])) {
826+
foreach ($ignoreError['messages'] as $message) {
827+
$expandedIgnoreError = $ignoreError;
828+
unset($expandedIgnoreError['messages']);
829+
$expandedIgnoreError['message'] = $message;
830+
$expandedIgnoreErrors[] = $expandedIgnoreError;
831+
}
832+
} else {
833+
$expandedIgnoreErrors[] = $ignoreError;
834+
}
835+
}
836+
$uniquedExpandedIgnoreErrors = [];
837+
foreach ($expandedIgnoreErrors as $ignoreError) {
838+
if (!isset($ignoreError['message']) && !isset($ignoreError['identifier'])) {
839+
$uniquedExpandedIgnoreErrors[] = $ignoreError;
840+
continue;
841+
}
842+
if (!isset($ignoreError['path'])) {
843+
$uniquedExpandedIgnoreErrors[] = $ignoreError;
844+
continue;
845+
}
846+
847+
$key = $ignoreError['path'];
848+
if (isset($ignoreError['message'])) {
849+
$key = sprintf("%s\n%s", $key, $ignoreError['message']);
850+
}
851+
if (isset($ignoreError['identifier'])) {
852+
$key = sprintf("%s\n%s", $key, $ignoreError['identifier']);
853+
}
854+
if ($key === '') {
855+
throw new ShouldNotHappenException();
856+
}
857+
858+
if (!array_key_exists($key, $uniquedExpandedIgnoreErrors)) {
859+
$uniquedExpandedIgnoreErrors[$key] = $ignoreError;
860+
continue;
861+
}
862+
863+
$uniquedExpandedIgnoreErrors[$key] = [
864+
'message' => $ignoreError['message'] ?? null,
865+
'path' => $ignoreError['path'],
866+
'identifier' => $ignoreError['identifier'] ?? null,
867+
'count' => ($uniquedExpandedIgnoreErrors[$key]['count'] ?? 1) + ($ignoreError['count'] ?? 1),
868+
'reportUnmatched' => false,
869+
];
870+
}
871+
$expandedIgnoreErrors = array_values($uniquedExpandedIgnoreErrors);
872+
873+
foreach ($expandedIgnoreErrors as $i => $ignoreError) {
874+
$ignoreErrorEntry = [
875+
'index' => $i,
876+
'ignoreError' => $ignoreError,
877+
];
878+
879+
if (!isset($ignoreError['message']) && !isset($ignoreError['identifier'])) {
880+
continue;
881+
}
882+
if (!isset($ignoreError['path'])) {
883+
throw new ShouldNotHappenException('Baseline should not have ignore errors without path');
884+
}
885+
886+
$normalizedPath = $fileHelper->normalizePath($ignoreError['path']);
887+
$ignoreError['path'] = $normalizedPath;
888+
$ignoreErrorsByFile[$normalizedPath][] = $ignoreErrorEntry;
889+
$ignoreError['realPath'] = $normalizedPath;
890+
$expandedIgnoreErrors[$i] = $ignoreError;
891+
}
892+
893+
return $ignoreErrorsByFile;
894+
}
895+
719896
}

tests/PHPStan/Command/AnalyseCommandTest.php

Lines changed: 74 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,14 @@
66
use PHPStan\Testing\PHPStanTestCase;
77
use Symfony\Component\Console\Tester\CommandTester;
88
use Throwable;
9+
use function array_merge;
910
use function chdir;
1011
use function getcwd;
1112
use function microtime;
1213
use function realpath;
14+
use function rename;
1315
use function sprintf;
16+
use function unlink;
1417
use const DIRECTORY_SEPARATOR;
1518
use const PHP_EOL;
1619

@@ -71,6 +74,74 @@ public function testValidAutoloadFile(): void
7174
}
7275
}
7376

77+
public function testGenerateBaselineIgnoreNewErrorsRemoveFile(): void
78+
{
79+
$baselineFile = __DIR__ . '/data-ignore-new-errors/baseline.neon';
80+
$this->runCommand(0, [
81+
'paths' => [__DIR__ . '/data-ignore-new-errors/A.php', __DIR__ . '/data-ignore-new-errors/B.php'],
82+
'--configuration' => __DIR__ . '/data-ignore-new-errors/empty.neon',
83+
'--level' => '9',
84+
'--generate-baseline' => $baselineFile,
85+
]);
86+
87+
$output = $this->runCommand(0, [
88+
'paths' => [__DIR__ . '/data-ignore-new-errors/B.php', __DIR__ . '/data-ignore-new-errors/C.php'],
89+
'--configuration' => $baselineFile,
90+
'--level' => '9',
91+
'--generate-baseline' => $baselineFile,
92+
'--ignore-new-errors' => true,
93+
]);
94+
@unlink($baselineFile);
95+
96+
$this->assertStringContainsString('[OK] Baseline generated with 1 error', $output);
97+
}
98+
99+
public function testGenerateBaselineIgnoreNewErrorsChangeFile(): void
100+
{
101+
$baselineFile = __DIR__ . '/data-ignore-new-errors-baseline/baseline.neon';
102+
$baselineFileSecondRun = __DIR__ . '/data-ignore-new-errors/baseline.neon';
103+
$this->runCommand(0, [
104+
'paths' => [__DIR__ . '/data-ignore-new-errors-baseline/A.php'],
105+
'--configuration' => __DIR__ . '/data-ignore-new-errors-baseline/empty.neon',
106+
'--level' => '9',
107+
'--generate-baseline' => $baselineFile,
108+
]);
109+
110+
rename($baselineFile, $baselineFileSecondRun);
111+
$output = $this->runCommand(0, [
112+
'paths' => [__DIR__ . '/data-ignore-new-errors/A.php'],
113+
'--configuration' => $baselineFileSecondRun,
114+
'--level' => '9',
115+
'--generate-baseline' => $baselineFileSecondRun,
116+
'--ignore-new-errors' => true,
117+
]);
118+
@unlink($baselineFileSecondRun);
119+
120+
$this->assertStringContainsString('[OK] Baseline generated with 2 errors', $output);
121+
}
122+
123+
public function testGenerateBaselineIgnoreNewErrorsEmptyBaseline(): void
124+
{
125+
$baselineFile = __DIR__ . '/data-ignore-new-errors/baseline.neon';
126+
$this->runCommand(0, [
127+
'paths' => [__DIR__ . '/data-ignore-new-errors/A.php', __DIR__ . '/data-ignore-new-errors/B.php'],
128+
'--configuration' => __DIR__ . '/data-ignore-new-errors/empty.neon',
129+
'--level' => '9',
130+
'--generate-baseline' => $baselineFile,
131+
]);
132+
133+
$output = $this->runCommand(1, [
134+
'paths' => [__DIR__ . '/data-ignore-new-errors/C.php'],
135+
'--configuration' => $baselineFile,
136+
'--level' => '9',
137+
'--generate-baseline' => $baselineFile,
138+
'--ignore-new-errors' => true,
139+
]);
140+
@unlink($baselineFile);
141+
142+
$this->assertStringContainsString('[ERROR] No errors were found during the analysis. Baseline could not be generated.', $output);
143+
}
144+
74145
/**
75146
* @return string[][]
76147
*/
@@ -117,16 +188,16 @@ public static function autoDiscoveryPathsProvider(): array
117188
}
118189

119190
/**
120-
* @param array<string, string> $parameters
191+
* @param array<string, string|string[]|bool> $parameters
121192
*/
122193
private function runCommand(int $expectedStatusCode, array $parameters = []): string
123194
{
124195
$commandTester = new CommandTester(new AnalyseCommand([], microtime(true)));
125196

126-
$commandTester->execute([
197+
$commandTester->execute(array_merge([
127198
'paths' => [__DIR__ . DIRECTORY_SEPARATOR . 'test'],
128199
'--debug' => true,
129-
] + $parameters, ['debug' => true]);
200+
], $parameters), ['debug' => true]);
130201

131202
$this->assertSame($expectedStatusCode, $commandTester->getStatusCode(), $commandTester->getDisplay());
132203

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace BaselineIntegration;
4+
5+
use function array_key_first;
6+
7+
class A
8+
{
9+
10+
public function doBar(): void
11+
{
12+
array_key_first();
13+
array_key_first();
14+
}
15+
16+
}

tests/PHPStan/Command/data-ignore-new-errors-baseline/empty.neon

Whitespace-only changes.

0 commit comments

Comments
 (0)