Skip to content

Commit 3e541c8

Browse files
N-Silbernagelondrejmirtes
authored andcommitted
Add --only-remove-errors analyse command option
Used together with --generate-baseline: removes baseline entries that no longer match any error, without adding new errors to the baseline. Amp-Thread-ID: https://ampcode.com/threads/T-01a19219-874b-7614-bea5-a5f416095107
1 parent 091c5a7 commit 3e541c8

3 files changed

Lines changed: 309 additions & 4 deletions

File tree

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\Analyser\Ignore;
4+
5+
use PHPStan\Analyser\Error;
6+
use PHPStan\DependencyInjection\AutowiredService;
7+
use PHPStan\File\FileHelper;
8+
use PHPStan\File\ParentDirectoryRelativePathHelper;
9+
use PHPStan\File\RelativePathHelper;
10+
11+
#[AutowiredService]
12+
final class BaselineIgnoredErrorHelper
13+
{
14+
15+
public function __construct(
16+
private FileHelper $fileHelper,
17+
)
18+
{
19+
}
20+
21+
/**
22+
* @param mixed[][] $baselinedErrors errors currently present in the baseline
23+
* @param list<Error> $currentAnalysisErrors errors from the current analysis
24+
* @return list<Error> errors from the current analysis which already exit in the baseline
25+
*/
26+
public function removeUnusedIgnoredErrors(array $baselinedErrors, array $currentAnalysisErrors, ParentDirectoryRelativePathHelper $baselinePathHelper): array
27+
{
28+
$ignoreErrorsByFile = $this->mapIgnoredErrorsByFile($baselinedErrors);
29+
30+
$ignoreUseCount = [];
31+
$nextBaselinedErrors = [];
32+
foreach ($currentAnalysisErrors as $error) {
33+
$hasMatchingIgnore = $this->checkIgnoreErrorByPath($error->getFilePath(), $ignoreErrorsByFile, $error, $ignoreUseCount, $baselinePathHelper);
34+
if ($hasMatchingIgnore) {
35+
$nextBaselinedErrors[] = $error;
36+
continue;
37+
}
38+
39+
$traitFilePath = $error->getTraitFilePath();
40+
if ($traitFilePath === null) {
41+
continue;
42+
}
43+
44+
$hasMatchingIgnore = $this->checkIgnoreErrorByPath($traitFilePath, $ignoreErrorsByFile, $error, $ignoreUseCount, $baselinePathHelper);
45+
if (!$hasMatchingIgnore) {
46+
continue;
47+
}
48+
49+
$nextBaselinedErrors[] = $error;
50+
}
51+
52+
return $nextBaselinedErrors;
53+
}
54+
55+
/**
56+
* @param mixed[][] $ignoreErrorsByFile
57+
* @param int[] $ignoreUseCount map of indexes of ignores and how often they have been "used" to ignore an error
58+
*/
59+
private function checkIgnoreErrorByPath(string $filePath, array $ignoreErrorsByFile, Error $error, array &$ignoreUseCount, RelativePathHelper $baselinePathHelper): bool
60+
{
61+
$relativePath = $baselinePathHelper->getRelativePath($filePath);
62+
if (!isset($ignoreErrorsByFile[$relativePath])) {
63+
return false;
64+
}
65+
66+
foreach ($ignoreErrorsByFile[$relativePath] as $ignoreError) {
67+
$ignore = $ignoreError['ignoreError'];
68+
$shouldIgnore = IgnoredError::shouldIgnore($this->fileHelper, $error, ignoredErrorPattern: $ignore['message'] ?? null, ignoredErrorMessage: $ignore['rawMessage'] ?? null, identifier: $ignore['identifier'] ?? null, path: null);
69+
if (!$shouldIgnore) {
70+
continue;
71+
}
72+
73+
$realCount = $ignoreUseCount[$ignoreError['index']] ?? 0;
74+
$realCount++;
75+
$ignoreUseCount[$ignoreError['index']] = $realCount;
76+
77+
if ($realCount <= $ignore['count']) {
78+
return true;
79+
}
80+
}
81+
82+
return false;
83+
}
84+
85+
/**
86+
* @param mixed[][] $baselineIgnoreErrors
87+
* @return mixed[][] ignored errors from baseline mapped and grouped by files
88+
*/
89+
private function mapIgnoredErrorsByFile(array $baselineIgnoreErrors): array
90+
{
91+
$ignoreErrorsByFile = [];
92+
93+
foreach ($baselineIgnoreErrors as $i => $ignoreError) {
94+
$ignoreErrorEntry = [
95+
'index' => $i,
96+
'ignoreError' => $ignoreError,
97+
];
98+
99+
$normalizedPath = $this->fileHelper->normalizePath($ignoreError['path']);
100+
$ignoreErrorsByFile[$normalizedPath][] = $ignoreErrorEntry;
101+
}
102+
103+
return $ignoreErrorsByFile;
104+
}
105+
106+
}

‎src/Command/AnalyseCommand.php‎

Lines changed: 52 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,12 @@
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;
69
use Override;
10+
use PHPStan\Analyser\Ignore\BaselineIgnoredErrorHelper;
711
use PHPStan\Analyser\InternalError;
812
use PHPStan\Command\ErrorFormatter\BaselineNeonErrorFormatter;
913
use PHPStan\Command\ErrorFormatter\BaselinePhpErrorFormatter;
@@ -120,6 +124,7 @@ protected function configure(): void
120124
new InputOption('watch', mode: InputOption::VALUE_NONE, description: 'Launch PHPStan Pro'),
121125
new InputOption('pro', mode: InputOption::VALUE_NONE, description: 'Launch PHPStan Pro'),
122126
new InputOption('fail-without-result-cache', mode: InputOption::VALUE_NONE, description: 'Return non-zero exit code when result cache is not used'),
127+
new InputOption('only-remove-errors', mode: InputOption::VALUE_NONE, description: 'Only remove existing errors from the baseline. Do not add new ones.'),
123128
]);
124129
}
125130

@@ -158,6 +163,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int
158163
$pro = (bool) $input->getOption('watch') || (bool) $input->getOption('pro');
159164
$fix = (bool) $input->getOption('fix');
160165
$failWithoutResultCache = (bool) $input->getOption('fail-without-result-cache');
166+
$onlyRemoveErrors = (bool) $input->getOption('only-remove-errors');
161167

162168
/** @var string|false|null $generateBaselineFile */
163169
$generateBaselineFile = $input->getOption('generate-baseline');
@@ -236,6 +242,11 @@ protected function execute(InputInterface $input, OutputInterface $output): int
236242
$inceptionResult->getErrorOutput()->getStyle()->note('The --fix CLI option no longer launches PHPStan Pro. Use --pro instead if you want to launch PHPStan Pro');
237243
}
238244

245+
if ($generateBaselineFile === null && $onlyRemoveErrors) {
246+
$inceptionResult->getStdOutput()->getStyle()->error('You must pass the --generate-baseline option alongside --only-remove-errors.');
247+
return $inceptionResult->handleReturn(1, null, $this->analysisStartTime);
248+
}
249+
239250
$errorOutput = $inceptionResult->getErrorOutput();
240251
$errorFormat = $input->getOption('error-format');
241252

@@ -508,7 +519,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int
508519
return $inceptionResult->handleReturn(1, $analysisResult->getPeakMemoryUsageBytes(), $this->analysisStartTime, $analysisResult->getWorkerCount());
509520
}
510521

511-
return $this->generateBaseline($generateBaselineFile, $inceptionResult, $analysisResult, $output, $allowEmptyBaseline, $baselineExtension, $failWithoutResultCache);
522+
return $this->generateBaseline($generateBaselineFile, $inceptionResult, $analysisResult, $output, $allowEmptyBaseline, $baselineExtension, $failWithoutResultCache, $onlyRemoveErrors, $container);
512523
}
513524

514525
/** @var ErrorFormatter $errorFormatter */
@@ -757,8 +768,14 @@ private function getMessageFromInternalError(FileHelper $fileHelper, InternalErr
757768
return $message;
758769
}
759770

760-
private function generateBaseline(string $generateBaselineFile, InceptionResult $inceptionResult, AnalysisResult $analysisResult, OutputInterface $output, bool $allowEmptyBaseline, string $baselineExtension, bool $failWithoutResultCache): int
771+
private function generateBaseline(string $generateBaselineFile, InceptionResult $inceptionResult, AnalysisResult $analysisResult, OutputInterface $output, bool $allowEmptyBaseline, string $baselineExtension, bool $failWithoutResultCache, bool $onlyRemoveErrors, Container $container): int
761772
{
773+
$baselineFileDirectory = dirname($generateBaselineFile);
774+
$baselinePathHelper = new ParentDirectoryRelativePathHelper($baselineFileDirectory);
775+
if ($onlyRemoveErrors) {
776+
$analysisResult = $this->filterAnalysisResultForExistingErrors($analysisResult, $generateBaselineFile, $inceptionResult, $container, $baselinePathHelper);
777+
}
778+
762779
if (!$allowEmptyBaseline && !$analysisResult->hasErrors()) {
763780
$inceptionResult->getStdOutput()->getStyle()->error('No errors were found during the analysis. Baseline could not be generated.');
764781
$inceptionResult->getStdOutput()->writeLineFormatted('To allow generating empty baselines, pass <fg=cyan>--allow-empty-baseline</> option.');
@@ -769,8 +786,6 @@ private function generateBaseline(string $generateBaselineFile, InceptionResult
769786
$streamOutput = $this->createStreamOutput();
770787
$errorConsoleStyle = new ErrorsConsoleStyle(new StringInput(''), $streamOutput);
771788
$baselineOutput = new SymfonyOutput($streamOutput, new SymfonyStyle($errorConsoleStyle));
772-
$baselineFileDirectory = dirname($generateBaselineFile);
773-
$baselinePathHelper = new ParentDirectoryRelativePathHelper($baselineFileDirectory);
774789
$rawMessageInBaseline = $inceptionResult->getContainer()->getParameter('featureToggles')['rawMessageInBaseline'];
775790

776791
if ($baselineExtension === 'php') {
@@ -842,6 +857,20 @@ private function generateBaseline(string $generateBaselineFile, InceptionResult
842857
return $inceptionResult->handleReturn($exitCode, $analysisResult->getPeakMemoryUsageBytes(), $this->analysisStartTime, $analysisResult->getWorkerCount());
843858
}
844859

860+
private function filterAnalysisResultForExistingErrors(AnalysisResult $analysisResult, string $generateBaselineFile, InceptionResult $inceptionResult, Container $container, ParentDirectoryRelativePathHelper $baselinePathHelper): AnalysisResult
861+
{
862+
$currentAnalysisErrors = $analysisResult->getFileSpecificErrors();
863+
864+
$currentBaselinedErrors = $this->getCurrentBaselinedErrors($generateBaselineFile, $inceptionResult);
865+
866+
/** @var BaselineIgnoredErrorHelper $baselineIgnoredErrorsHelper */
867+
$baselineIgnoredErrorsHelper = $container->getByType(BaselineIgnoredErrorHelper::class);
868+
869+
$nextBaselinedErrors = $baselineIgnoredErrorsHelper->removeUnusedIgnoredErrors($currentBaselinedErrors, $currentAnalysisErrors, $baselinePathHelper);
870+
871+
return $analysisResult->withFileSpecificErrors($nextBaselinedErrors);
872+
}
873+
845874
/**
846875
* @param string[] $files
847876
*/
@@ -897,4 +926,23 @@ private function runDiagnoseExtensions(Container $container, Output $errorOutput
897926
}
898927
}
899928

929+
/**
930+
* @return mixed[][]
931+
*/
932+
private function getCurrentBaselinedErrors(string $generateBaselineFile, InceptionResult $inceptionResult): array
933+
{
934+
$loader = new Loader();
935+
try {
936+
$currentBaselineConfig = $loader->load($generateBaselineFile);
937+
$baselinedErrors = $currentBaselineConfig['parameters']['ignoreErrors'] ?? [];
938+
} catch (FileNotFoundException) {
939+
// currently no baseline file -> empty config
940+
$baselinedErrors = [];
941+
} catch (InvalidStateException $invalidStateException) {
942+
$inceptionResult->getErrorOutput()->writeLineFormatted($invalidStateException->getMessage());
943+
throw $invalidStateException;
944+
}
945+
return $baselinedErrors;
946+
}
947+
900948
}
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\Analyser\Ignore;
4+
5+
use PHPStan\Analyser\Error;
6+
use PHPStan\File\ParentDirectoryRelativePathHelper;
7+
use PHPStan\Testing\PHPStanTestCase;
8+
9+
class BaselineIgnoredErrorsHelperTest extends PHPStanTestCase
10+
{
11+
12+
public function testEmptyBaseline(): void
13+
{
14+
$result = $this->runRemoveUnusedIgnoredErrors(
15+
[],
16+
[
17+
new Error(
18+
'Foo',
19+
__DIR__ . '/foo.php',
20+
),
21+
],
22+
);
23+
24+
$this->assertCount(0, $result);
25+
}
26+
27+
public function testRemoveUnusedIgnoreError(): void
28+
{
29+
$result = $this->runRemoveUnusedIgnoredErrors(
30+
[
31+
[
32+
'message' => '#^Foo#',
33+
'count' => 1,
34+
'path' => 'foo.php',
35+
],
36+
],
37+
[],
38+
);
39+
40+
$this->assertCount(0, $result);
41+
}
42+
43+
public function testeReduceErrorCount(): void
44+
{
45+
$result = $this->runRemoveUnusedIgnoredErrors(
46+
[
47+
[
48+
'message' => '#^Foo#',
49+
'count' => 2,
50+
'path' => 'foo.php',
51+
],
52+
],
53+
[
54+
new Error(
55+
'Foo',
56+
__DIR__ . '/foo.php',
57+
),
58+
],
59+
);
60+
61+
$this->assertCount(1, $result);
62+
$this->assertSame('Foo', $result[0]->getMessage());
63+
$this->assertSame(__DIR__ . '/foo.php', $result[0]->getFilePath());
64+
}
65+
66+
public function testNewError(): void
67+
{
68+
$result = $this->runRemoveUnusedIgnoredErrors(
69+
[
70+
[
71+
'message' => '#^Foo#',
72+
'count' => 1,
73+
'path' => 'foo.php',
74+
],
75+
],
76+
[
77+
new Error(
78+
'Bar',
79+
__DIR__ . '/bar.php',
80+
),
81+
],
82+
);
83+
84+
$this->assertCount(0, $result);
85+
}
86+
87+
public function testIncreaseErrorCount(): void
88+
{
89+
$result = $this->runRemoveUnusedIgnoredErrors(
90+
[
91+
[
92+
'message' => '#^Foo#',
93+
'count' => 1,
94+
'path' => 'foo.php',
95+
],
96+
],
97+
[
98+
new Error(
99+
'Foo',
100+
__DIR__ . '/foo.php',
101+
),
102+
new Error(
103+
'Foo',
104+
__DIR__ . '/foo.php',
105+
),
106+
],
107+
);
108+
109+
$this->assertCount(1, $result);
110+
$this->assertSame('Foo', $result[0]->getMessage());
111+
$this->assertSame(__DIR__ . '/foo.php', $result[0]->getFilePath());
112+
}
113+
114+
public function testRawMessageWithIdentifier(): void
115+
{
116+
$result = $this->runRemoveUnusedIgnoredErrors(
117+
[
118+
[
119+
'rawMessage' => 'Foo',
120+
'identifier' => 'foo.bar',
121+
'count' => 1,
122+
'path' => 'foo.php',
123+
],
124+
],
125+
[
126+
new Error('Foo', __DIR__ . '/foo.php', identifier: 'foo.bar'),
127+
new Error('Foo', __DIR__ . '/foo.php', identifier: 'foo.baz'),
128+
new Error('Foo bar', __DIR__ . '/foo.php', identifier: 'foo.bar'),
129+
],
130+
);
131+
132+
$this->assertCount(1, $result);
133+
$this->assertSame('Foo', $result[0]->getMessage());
134+
$this->assertSame('foo.bar', $result[0]->getIdentifier());
135+
}
136+
137+
/**
138+
* @param mixed[][] $baselinedErrors
139+
* @param list<Error> $currentAnalysisErrors
140+
* @return list<Error> errors
141+
*/
142+
private function runRemoveUnusedIgnoredErrors(array $baselinedErrors, array $currentAnalysisErrors): array
143+
{
144+
$baselineIgnoredErrorHelper = new BaselineIgnoredErrorHelper(self::getFileHelper());
145+
146+
$parentDirHelper = new ParentDirectoryRelativePathHelper(__DIR__);
147+
148+
return $baselineIgnoredErrorHelper->removeUnusedIgnoredErrors($baselinedErrors, $currentAnalysisErrors, $parentDirHelper);
149+
}
150+
151+
}

0 commit comments

Comments
 (0)