diff --git a/conf/config.neon b/conf/config.neon index e764d064eb..84f0f0f18e 100644 --- a/conf/config.neon +++ b/conf/config.neon @@ -470,6 +470,9 @@ services: ignoreErrors: %ignoreErrors% reportUnmatchedIgnoredErrors: %reportUnmatchedIgnoredErrors% + - + class: PHPStan\Analyser\Ignore\BaselineIgnoredErrorHelper + - class: PHPStan\Analyser\Ignore\IgnoreLexer diff --git a/src/Analyser/Ignore/BaselineIgnoredErrorHelper.php b/src/Analyser/Ignore/BaselineIgnoredErrorHelper.php new file mode 100644 index 0000000000..cc3e721228 --- /dev/null +++ b/src/Analyser/Ignore/BaselineIgnoredErrorHelper.php @@ -0,0 +1,104 @@ + $currentAnalysisErrors errors from the current analysis + * @return list errors from the current analysis which already exit in the baseline + */ + public function removeUnusedIgnoredErrors(array $baselinedErrors, array $currentAnalysisErrors, ParentDirectoryRelativePathHelper $baselinePathHelper): array + { + $ignoreErrorsByFile = $this->mapIgnoredErrorsByFile($baselinedErrors); + + $ignoreUseCount = []; + $nextBaselinedErrors = []; + foreach ($currentAnalysisErrors as $error) { + $hasMatchingIgnore = $this->checkIgnoreErrorByPath($error->getFilePath(), $ignoreErrorsByFile, $error, $ignoreUseCount, $baselinePathHelper); + if ($hasMatchingIgnore) { + $nextBaselinedErrors[] = $error; + continue; + } + + $traitFilePath = $error->getTraitFilePath(); + if ($traitFilePath === null) { + continue; + } + + $hasMatchingIgnore = $this->checkIgnoreErrorByPath($traitFilePath, $ignoreErrorsByFile, $error, $ignoreUseCount, $baselinePathHelper); + if (!$hasMatchingIgnore) { + continue; + } + + $nextBaselinedErrors[] = $error; + } + + return $nextBaselinedErrors; + } + + /** + * @param mixed[][] $ignoreErrorsByFile + * @param int[] $ignoreUseCount map of indexes of ignores and how often they have been "used" to ignore an error + */ + private function checkIgnoreErrorByPath(string $filePath, array $ignoreErrorsByFile, Error $error, array &$ignoreUseCount, RelativePathHelper $baselinePathHelper): bool + { + $relativePath = $baselinePathHelper->getRelativePath($filePath); + if (!isset($ignoreErrorsByFile[$relativePath])) { + return false; + } + + foreach ($ignoreErrorsByFile[$relativePath] as $ignoreError) { + $ignore = $ignoreError['ignoreError']; + $shouldIgnore = IgnoredError::shouldIgnore($this->fileHelper, $error, $ignore['message'] ?? null, $ignore['identifier'] ?? null, null); + if (!$shouldIgnore) { + continue; + } + + $realCount = $ignoreUseCount[$ignoreError['index']] ?? 0; + $realCount++; + $ignoreUseCount[$ignoreError['index']] = $realCount; + + if ($realCount <= $ignore['count']) { + return true; + } + } + + return false; + } + + /** + * @param mixed[][] $baselineIgnoreErrors + * @return mixed[][] ignored errors from baseline mapped and grouped by files + */ + private function mapIgnoredErrorsByFile(array $baselineIgnoreErrors): array + { + $ignoreErrorsByFile = []; + + foreach ($baselineIgnoreErrors as $i => $ignoreError) { + $ignoreErrorEntry = [ + 'index' => $i, + 'ignoreError' => $ignoreError, + ]; + + $normalizedPath = $this->fileHelper->normalizePath($ignoreError['path']); + $ignoreErrorsByFile[$normalizedPath][] = $ignoreErrorEntry; + } + + return $ignoreErrorsByFile; + } + +} diff --git a/src/Command/AnalyseCommand.php b/src/Command/AnalyseCommand.php index a81115e108..affd28d382 100644 --- a/src/Command/AnalyseCommand.php +++ b/src/Command/AnalyseCommand.php @@ -2,7 +2,11 @@ namespace PHPStan\Command; +use Nette\DI\Config\Loader; +use Nette\FileNotFoundException; +use Nette\InvalidStateException; use OndraM\CiDetector\CiDetector; +use PHPStan\Analyser\Ignore\BaselineIgnoredErrorHelper; use PHPStan\Analyser\InternalError; use PHPStan\Command\ErrorFormatter\BaselineNeonErrorFormatter; use PHPStan\Command\ErrorFormatter\BaselinePhpErrorFormatter; @@ -102,6 +106,7 @@ protected function configure(): void new InputOption('watch', null, InputOption::VALUE_NONE, 'Launch PHPStan Pro'), new InputOption('pro', null, InputOption::VALUE_NONE, 'Launch PHPStan Pro'), new InputOption('fail-without-result-cache', null, InputOption::VALUE_NONE, 'Return non-zero exit code when result cache is not used'), + new InputOption('only-remove-errors', null, InputOption::VALUE_NONE, 'Only remove existing errors from the baseline. Do not add new ones.'), ]); } @@ -136,6 +141,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $debugEnabled = (bool) $input->getOption('debug'); $fix = (bool) $input->getOption('fix') || (bool) $input->getOption('watch') || (bool) $input->getOption('pro'); $failWithoutResultCache = (bool) $input->getOption('fail-without-result-cache'); + $onlyRemoveErrors = (bool) $input->getOption('only-remove-errors'); /** @var string|false|null $generateBaselineFile */ $generateBaselineFile = $input->getOption('generate-baseline'); @@ -182,6 +188,11 @@ protected function execute(InputInterface $input, OutputInterface $output): int return $inceptionResult->handleReturn(1, null, $this->analysisStartTime); } + if ($generateBaselineFile === null && $onlyRemoveErrors) { + $inceptionResult->getStdOutput()->getStyle()->error('You must pass the --generate-baseline option alongside --only-remove-errors.'); + return $inceptionResult->handleReturn(1, null, $this->analysisStartTime); + } + $errorOutput = $inceptionResult->getErrorOutput(); $errorFormat = $input->getOption('error-format'); @@ -411,7 +422,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int return $inceptionResult->handleReturn(1, $analysisResult->getPeakMemoryUsageBytes(), $this->analysisStartTime); } - return $this->generateBaseline($generateBaselineFile, $inceptionResult, $analysisResult, $output, $allowEmptyBaseline, $baselineExtension, $failWithoutResultCache); + return $this->generateBaseline($generateBaselineFile, $inceptionResult, $analysisResult, $output, $allowEmptyBaseline, $baselineExtension, $failWithoutResultCache, $onlyRemoveErrors, $container); } /** @var ErrorFormatter $errorFormatter */ @@ -587,8 +598,14 @@ private function getMessageFromInternalError(FileHelper $fileHelper, InternalErr return $message; } - private function generateBaseline(string $generateBaselineFile, InceptionResult $inceptionResult, AnalysisResult $analysisResult, OutputInterface $output, bool $allowEmptyBaseline, string $baselineExtension, bool $failWithoutResultCache): int + private function generateBaseline(string $generateBaselineFile, InceptionResult $inceptionResult, AnalysisResult $analysisResult, OutputInterface $output, bool $allowEmptyBaseline, string $baselineExtension, bool $failWithoutResultCache, bool $onlyRemoveErrors, Container $container): int { + $baselineFileDirectory = dirname($generateBaselineFile); + $baselinePathHelper = new ParentDirectoryRelativePathHelper($baselineFileDirectory); + if ($onlyRemoveErrors) { + $analysisResult = $this->filterAnalysisResultForExistingErrors($analysisResult, $generateBaselineFile, $inceptionResult, $container, $baselinePathHelper); + } + if (!$allowEmptyBaseline && !$analysisResult->hasErrors()) { $inceptionResult->getStdOutput()->getStyle()->error('No errors were found during the analysis. Baseline could not be generated.'); $inceptionResult->getStdOutput()->writeLineFormatted('To allow generating empty baselines, pass --allow-empty-baseline option.'); @@ -599,8 +616,6 @@ private function generateBaseline(string $generateBaselineFile, InceptionResult $streamOutput = $this->createStreamOutput(); $errorConsoleStyle = new ErrorsConsoleStyle(new StringInput(''), $streamOutput); $baselineOutput = new SymfonyOutput($streamOutput, new SymfonyStyle($errorConsoleStyle)); - $baselineFileDirectory = dirname($generateBaselineFile); - $baselinePathHelper = new ParentDirectoryRelativePathHelper($baselineFileDirectory); if ($baselineExtension === 'php') { $baselineErrorFormatter = new BaselinePhpErrorFormatter($baselinePathHelper); @@ -674,6 +689,32 @@ private function generateBaseline(string $generateBaselineFile, InceptionResult return $inceptionResult->handleReturn($exitCode, $analysisResult->getPeakMemoryUsageBytes(), $this->analysisStartTime); } + private function filterAnalysisResultForExistingErrors(AnalysisResult $analysisResult, string $generateBaselineFile, InceptionResult $inceptionResult, Container $container, ParentDirectoryRelativePathHelper $baselinePathHelper): AnalysisResult + { + $currentAnalysisErrors = $analysisResult->getFileSpecificErrors(); + + $currentBaselinedErrors = $this->getCurrentBaselinedErrors($generateBaselineFile, $inceptionResult); + + /** @var BaselineIgnoredErrorHelper $baselineIgnoredErrorsHelper */ + $baselineIgnoredErrorsHelper = $container->getByType(BaselineIgnoredErrorHelper::class); + + $nextBaselinedErrors = $baselineIgnoredErrorsHelper->removeUnusedIgnoredErrors($currentBaselinedErrors, $currentAnalysisErrors, $baselinePathHelper); + + return new AnalysisResult( + $nextBaselinedErrors, + $analysisResult->getNotFileSpecificErrors(), + $analysisResult->getInternalErrorObjects(), + $analysisResult->getWarnings(), + $analysisResult->getCollectedData(), + $analysisResult->isDefaultLevelUsed(), + $analysisResult->getProjectConfigFile(), + $analysisResult->isResultCacheSaved(), + $analysisResult->getPeakMemoryUsageBytes(), + $analysisResult->isResultCacheUsed(), + $analysisResult->getChangedProjectExtensionFilesOutsideOfAnalysedPaths(), + ); + } + /** * @param string[] $files */ @@ -716,4 +757,23 @@ private function runDiagnoseExtensions(Container $container, Output $errorOutput } } + /** + * @return mixed[][] + */ + private function getCurrentBaselinedErrors(string $generateBaselineFile, InceptionResult $inceptionResult): array + { + $loader = new Loader(); + try { + $currentBaselineConfig = $loader->load($generateBaselineFile); + $baselinedErrors = $currentBaselineConfig['parameters']['ignoreErrors'] ?? []; + } catch (FileNotFoundException) { + // currently no baseline file -> empty config + $baselinedErrors = []; + } catch (InvalidStateException $invalidStateException) { + $inceptionResult->getErrorOutput()->writeLineFormatted($invalidStateException->getMessage()); + throw $invalidStateException; + } + return $baselinedErrors; + } + } diff --git a/tests/PHPStan/Analyser/Ignore/BaselineIgnoredErrorsHelperTest.php b/tests/PHPStan/Analyser/Ignore/BaselineIgnoredErrorsHelperTest.php new file mode 100644 index 0000000000..18ea62e9fe --- /dev/null +++ b/tests/PHPStan/Analyser/Ignore/BaselineIgnoredErrorsHelperTest.php @@ -0,0 +1,128 @@ +runRemoveUnusedIgnoredErrors( + [], + [ + new Error( + 'Foo', + __DIR__ . '/foo.php', + ), + ], + ); + + $this->assertCount(0, $result); + } + + public function testRemoveUnusedIgnoreError(): void + { + $result = $this->runRemoveUnusedIgnoredErrors( + [ + [ + 'message' => '#^Foo#', + 'count' => 1, + 'path' => 'foo.php', + ], + ], + [], + ); + + $this->assertCount(0, $result); + } + + public function testeReduceErrorCount(): void + { + $result = $this->runRemoveUnusedIgnoredErrors( + [ + [ + 'message' => '#^Foo#', + 'count' => 2, + 'path' => 'foo.php', + ], + ], + [ + new Error( + 'Foo', + __DIR__ . '/foo.php', + ), + ], + ); + + $this->assertCount(1, $result); + $this->assertSame('Foo', $result[0]->getMessage()); + $this->assertSame(__DIR__ . '/foo.php', $result[0]->getFilePath()); + } + + public function testNewError(): void + { + $result = $this->runRemoveUnusedIgnoredErrors( + [ + [ + 'message' => '#^Foo#', + 'count' => 1, + 'path' => 'foo.php', + ], + ], + [ + new Error( + 'Bar', + __DIR__ . '/bar.php', + ), + ], + ); + + $this->assertCount(0, $result); + } + + public function testIncreaseErrorCount(): void + { + $result = $this->runRemoveUnusedIgnoredErrors( + [ + [ + 'message' => '#^Foo#', + 'count' => 1, + 'path' => 'foo.php', + ], + ], + [ + new Error( + 'Foo', + __DIR__ . '/foo.php', + ), + new Error( + 'Foo', + __DIR__ . '/foo.php', + ), + ], + ); + + $this->assertCount(1, $result); + $this->assertSame('Foo', $result[0]->getMessage()); + $this->assertSame(__DIR__ . '/foo.php', $result[0]->getFilePath()); + } + + /** + * @param mixed[][] $baselinedErrors + * @param list $currentAnalysisErrors + * @return list errors + */ + private function runRemoveUnusedIgnoredErrors(array $baselinedErrors, array $currentAnalysisErrors): array + { + $baselineIgnoredErrorHelper = new BaselineIgnoredErrorHelper(self::getFileHelper()); + + $parentDirHelper = new ParentDirectoryRelativePathHelper(__DIR__); + + return $baselineIgnoredErrorHelper->removeUnusedIgnoredErrors($baselinedErrors, $currentAnalysisErrors, $parentDirHelper); + } + +}