Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}'
Expand Down
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
14 changes: 14 additions & 0 deletions playground/baseline.neon
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions playground/phpstan.neon
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

includes:
- ../extension.neon
- baseline.neon

parameters:
level: 9
Expand Down
2 changes: 1 addition & 1 deletion src/ErrorFormat/ErrorWriter.php
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ public function writeFileSpecificErrors(AnalysisResult $analysisResult, Output $
}

if (null !== $errorIdentifier) {
$output->writeLineFormatted(" <fg=default>🪪 {$errorIdentifier}</>");
$output->writeLineFormatted(" <fg=default>🏷️ {$errorIdentifier}</>");
}

if (\is_string($this->config->editorUrl)) {
Expand Down
88 changes: 83 additions & 5 deletions src/ErrorFormat/SummaryWriter.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,27 +8,105 @@
class SummaryWriter
{
private const IDENTIFIER_NO_IDENTIFIER = '<no-identifier>';
private const IDENTIFIER_IGNORE_UNMATCHED = 'ignore.unmatched';

public function writeGroupedErrorsSummary(AnalysisResult $analysisResult, Output $output): void
{
/** @var array<string, int> $errorCounter */
$errorCounter = [];
$nonIgnorableCounter = 0;

/** @var array<string, array<string, true>> $files files per identifier */
$files = [];

/** @var array<string, true> $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 <fg=gray>(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 <fg=red>%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 <fg=red>%d</> error identifiers', \count($errorCounter)));
$output->writeLineFormatted(\sprintf('📂 Across <fg=red>%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';
}
}
40 changes: 40 additions & 0 deletions tests/FriendlyErrorFormatterTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,12 @@ public static function provideFormatErrorsCases(): iterable
14| }
15|
16| }',
'📈 Error Identifier Summary:',
' <no-identifier> (in 1 file)',
'📊 Summary:',
'❌ Found 1 errors',
'🏷️ In 1 error identifiers',
'📂 Across 1 file',
'[ERROR] Found 1 error',
],
];
Expand Down Expand Up @@ -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('<no-identifier> (in 1 file)', $outputContent);
self::assertStringContainsString('missingType (in 1 file)', $outputContent);
self::assertStringContainsString('1 error cannot be ignored by baseline', $outputContent);
}

/**
* @throws ShouldNotHappenException
*/
Expand All @@ -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<Error> $fileErrors
* @param list<string> $genericErrors
* @param list<string> $warnings
*/
private function createAnalysisResult(array $fileErrors, array $genericErrors, array $warnings): AnalysisResult
{
$reflectionMethod = new \ReflectionMethod(AnalysisResult::class, '__construct');
$numOfParams = $reflectionMethod->getNumberOfParameters();

Expand Down