From 2032437cd4c71950775e4a11dffaf0e6d04c413d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matuz=20M=C3=A1rk?= Date: Thu, 8 Jan 2026 09:00:25 +0100 Subject: [PATCH 1/9] feat: improve error summary formatting and add more details --- src/ErrorFormat/SummaryWriter.php | 61 +++++++++++++++++++++++++++++-- 1 file changed, 57 insertions(+), 4 deletions(-) diff --git a/src/ErrorFormat/SummaryWriter.php b/src/ErrorFormat/SummaryWriter.php index 4ce5c47..3c2838b 100644 --- a/src/ErrorFormat/SummaryWriter.php +++ b/src/ErrorFormat/SummaryWriter.php @@ -13,13 +13,28 @@ public function writeGroupedErrorsSummary(AnalysisResult $analysisResult, Output { /** @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->getFile(); + + $errorCounter[$identifier] ??= 0; ++$errorCounter[$identifier]; + + $files[$identifier][$file] = true; + + $uniqueFiles[$file] = true; + + if (!$error->canBeIgnored()) { + ++$nonignorableCounter; + } } arsort($errorCounter); @@ -28,7 +43,45 @@ public function writeGroupedErrorsSummary(AnalysisResult $analysisResult, Output $output->writeLineFormatted('────────────────────────────'); foreach ($errorCounter as $identifier => $count) { - $output->writeLineFormatted(\sprintf(' %d %s', $count, $identifier)); + $fileCount = \count($files[$identifier]); + $suffix = 1 === $fileCount ? 'file' : 'files'; + $color = 'ignore.unmatched' === $identifier ? 'green' : 'red'; + $output->writeLineFormatted(\sprintf( + " %d %s (in %d %s)", + $count, + $identifier, + $fileCount, + $suffix + )); + } + + $totalFileCount = \count($uniqueFiles); + $suffix = 1 === $totalFileCount ? 'file' : 'files'; + + $output->writeLineFormatted(''); + $output->writeLineFormatted('📊 Summary:'); + $output->writeLineFormatted('───────────'); + + $output->writeLineFormatted("❌ Found {$analysisResult->getTotalErrorsCount()} errors"); + $output->writeLineFormatted('🏷️ In '.\count($errorCounter).' error categories'); + $output->writeLineFormatted("📂 Across {$totalFileCount} {$suffix}"); + + if (isset($errorCounter['ignore.unmatched']) || isset($errorCounter[self::IDENTIFIER_NO_IDENTIFIER]) || 0 !== $nonignorableCounter) { + $output->writeLineFormatted(''); + $output->writeLineFormatted('ℹ️ Note:'); + $output->writeLineFormatted('────────'); + } + + if (isset($errorCounter['ignore.unmatched'])) { + $output->writeLineFormatted("🎉 {$errorCounter['ignore.unmatched']} errors can be removed after updating the baseline."); + } + + if (isset($errorCounter[self::IDENTIFIER_NO_IDENTIFIER])) { + $output->writeLineFormatted("⚠️ {$errorCounter[self::IDENTIFIER_NO_IDENTIFIER]} errors have no identifier. Consider upgrading to PHPStan v2, which requires identifiers."); + } + + if (0 !== $nonignorableCounter) { + $output->writeLineFormatted("🚨 {$nonignorableCounter} errors cannot be ignored by baseline!"); } } } From ec0a412d12af4df4dfa71b570c98c79e3870d47b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matuz=20M=C3=A1rk?= Date: Thu, 8 Jan 2026 16:53:19 +0100 Subject: [PATCH 2/9] refact: modify changes suggested by Gemini - change to sprintf formatted strings - use class constant - use helper class for pluralization logic --- src/ErrorFormat/SummaryWriter.php | 30 ++++++++++++++++++------------ 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/src/ErrorFormat/SummaryWriter.php b/src/ErrorFormat/SummaryWriter.php index 3c2838b..3eedc6e 100644 --- a/src/ErrorFormat/SummaryWriter.php +++ b/src/ErrorFormat/SummaryWriter.php @@ -8,6 +8,7 @@ class SummaryWriter { private const IDENTIFIER_NO_IDENTIFIER = ''; + private const IDENTIFIER_IGNORE_UNMATCHED = 'ignore.unmatched'; public function writeGroupedErrorsSummary(AnalysisResult $analysisResult, Output $output): void { @@ -44,8 +45,9 @@ public function writeGroupedErrorsSummary(AnalysisResult $analysisResult, Output foreach ($errorCounter as $identifier => $count) { $fileCount = \count($files[$identifier]); - $suffix = 1 === $fileCount ? 'file' : 'files'; - $color = 'ignore.unmatched' === $identifier ? 'green' : 'red'; + $suffix = $this->getFileSuffix($fileCount); + $color = self::IDENTIFIER_IGNORE_UNMATCHED === $identifier ? 'green' : 'red'; + $output->writeLineFormatted(\sprintf( " %d %s (in %d %s)", $count, @@ -56,32 +58,36 @@ public function writeGroupedErrorsSummary(AnalysisResult $analysisResult, Output } $totalFileCount = \count($uniqueFiles); - $suffix = 1 === $totalFileCount ? 'file' : 'files'; - + $suffix = $this->getFileSuffix($totalFileCount); $output->writeLineFormatted(''); $output->writeLineFormatted('📊 Summary:'); $output->writeLineFormatted('───────────'); - $output->writeLineFormatted("❌ Found {$analysisResult->getTotalErrorsCount()} errors"); - $output->writeLineFormatted('🏷️ In '.\count($errorCounter).' error categories'); - $output->writeLineFormatted("📂 Across {$totalFileCount} {$suffix}"); + $output->writeLineFormatted(\sprintf('❌ Found %d errors', $analysisResult->getTotalErrorsCount())); + $output->writeLineFormatted(\sprintf('🏷️ In %d error categories', \count($errorCounter))); + $output->writeLineFormatted(\sprintf('📂 Across %d %s', $totalFileCount, $suffix)); - if (isset($errorCounter['ignore.unmatched']) || isset($errorCounter[self::IDENTIFIER_NO_IDENTIFIER]) || 0 !== $nonignorableCounter) { + if (isset($errorCounter[self::IDENTIFIER_IGNORE_UNMATCHED]) || isset($errorCounter[self::IDENTIFIER_NO_IDENTIFIER]) || 0 !== $nonignorableCounter) { $output->writeLineFormatted(''); $output->writeLineFormatted('ℹ️ Note:'); $output->writeLineFormatted('────────'); } - if (isset($errorCounter['ignore.unmatched'])) { - $output->writeLineFormatted("🎉 {$errorCounter['ignore.unmatched']} errors can be removed after updating the baseline."); + if (isset($errorCounter[self::IDENTIFIER_IGNORE_UNMATCHED])) { + $output->writeLineFormatted(\sprintf('🎉 %d errors can be removed after updating the baseline.', $errorCounter[self::IDENTIFIER_IGNORE_UNMATCHED])); } if (isset($errorCounter[self::IDENTIFIER_NO_IDENTIFIER])) { - $output->writeLineFormatted("⚠️ {$errorCounter[self::IDENTIFIER_NO_IDENTIFIER]} errors have no identifier. Consider upgrading to PHPStan v2, which requires identifiers."); + $output->writeLineFormatted(\sprintf('⚠️ %d errors have no identifier. Consider upgrading to PHPStan v2, which requires identifiers.', $errorCounter[self::IDENTIFIER_NO_IDENTIFIER])); } if (0 !== $nonignorableCounter) { - $output->writeLineFormatted("🚨 {$nonignorableCounter} errors cannot be ignored by baseline!"); + $output->writeLineFormatted(\sprintf('🚨 %d errors cannot be ignored by baseline!', $nonignorableCounter)); } } + + private function getFileSuffix(int $count): string + { + return 1 === $count ? 'file' : 'files'; + } } From 1f4b28e867639b5aeaf1f88eaa90f05f29512292 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 19 Jan 2026 15:42:21 +0000 Subject: [PATCH 3/9] chore(deps): update all non-major dependencies --- .github/workflows/tests.yml | 2 +- composer.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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" }, From 397394b44c4999d163ad17379056df3614d14cec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matuz=20M=C3=A1rk?= Date: Thu, 22 Jan 2026 00:03:21 +0100 Subject: [PATCH 4/9] feat add tests to improve test coverage --- src/ErrorFormat/SummaryWriter.php | 28 +++++++---- tests/FriendlyErrorFormatterTest.php | 74 ++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+), 9 deletions(-) diff --git a/src/ErrorFormat/SummaryWriter.php b/src/ErrorFormat/SummaryWriter.php index 3eedc6e..ad77434 100644 --- a/src/ErrorFormat/SummaryWriter.php +++ b/src/ErrorFormat/SummaryWriter.php @@ -46,7 +46,11 @@ public function writeGroupedErrorsSummary(AnalysisResult $analysisResult, Output foreach ($errorCounter as $identifier => $count) { $fileCount = \count($files[$identifier]); $suffix = $this->getFileSuffix($fileCount); - $color = self::IDENTIFIER_IGNORE_UNMATCHED === $identifier ? 'green' : 'red'; + $color = match ($identifier) { + self::IDENTIFIER_IGNORE_UNMATCHED => 'green', + self::IDENTIFIER_NO_IDENTIFIER => 'yellow', + default => 'red', + }; $output->writeLineFormatted(\sprintf( " %d %s (in %d %s)", @@ -67,22 +71,28 @@ public function writeGroupedErrorsSummary(AnalysisResult $analysisResult, Output $output->writeLineFormatted(\sprintf('🏷️ In %d error categories', \count($errorCounter))); $output->writeLineFormatted(\sprintf('📂 Across %d %s', $totalFileCount, $suffix)); - if (isset($errorCounter[self::IDENTIFIER_IGNORE_UNMATCHED]) || isset($errorCounter[self::IDENTIFIER_NO_IDENTIFIER]) || 0 !== $nonignorableCounter) { - $output->writeLineFormatted(''); - $output->writeLineFormatted('ℹ️ Note:'); - $output->writeLineFormatted('────────'); - } + $notes = []; if (isset($errorCounter[self::IDENTIFIER_IGNORE_UNMATCHED])) { - $output->writeLineFormatted(\sprintf('🎉 %d errors can be removed after updating the baseline.', $errorCounter[self::IDENTIFIER_IGNORE_UNMATCHED])); + $notes[] = \sprintf('🎉 %d errors can be removed after updating the baseline.', $errorCounter[self::IDENTIFIER_IGNORE_UNMATCHED]); } if (isset($errorCounter[self::IDENTIFIER_NO_IDENTIFIER])) { - $output->writeLineFormatted(\sprintf('⚠️ %d errors have no identifier. Consider upgrading to PHPStan v2, which requires identifiers.', $errorCounter[self::IDENTIFIER_NO_IDENTIFIER])); + $notes[] = \sprintf('⚠️ %d errors have no identifier. Consider upgrading to PHPStan v2, which requires identifiers.', $errorCounter[self::IDENTIFIER_NO_IDENTIFIER]); } if (0 !== $nonignorableCounter) { - $output->writeLineFormatted(\sprintf('🚨 %d errors cannot be ignored by baseline!', $nonignorableCounter)); + $notes[] = \sprintf('🚨 %d errors cannot be ignored by baseline!', $nonignorableCounter); + } + + if ([] !== $notes) { + $output->writeLineFormatted(''); + $output->writeLineFormatted('ℹ️ Note:'); + $output->writeLineFormatted('────────'); + + foreach ($notes as $note) { + $output->writeLineFormatted($note); + } } } diff --git a/tests/FriendlyErrorFormatterTest.php b/tests/FriendlyErrorFormatterTest.php index 4a90e28..68be4a2 100644 --- a/tests/FriendlyErrorFormatterTest.php +++ b/tests/FriendlyErrorFormatterTest.php @@ -73,6 +73,14 @@ public static function provideFormatErrorsCases(): iterable 14| } 15| 16| }', + '📊 Error Identifier Summary:', + ' (in 1 file)', + '📊 Summary:', + '❌ Found 1 errors', + '🏷️ In 1 error categories', + '📂 Across 1 file', + 'ℹ️ Note:', + '⚠️ 1 errors have no identifier. Consider upgrading to PHPStan v2, which requires identifiers.', '[ERROR] Found 1 error', ], ]; @@ -192,6 +200,33 @@ public static function provideFormatErrorsCases(): iterable ]; } + public function testDecoratedSummaryShowsColorsAndNotes(): 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(true)); + $outputContent = StringUtil::rtrimByLines($this->getOutputContent(true)); + + self::assertSame(1, $exitCode); + self::assertMatchesRegularExpression($this->buildErrorSummaryPattern('green', 'ignore.unmatched'), $outputContent); + self::assertMatchesRegularExpression($this->buildErrorSummaryPattern('yellow', ''), $outputContent); + self::assertMatchesRegularExpression($this->buildErrorSummaryPattern('red', 'missingType'), $outputContent); + self::assertStringContainsString('ℹ️ Note:', $outputContent); + self::assertStringContainsString('🎉', $outputContent); + self::assertStringContainsString('⚠️', $outputContent); + self::assertStringContainsString('🚨', $outputContent); + } + /** * @throws ShouldNotHappenException */ @@ -214,6 +249,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(); @@ -248,4 +293,33 @@ private function getDummyAnalysisResult(int $numFileErrors, int $numGenericError // @phpstan-ignore-next-line return new AnalysisResult($fileErrors, $genericErrors, [], $warnings, [], false, null, true, memory_get_peak_usage(true), false); } + + /** + * Builds a regular expression pattern for matching ANSI-colored error summary lines. + * + * The pattern matches lines that contain a colored count of errors or warnings, + * a colored identifier (such as "errors" or "warnings"), and a colored file count + * in parentheses, using the same ANSI escape sequences as produced by the formatter. + * + * @param string $colorCode One of the keys of the internal ANSI color map (e.g. 'green', 'yellow', 'red'). + * @param string $identifier the text identifier to match (for example "errors" or "warnings") + * @param int $count the expected number of errors or warnings to appear in the summary + * @param int $fileCount the expected number of files to appear in the summary + * + * @return string regular expression pattern for matching the formatted summary line + */ + private function buildErrorSummaryPattern(string $colorCode, string $identifier, int $count = 1, int $fileCount = 1): string + { + $ansiColorCodes = [ + 'green' => '\x1b\[32m', + 'yellow' => '\x1b\[33m', + 'red' => '\x1b\[31m', + ]; + $colorPattern = $ansiColorCodes[$colorCode]; + $identifierColorPattern = '\x1b\[33m'; + $resetPattern = '\x1b\[[0-9;]*m'; + $escapedIdentifier = preg_quote($identifier, '/'); + + return "/{$colorPattern}{$count}{$resetPattern}\\s+{$identifierColorPattern}{$escapedIdentifier}{$resetPattern} \\(in {$colorPattern}{$fileCount}{$resetPattern} files?\\)/"; + } } From 62d16fe642031fbb9f6dd5e7f83eeba5984c7ee8 Mon Sep 17 00:00:00 2001 From: yamadashy Date: Sun, 25 Jan 2026 22:33:26 +0900 Subject: [PATCH 5/9] chore(playground): add baseline to demonstrate Note section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add baseline.neon with unmatched errors to show: - 🎉 ignore.unmatched errors - 🚨 non-ignorable errors --- playground/baseline.neon | 14 ++++++++++++++ playground/phpstan.neon | 1 + 2 files changed, 15 insertions(+) create mode 100644 playground/baseline.neon 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 From 74b573fc3c092636a4b1b857aa9a50e8ac1bfeb3 Mon Sep 17 00:00:00 2001 From: yamadashy Date: Sun, 25 Jan 2026 22:38:53 +0900 Subject: [PATCH 6/9] style: use dimmed color for file count in error summary --- src/ErrorFormat/SummaryWriter.php | 3 ++- tests/FriendlyErrorFormatterTest.php | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/ErrorFormat/SummaryWriter.php b/src/ErrorFormat/SummaryWriter.php index ad77434..b5fbeb1 100644 --- a/src/ErrorFormat/SummaryWriter.php +++ b/src/ErrorFormat/SummaryWriter.php @@ -53,7 +53,8 @@ public function writeGroupedErrorsSummary(AnalysisResult $analysisResult, Output }; $output->writeLineFormatted(\sprintf( - " %d %s (in %d %s)", + " %d %s (in %d %s)", + $color, $count, $identifier, $fileCount, diff --git a/tests/FriendlyErrorFormatterTest.php b/tests/FriendlyErrorFormatterTest.php index 68be4a2..fad2734 100644 --- a/tests/FriendlyErrorFormatterTest.php +++ b/tests/FriendlyErrorFormatterTest.php @@ -317,9 +317,10 @@ private function buildErrorSummaryPattern(string $colorCode, string $identifier, ]; $colorPattern = $ansiColorCodes[$colorCode]; $identifierColorPattern = '\x1b\[33m'; + $grayPattern = '\x1b\[90m'; $resetPattern = '\x1b\[[0-9;]*m'; $escapedIdentifier = preg_quote($identifier, '/'); - return "/{$colorPattern}{$count}{$resetPattern}\\s+{$identifierColorPattern}{$escapedIdentifier}{$resetPattern} \\(in {$colorPattern}{$fileCount}{$resetPattern} files?\\)/"; + return "/{$colorPattern}{$count}{$resetPattern}\\s+{$identifierColorPattern}{$escapedIdentifier}{$resetPattern} {$grayPattern}\\(in {$fileCount} files?\\){$resetPattern}/"; } } From 8d0c0f62dfb346d0d039f75f2b0b843433a415fd Mon Sep 17 00:00:00 2001 From: yamadashy Date: Sun, 25 Jan 2026 23:32:04 +0900 Subject: [PATCH 7/9] feat: improve summary output with error breakdown tree - Add error breakdown tree showing "to fix" vs "can be removed from baseline" - Add "cannot be ignored by baseline" count for non-ignorable errors - Use text-based messaging instead of color-coded meaning - Change "error categories" to "error identifiers" for consistency --- src/ErrorFormat/SummaryWriter.php | 68 ++++++++++++++++------------ tests/FriendlyErrorFormatterTest.php | 51 ++++----------------- 2 files changed, 46 insertions(+), 73 deletions(-) diff --git a/src/ErrorFormat/SummaryWriter.php b/src/ErrorFormat/SummaryWriter.php index b5fbeb1..5bc80c7 100644 --- a/src/ErrorFormat/SummaryWriter.php +++ b/src/ErrorFormat/SummaryWriter.php @@ -14,7 +14,7 @@ public function writeGroupedErrorsSummary(AnalysisResult $analysisResult, Output { /** @var array $errorCounter */ $errorCounter = []; - $nonignorableCounter = 0; + $nonIgnorableCounter = 0; /** @var array> $files files per identifier */ $files = []; @@ -33,32 +33,31 @@ public function writeGroupedErrorsSummary(AnalysisResult $analysisResult, Output $uniqueFiles[$file] = true; - if (!$error->canBeIgnored()) { - ++$nonignorableCounter; + // Count non-ignorable errors excluding ignore.unmatched + if (!$error->canBeIgnored() && $identifier !== self::IDENTIFIER_IGNORE_UNMATCHED) { + ++$nonIgnorableCounter; } } arsort($errorCounter); - $output->writeLineFormatted('📊 Error Identifier Summary:'); + $output->writeLineFormatted('📈 Error Identifier Summary:'); $output->writeLineFormatted('────────────────────────────'); foreach ($errorCounter as $identifier => $count) { $fileCount = \count($files[$identifier]); $suffix = $this->getFileSuffix($fileCount); - $color = match ($identifier) { - self::IDENTIFIER_IGNORE_UNMATCHED => 'green', - self::IDENTIFIER_NO_IDENTIFIER => 'yellow', - default => 'red', - }; + $note = $identifier === self::IDENTIFIER_IGNORE_UNMATCHED + ? ', can be removed after baseline update' + : ''; $output->writeLineFormatted(\sprintf( - " %d %s (in %d %s)", - $color, + ' %d %s (in %d %s%s)', $count, $identifier, $fileCount, - $suffix + $suffix, + $note )); } @@ -68,37 +67,46 @@ public function writeGroupedErrorsSummary(AnalysisResult $analysisResult, Output $output->writeLineFormatted('📊 Summary:'); $output->writeLineFormatted('───────────'); - $output->writeLineFormatted(\sprintf('❌ Found %d errors', $analysisResult->getTotalErrorsCount())); - $output->writeLineFormatted(\sprintf('🏷️ In %d error categories', \count($errorCounter))); - $output->writeLineFormatted(\sprintf('📂 Across %d %s', $totalFileCount, $suffix)); + $totalErrors = $analysisResult->getTotalErrorsCount(); + $output->writeLineFormatted(\sprintf('❌ Found %d errors', $totalErrors)); - $notes = []; + $unmatchedCount = $errorCounter[self::IDENTIFIER_IGNORE_UNMATCHED] ?? 0; - if (isset($errorCounter[self::IDENTIFIER_IGNORE_UNMATCHED])) { - $notes[] = \sprintf('🎉 %d errors can be removed after updating the baseline.', $errorCounter[self::IDENTIFIER_IGNORE_UNMATCHED]); - } + $treeItems = []; + + $noIdentifierCount = $errorCounter[self::IDENTIFIER_NO_IDENTIFIER] ?? 0; - if (isset($errorCounter[self::IDENTIFIER_NO_IDENTIFIER])) { - $notes[] = \sprintf('⚠️ %d errors have no identifier. Consider upgrading to PHPStan v2, which requires identifiers.', $errorCounter[self::IDENTIFIER_NO_IDENTIFIER]); + if ($unmatchedCount > 0) { + $toFixCount = $totalErrors - $unmatchedCount; + $treeItems[] = \sprintf('%d %s to fix', $toFixCount, $this->getErrorSuffix($toFixCount)); + $treeItems[] = \sprintf('%d %s can be removed from baseline', $unmatchedCount, $this->getErrorSuffix($unmatchedCount)); } - if (0 !== $nonignorableCounter) { - $notes[] = \sprintf('🚨 %d errors cannot be ignored by baseline!', $nonignorableCounter); + if ($noIdentifierCount > 0) { + $treeItems[] = \sprintf('%d %s have no identifier', $noIdentifierCount, $this->getErrorSuffix($noIdentifierCount)); } - if ([] !== $notes) { - $output->writeLineFormatted(''); - $output->writeLineFormatted('ℹ️ Note:'); - $output->writeLineFormatted('────────'); + if ($nonIgnorableCounter > 0) { + $treeItems[] = \sprintf('%d %s cannot be ignored by baseline', $nonIgnorableCounter, $this->getErrorSuffix($nonIgnorableCounter)); + } - foreach ($notes as $note) { - $output->writeLineFormatted($note); - } + $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 fad2734..0a556b9 100644 --- a/tests/FriendlyErrorFormatterTest.php +++ b/tests/FriendlyErrorFormatterTest.php @@ -74,13 +74,11 @@ public static function provideFormatErrorsCases(): iterable 15| 16| }', '📊 Error Identifier Summary:', - ' (in 1 file)', + ' (in 1 file, consider upgrading to PHPStan v2)', '📊 Summary:', '❌ Found 1 errors', '🏷️ In 1 error categories', '📂 Across 1 file', - 'ℹ️ Note:', - '⚠️ 1 errors have no identifier. Consider upgrading to PHPStan v2, which requires identifiers.', '[ERROR] Found 1 error', ], ]; @@ -200,7 +198,7 @@ public static function provideFormatErrorsCases(): iterable ]; } - public function testDecoratedSummaryShowsColorsAndNotes(): void + public function testSummaryShowsSpecialIdentifierNotes(): void { $relativePathHelper = new FuzzyRelativePathHelper(new NullRelativePathHelper(), '', [], '/'); $simpleRelativePathHelper = new SimpleRelativePathHelper((string) getcwd()); @@ -214,17 +212,14 @@ public function testDecoratedSummaryShowsColorsAndNotes(): void $analysisResult = $this->createAnalysisResult($fileErrors, [], []); - $exitCode = $formatter->formatErrors($analysisResult, $this->getOutput(true)); - $outputContent = StringUtil::rtrimByLines($this->getOutputContent(true)); + $exitCode = $formatter->formatErrors($analysisResult, $this->getOutput(false)); + $outputContent = StringUtil::rtrimByLines($this->getOutputContent(false)); self::assertSame(1, $exitCode); - self::assertMatchesRegularExpression($this->buildErrorSummaryPattern('green', 'ignore.unmatched'), $outputContent); - self::assertMatchesRegularExpression($this->buildErrorSummaryPattern('yellow', ''), $outputContent); - self::assertMatchesRegularExpression($this->buildErrorSummaryPattern('red', 'missingType'), $outputContent); - self::assertStringContainsString('ℹ️ Note:', $outputContent); - self::assertStringContainsString('🎉', $outputContent); - self::assertStringContainsString('⚠️', $outputContent); - self::assertStringContainsString('🚨', $outputContent); + self::assertStringContainsString('ignore.unmatched (in 1 file, can be removed after baseline update)', $outputContent); + self::assertStringContainsString(' (in 1 file, consider upgrading to PHPStan v2)', $outputContent); + self::assertStringContainsString('missingType (in 1 file)', $outputContent); + self::assertStringContainsString('🚨 1 errors cannot be ignored by baseline', $outputContent); } /** @@ -293,34 +288,4 @@ private function createAnalysisResult(array $fileErrors, array $genericErrors, a // @phpstan-ignore-next-line return new AnalysisResult($fileErrors, $genericErrors, [], $warnings, [], false, null, true, memory_get_peak_usage(true), false); } - - /** - * Builds a regular expression pattern for matching ANSI-colored error summary lines. - * - * The pattern matches lines that contain a colored count of errors or warnings, - * a colored identifier (such as "errors" or "warnings"), and a colored file count - * in parentheses, using the same ANSI escape sequences as produced by the formatter. - * - * @param string $colorCode One of the keys of the internal ANSI color map (e.g. 'green', 'yellow', 'red'). - * @param string $identifier the text identifier to match (for example "errors" or "warnings") - * @param int $count the expected number of errors or warnings to appear in the summary - * @param int $fileCount the expected number of files to appear in the summary - * - * @return string regular expression pattern for matching the formatted summary line - */ - private function buildErrorSummaryPattern(string $colorCode, string $identifier, int $count = 1, int $fileCount = 1): string - { - $ansiColorCodes = [ - 'green' => '\x1b\[32m', - 'yellow' => '\x1b\[33m', - 'red' => '\x1b\[31m', - ]; - $colorPattern = $ansiColorCodes[$colorCode]; - $identifierColorPattern = '\x1b\[33m'; - $grayPattern = '\x1b\[90m'; - $resetPattern = '\x1b\[[0-9;]*m'; - $escapedIdentifier = preg_quote($identifier, '/'); - - return "/{$colorPattern}{$count}{$resetPattern}\\s+{$identifierColorPattern}{$escapedIdentifier}{$resetPattern} {$grayPattern}\\(in {$fileCount} files?\\){$resetPattern}/"; - } } From d448ed3b58b28aa85f6691ffff5f9ab798cb10a1 Mon Sep 17 00:00:00 2001 From: yamadashy Date: Sun, 25 Jan 2026 23:52:39 +0900 Subject: [PATCH 8/9] feat: polish summary output styling and wording MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Changes: - Use 🏷️ emoji for identifier display (unified across error details and summary) - Update tree item wording to match original PR style: - "can be removed after updating the baseline" - "have no identifier, consider upgrading to PHPStan v2" - Keep colors minimal - only gray for supplementary info like file counts - Update tests to match new output format Design principle: Colors should enhance readability, not carry meaning. CI environments often disable colors, so all information must be conveyed through text alone. --- src/ErrorFormat/ErrorWriter.php | 2 +- src/ErrorFormat/SummaryWriter.php | 8 ++++---- tests/FriendlyErrorFormatterTest.php | 10 +++++----- 3 files changed, 10 insertions(+), 10 deletions(-) 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 5bc80c7..02ba99f 100644 --- a/src/ErrorFormat/SummaryWriter.php +++ b/src/ErrorFormat/SummaryWriter.php @@ -34,7 +34,7 @@ public function writeGroupedErrorsSummary(AnalysisResult $analysisResult, Output $uniqueFiles[$file] = true; // Count non-ignorable errors excluding ignore.unmatched - if (!$error->canBeIgnored() && $identifier !== self::IDENTIFIER_IGNORE_UNMATCHED) { + if (!$error->canBeIgnored() && self::IDENTIFIER_IGNORE_UNMATCHED !== $identifier) { ++$nonIgnorableCounter; } } @@ -47,7 +47,7 @@ public function writeGroupedErrorsSummary(AnalysisResult $analysisResult, Output foreach ($errorCounter as $identifier => $count) { $fileCount = \count($files[$identifier]); $suffix = $this->getFileSuffix($fileCount); - $note = $identifier === self::IDENTIFIER_IGNORE_UNMATCHED + $note = self::IDENTIFIER_IGNORE_UNMATCHED === $identifier ? ', can be removed after baseline update' : ''; @@ -79,11 +79,11 @@ public function writeGroupedErrorsSummary(AnalysisResult $analysisResult, Output if ($unmatchedCount > 0) { $toFixCount = $totalErrors - $unmatchedCount; $treeItems[] = \sprintf('%d %s to fix', $toFixCount, $this->getErrorSuffix($toFixCount)); - $treeItems[] = \sprintf('%d %s can be removed from baseline', $unmatchedCount, $this->getErrorSuffix($unmatchedCount)); + $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', $noIdentifierCount, $this->getErrorSuffix($noIdentifierCount)); + $treeItems[] = \sprintf('%d %s have no identifier, consider upgrading to PHPStan v2', $noIdentifierCount, $this->getErrorSuffix($noIdentifierCount)); } if ($nonIgnorableCounter > 0) { diff --git a/tests/FriendlyErrorFormatterTest.php b/tests/FriendlyErrorFormatterTest.php index 0a556b9..d3a1922 100644 --- a/tests/FriendlyErrorFormatterTest.php +++ b/tests/FriendlyErrorFormatterTest.php @@ -73,11 +73,11 @@ public static function provideFormatErrorsCases(): iterable 14| } 15| 16| }', - '📊 Error Identifier Summary:', - ' (in 1 file, consider upgrading to PHPStan v2)', + '📈 Error Identifier Summary:', + ' (in 1 file)', '📊 Summary:', '❌ Found 1 errors', - '🏷️ In 1 error categories', + '🏷️ In 1 error identifiers', '📂 Across 1 file', '[ERROR] Found 1 error', ], @@ -217,9 +217,9 @@ public function testSummaryShowsSpecialIdentifierNotes(): void self::assertSame(1, $exitCode); self::assertStringContainsString('ignore.unmatched (in 1 file, can be removed after baseline update)', $outputContent); - self::assertStringContainsString(' (in 1 file, consider upgrading to PHPStan v2)', $outputContent); + self::assertStringContainsString(' (in 1 file)', $outputContent); self::assertStringContainsString('missingType (in 1 file)', $outputContent); - self::assertStringContainsString('🚨 1 errors cannot be ignored by baseline', $outputContent); + self::assertStringContainsString('1 error cannot be ignored by baseline', $outputContent); } /** From 5b8016c8cd69fbf399d7a4eda1ba2fd59bf092b9 Mon Sep 17 00:00:00 2001 From: yamadashy Date: Mon, 26 Jan 2026 10:33:33 +0900 Subject: [PATCH 9/9] fix: use consistent file path retrieval in SummaryWriter Use getTraitFilePath() ?? getFilePath() to match ErrorWriter behavior, ensuring file counts are accurate when errors occur in traits. --- src/ErrorFormat/SummaryWriter.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ErrorFormat/SummaryWriter.php b/src/ErrorFormat/SummaryWriter.php index 02ba99f..bd787e6 100644 --- a/src/ErrorFormat/SummaryWriter.php +++ b/src/ErrorFormat/SummaryWriter.php @@ -24,7 +24,7 @@ public function writeGroupedErrorsSummary(AnalysisResult $analysisResult, Output foreach ($analysisResult->getFileSpecificErrors() as $error) { $identifier = $error->getIdentifier() ?? self::IDENTIFIER_NO_IDENTIFIER; - $file = $error->getFile(); + $file = $error->getTraitFilePath() ?? $error->getFilePath(); $errorCounter[$identifier] ??= 0; ++$errorCounter[$identifier];