Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add --only-remove-errors analyse command option #3847

Open
wants to merge 10 commits into
base: 2.1.x
Choose a base branch
from
3 changes: 3 additions & 0 deletions conf/config.neon
Original file line number Diff line number Diff line change
Expand Up @@ -470,6 +470,9 @@ services:
ignoreErrors: %ignoreErrors%
reportUnmatchedIgnoredErrors: %reportUnmatchedIgnoredErrors%

-
class: PHPStan\Analyser\Ignore\BaselineIgnoredErrorHelper

-
class: PHPStan\Analyser\Ignore\IgnoreLexer

Expand Down
104 changes: 104 additions & 0 deletions src/Analyser/Ignore/BaselineIgnoredErrorHelper.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
<?php declare(strict_types = 1);

namespace PHPStan\Analyser\Ignore;

use PHPStan\Analyser\Error;
use PHPStan\File\FileHelper;
use PHPStan\File\ParentDirectoryRelativePathHelper;
use PHPStan\File\RelativePathHelper;

final class BaselineIgnoredErrorHelper
{

public function __construct(
private FileHelper $fileHelper,
)
{
}

/**
* @param mixed[][] $baselinedErrors errors currently present in the baseline
* @param list<Error> $currentAnalysisErrors errors from the current analysis
* @return list<Error> 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;
}

}
68 changes: 64 additions & 4 deletions src/Command/AnalyseCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.'),
]);
}

Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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');

Expand Down Expand Up @@ -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 */
Expand Down Expand Up @@ -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 <fg=cyan>--allow-empty-baseline</> option.');
Expand All @@ -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);
Expand Down Expand Up @@ -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
*/
Expand Down Expand Up @@ -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;
}

}
128 changes: 128 additions & 0 deletions tests/PHPStan/Analyser/Ignore/BaselineIgnoredErrorsHelperTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
<?php declare(strict_types = 1);

namespace PHPStan\Analyser\Ignore;

use PHPStan\Analyser\Error;
use PHPStan\File\ParentDirectoryRelativePathHelper;
use PHPStan\Testing\PHPStanTestCase;

class BaselineIgnoredErrorsHelperTest extends PHPStanTestCase
{

public function testEmptyBaseline(): void
{
$result = $this->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<Error> $currentAnalysisErrors
* @return list<Error> errors
*/
private function runRemoveUnusedIgnoredErrors(array $baselinedErrors, array $currentAnalysisErrors): array
{
$baselineIgnoredErrorHelper = new BaselineIgnoredErrorHelper(self::getFileHelper());

$parentDirHelper = new ParentDirectoryRelativePathHelper(__DIR__);

return $baselineIgnoredErrorHelper->removeUnusedIgnoredErrors($baselinedErrors, $currentAnalysisErrors, $parentDirHelper);
}

}
Loading