diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 328ca21..cd7e5d4 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -60,7 +60,7 @@ jobs: uses: actions/checkout@v6 - name: "Check for typos" - uses: "crate-ci/typos@v1.40.0" + uses: "crate-ci/typos@v1.42.1" unit_tests: name: 'Unit Test ${{ matrix.php-version }}, ${{ matrix.dependency-version }}' diff --git a/composer.json b/composer.json index e0c7070..fc013c2 100644 --- a/composer.json +++ b/composer.json @@ -16,7 +16,7 @@ "phpstan/phpstan": "^1.0 || ^2.0" }, "require-dev": { - "friendsofphp/php-cs-fixer": "^3.92.3", + "friendsofphp/php-cs-fixer": "^3.92.5", "phpstan/phpstan-phpunit": "^2.0.11", "phpunit/phpunit": "^10.0 || ^11.0" }, diff --git a/playground/baseline.neon b/playground/baseline.neon new file mode 100644 index 0000000..c24ffd6 --- /dev/null +++ b/playground/baseline.neon @@ -0,0 +1,14 @@ +# Baseline file for demonstrating ignore.unmatched errors +# These errors no longer exist in the codebase, so they will trigger "ignore.unmatched" + +parameters: + ignoreErrors: + # This error was "fixed" so it will show as unmatched + - + message: '#This error no longer exists in the codebase#' + path: type_errors.php + + # Another unmatched error + - + message: '#Old error that was removed#' + path: dead_code.php diff --git a/playground/phpstan.neon b/playground/phpstan.neon index 36aa70d..bb801f3 100644 --- a/playground/phpstan.neon +++ b/playground/phpstan.neon @@ -3,6 +3,7 @@ includes: - ../extension.neon + - baseline.neon parameters: level: 9 diff --git a/src/ErrorFormat/ErrorWriter.php b/src/ErrorFormat/ErrorWriter.php index f3c702b..a6ded97 100644 --- a/src/ErrorFormat/ErrorWriter.php +++ b/src/ErrorFormat/ErrorWriter.php @@ -75,7 +75,7 @@ public function writeFileSpecificErrors(AnalysisResult $analysisResult, Output $ } if (null !== $errorIdentifier) { - $output->writeLineFormatted(" 🪪 {$errorIdentifier}"); + $output->writeLineFormatted(" 🏷️ {$errorIdentifier}"); } if (\is_string($this->config->editorUrl)) { diff --git a/src/ErrorFormat/SummaryWriter.php b/src/ErrorFormat/SummaryWriter.php index 4ce5c47..bd787e6 100644 --- a/src/ErrorFormat/SummaryWriter.php +++ b/src/ErrorFormat/SummaryWriter.php @@ -8,27 +8,105 @@ class SummaryWriter { private const IDENTIFIER_NO_IDENTIFIER = ''; + private const IDENTIFIER_IGNORE_UNMATCHED = 'ignore.unmatched'; public function writeGroupedErrorsSummary(AnalysisResult $analysisResult, Output $output): void { /** @var array $errorCounter */ $errorCounter = []; + $nonIgnorableCounter = 0; + + /** @var array> $files files per identifier */ + $files = []; + + /** @var array $uniqueFiles */ + $uniqueFiles = []; foreach ($analysisResult->getFileSpecificErrors() as $error) { $identifier = $error->getIdentifier() ?? self::IDENTIFIER_NO_IDENTIFIER; - if (!\array_key_exists($identifier, $errorCounter)) { - $errorCounter[$identifier] = 0; - } + $file = $error->getTraitFilePath() ?? $error->getFilePath(); + + $errorCounter[$identifier] ??= 0; ++$errorCounter[$identifier]; + + $files[$identifier][$file] = true; + + $uniqueFiles[$file] = true; + + // Count non-ignorable errors excluding ignore.unmatched + if (!$error->canBeIgnored() && self::IDENTIFIER_IGNORE_UNMATCHED !== $identifier) { + ++$nonIgnorableCounter; + } } arsort($errorCounter); - $output->writeLineFormatted('📊 Error Identifier Summary:'); + $output->writeLineFormatted('📈 Error Identifier Summary:'); $output->writeLineFormatted('────────────────────────────'); foreach ($errorCounter as $identifier => $count) { - $output->writeLineFormatted(\sprintf(' %d %s', $count, $identifier)); + $fileCount = \count($files[$identifier]); + $suffix = $this->getFileSuffix($fileCount); + $note = self::IDENTIFIER_IGNORE_UNMATCHED === $identifier + ? ', can be removed after baseline update' + : ''; + + $output->writeLineFormatted(\sprintf( + ' %d %s (in %d %s%s)', + $count, + $identifier, + $fileCount, + $suffix, + $note + )); } + + $totalFileCount = \count($uniqueFiles); + $suffix = $this->getFileSuffix($totalFileCount); + $output->writeLineFormatted(''); + $output->writeLineFormatted('📊 Summary:'); + $output->writeLineFormatted('───────────'); + + $totalErrors = $analysisResult->getTotalErrorsCount(); + $output->writeLineFormatted(\sprintf('❌ Found %d errors', $totalErrors)); + + $unmatchedCount = $errorCounter[self::IDENTIFIER_IGNORE_UNMATCHED] ?? 0; + + $treeItems = []; + + $noIdentifierCount = $errorCounter[self::IDENTIFIER_NO_IDENTIFIER] ?? 0; + + if ($unmatchedCount > 0) { + $toFixCount = $totalErrors - $unmatchedCount; + $treeItems[] = \sprintf('%d %s to fix', $toFixCount, $this->getErrorSuffix($toFixCount)); + $treeItems[] = \sprintf('%d %s can be removed after updating the baseline', $unmatchedCount, $this->getErrorSuffix($unmatchedCount)); + } + + if ($noIdentifierCount > 0) { + $treeItems[] = \sprintf('%d %s have no identifier, consider upgrading to PHPStan v2', $noIdentifierCount, $this->getErrorSuffix($noIdentifierCount)); + } + + if ($nonIgnorableCounter > 0) { + $treeItems[] = \sprintf('%d %s cannot be ignored by baseline', $nonIgnorableCounter, $this->getErrorSuffix($nonIgnorableCounter)); + } + + $lastIndex = \count($treeItems) - 1; + foreach ($treeItems as $index => $item) { + $prefix = $index === $lastIndex ? '└─' : '├─'; + $output->writeLineFormatted(\sprintf(' %s %s', $prefix, $item)); + } + + $output->writeLineFormatted(\sprintf('🏷️ In %d error identifiers', \count($errorCounter))); + $output->writeLineFormatted(\sprintf('📂 Across %d %s', $totalFileCount, $suffix)); + } + + private function getFileSuffix(int $count): string + { + return 1 === $count ? 'file' : 'files'; + } + + private function getErrorSuffix(int $count): string + { + return 1 === $count ? 'error' : 'errors'; } } diff --git a/tests/FriendlyErrorFormatterTest.php b/tests/FriendlyErrorFormatterTest.php index 4a90e28..d3a1922 100644 --- a/tests/FriendlyErrorFormatterTest.php +++ b/tests/FriendlyErrorFormatterTest.php @@ -73,6 +73,12 @@ public static function provideFormatErrorsCases(): iterable 14| } 15| 16| }', + '📈 Error Identifier Summary:', + ' (in 1 file)', + '📊 Summary:', + '❌ Found 1 errors', + '🏷️ In 1 error identifiers', + '📂 Across 1 file', '[ERROR] Found 1 error', ], ]; @@ -192,6 +198,30 @@ public static function provideFormatErrorsCases(): iterable ]; } + public function testSummaryShowsSpecialIdentifierNotes(): void + { + $relativePathHelper = new FuzzyRelativePathHelper(new NullRelativePathHelper(), '', [], '/'); + $simpleRelativePathHelper = new SimpleRelativePathHelper((string) getcwd()); + $formatter = new FriendlyErrorFormatter($relativePathHelper, $simpleRelativePathHelper, 3, 3, null); + + $fileErrors = [ + new Error('Ignore unmatched', __DIR__.'/data/AnalysisTargetFoo.php', 13, true, null, null, null, null, null, 'ignore.unmatched'), + new Error('No identifier', __DIR__.'/data/AnalysisTargetFoo.php', 15), + new Error('Non ignorable', __DIR__.'/data/AnalysisTargetBar.php', 9, false, null, null, null, null, null, 'missingType'), + ]; + + $analysisResult = $this->createAnalysisResult($fileErrors, [], []); + + $exitCode = $formatter->formatErrors($analysisResult, $this->getOutput(false)); + $outputContent = StringUtil::rtrimByLines($this->getOutputContent(false)); + + self::assertSame(1, $exitCode); + self::assertStringContainsString('ignore.unmatched (in 1 file, can be removed after baseline update)', $outputContent); + self::assertStringContainsString(' (in 1 file)', $outputContent); + self::assertStringContainsString('missingType (in 1 file)', $outputContent); + self::assertStringContainsString('1 error cannot be ignored by baseline', $outputContent); + } + /** * @throws ShouldNotHappenException */ @@ -214,6 +244,16 @@ private function getDummyAnalysisResult(int $numFileErrors, int $numGenericError 'first warning', 'second warning', ], 0, $numWarnings); + return $this->createAnalysisResult($fileErrors, $genericErrors, $warnings); + } + + /** + * @param list $fileErrors + * @param list $genericErrors + * @param list $warnings + */ + private function createAnalysisResult(array $fileErrors, array $genericErrors, array $warnings): AnalysisResult + { $reflectionMethod = new \ReflectionMethod(AnalysisResult::class, '__construct'); $numOfParams = $reflectionMethod->getNumberOfParameters();