From f012a0a3ed2e8b7869daa017f3b8aa6bf64638e1 Mon Sep 17 00:00:00 2001 From: staabm <120441+staabm@users.noreply.github.com> Date: Sun, 10 May 2026 09:02:21 +0000 Subject: [PATCH] Support `reportUnmatchedIgnoredErrors: warning` to report unmatched ignores as warnings instead of errors - Accept `'warning'` as a new value for both `reportUnmatchedIgnoredErrors` (global) and per-error `reportUnmatched` alongside `true`/`false` - When set to `'warning'`, unmatched baseline/config ignore patterns and unmatched `@phpstan-ignore` line comments are reported as warnings (non-failing) instead of errors (failing) - Update `IgnoredErrorHelperResult::process()` to route unmatched ignores to a new warnings array when `reportUnmatched === 'warning'` - Update `AnalyserResultFinalizer::addUnmatchedIgnoredErrors()` to route unmatched line ignores to warnings when set to `'warning'` - Add `getWarnings()` to `IgnoredErrorHelperProcessedResult` and `FinalizerResult` to carry warnings through the pipeline - Wire warnings from both sources into `AnalysisResult` via `AnalyseApplication::analyse()` - Update `parametersSchema.neon` to accept `anyOf(bool(), 'warning')` - Update `ValidateIgnoredErrorsExtension` to validate paths when the setting is `'warning'` (not just `true`) - Add merge logic for per-error `reportUnmatched` during deduplication with precedence: `true` > `'warning'` > `false` --- conf/parametersSchema.neon | 4 +- src/Analyser/AnalyserResultFinalizer.php | 45 ++++++++----- src/Analyser/FinalizerResult.php | 10 +++ src/Analyser/Ignore/IgnoredErrorHelper.php | 25 +++++-- .../IgnoredErrorHelperProcessedResult.php | 10 +++ .../Ignore/IgnoredErrorHelperResult.php | 45 +++++++++---- src/Command/AnalyseApplication.php | 8 ++- .../ValidateIgnoredErrorsExtension.php | 4 +- tests/PHPStan/Analyser/AnalyserTest.php | 67 ++++++++++++++++++- 9 files changed, 174 insertions(+), 44 deletions(-) diff --git a/conf/parametersSchema.neon b/conf/parametersSchema.neon index 15e6e02c215..2419012f019 100644 --- a/conf/parametersSchema.neon +++ b/conf/parametersSchema.neon @@ -136,7 +136,7 @@ parametersSchema: ?path: string() ?paths: listOf(string()) ?count: int() - ?reportUnmatched: bool() + ?reportUnmatched: anyOf(bool(), 'warning') ]), ) ) @@ -146,7 +146,7 @@ parametersSchema: resolvedPhpDocBlockCacheCountMax: int(), nameScopeMapMemoryCacheCountMax: int() ]) - reportUnmatchedIgnoredErrors: bool() + reportUnmatchedIgnoredErrors: anyOf(bool(), 'warning') reportIgnoresWithoutComments: bool() typeAliases: arrayOf(string()) universalObjectCratesClasses: listOf(string()) diff --git a/src/Analyser/AnalyserResultFinalizer.php b/src/Analyser/AnalyserResultFinalizer.php index 627c946e691..af04246a7a7 100644 --- a/src/Analyser/AnalyserResultFinalizer.php +++ b/src/Analyser/AnalyserResultFinalizer.php @@ -27,7 +27,7 @@ public function __construct( private ScopeFactory $scopeFactory, private LocalIgnoresProcessor $localIgnoresProcessor, #[AutowiredParameter] - private bool $reportUnmatchedIgnoredErrors, + private bool|string $reportUnmatchedIgnoredErrors, ) { } @@ -182,11 +182,13 @@ private function addUnmatchedIgnoredErrors( array $locallyIgnoredCollectorErrors, ): FinalizerResult { - if (!$this->reportUnmatchedIgnoredErrors) { + if ($this->reportUnmatchedIgnoredErrors === false) { return new FinalizerResult($analyserResult, $collectorErrors, $locallyIgnoredCollectorErrors); } + $isWarning = $this->reportUnmatchedIgnoredErrors === 'warning'; $errors = $analyserResult->getUnorderedErrors(); + $warnings = []; foreach ($analyserResult->getUnmatchedLineIgnores() as $file => $data) { foreach ($data as $ignoredFile => $lines) { if ($ignoredFile !== $file) { @@ -195,24 +197,34 @@ private function addUnmatchedIgnoredErrors( foreach ($lines as $line => $identifiers) { if ($identifiers === null) { - $errors[] = (new Error( - sprintf('No error to ignore is reported on line %d.', $line), - $file, - $line, - false, - $file, - ))->withIdentifier('ignore.unmatchedLine'); + $message = sprintf('No error to ignore is reported on line %d.', $line); + if ($isWarning) { + $warnings[] = $message; + } else { + $errors[] = (new Error( + $message, + $file, + $line, + false, + $file, + ))->withIdentifier('ignore.unmatchedLine'); + } continue; } foreach ($identifiers as $identifier) { - $errors[] = (new Error( - sprintf('No error with identifier %s is reported on line %d.', $identifier['name'], $line), - $file, - $line, - false, - $file, - ))->withIdentifier('ignore.unmatchedIdentifier'); + $message = sprintf('No error with identifier %s is reported on line %d.', $identifier['name'], $line); + if ($isWarning) { + $warnings[] = $message; + } else { + $errors[] = (new Error( + $message, + $file, + $line, + false, + $file, + ))->withIdentifier('ignore.unmatchedIdentifier'); + } } } } @@ -237,6 +249,7 @@ private function addUnmatchedIgnoredErrors( ), $collectorErrors, $locallyIgnoredCollectorErrors, + $warnings, ); } diff --git a/src/Analyser/FinalizerResult.php b/src/Analyser/FinalizerResult.php index c196b25d99f..c6be4402eef 100644 --- a/src/Analyser/FinalizerResult.php +++ b/src/Analyser/FinalizerResult.php @@ -8,11 +8,13 @@ final class FinalizerResult /** * @param list $collectorErrors * @param list $locallyIgnoredCollectorErrors + * @param list $warnings */ public function __construct( private AnalyserResult $analyserResult, private array $collectorErrors, private array $locallyIgnoredCollectorErrors, + private array $warnings = [], ) { } @@ -46,4 +48,12 @@ public function getLocallyIgnoredCollectorErrors(): array return $this->locallyIgnoredCollectorErrors; } + /** + * @return list + */ + public function getWarnings(): array + { + return $this->warnings; + } + } diff --git a/src/Analyser/Ignore/IgnoredErrorHelper.php b/src/Analyser/Ignore/IgnoredErrorHelper.php index d3394bcb0bb..9ea8d3cb62d 100644 --- a/src/Analyser/Ignore/IgnoredErrorHelper.php +++ b/src/Analyser/Ignore/IgnoredErrorHelper.php @@ -26,7 +26,7 @@ public function __construct( #[AutowiredParameter] private array $ignoreErrors, #[AutowiredParameter] - private bool $reportUnmatchedIgnoredErrors, + private bool|string $reportUnmatchedIgnoredErrors, ) { } @@ -106,10 +106,9 @@ public function initialize(): IgnoredErrorHelperResult continue; } - $reportUnmatched = (bool) ($uniquedExpandedIgnoreErrors[$key]['reportUnmatched'] ?? $this->reportUnmatchedIgnoredErrors); - if (!$reportUnmatched) { - $reportUnmatched = $ignoreError['reportUnmatched'] ?? $this->reportUnmatchedIgnoredErrors; - } + $existingReportUnmatched = $uniquedExpandedIgnoreErrors[$key]['reportUnmatched'] ?? $this->reportUnmatchedIgnoredErrors; + $newReportUnmatched = $ignoreError['reportUnmatched'] ?? $this->reportUnmatchedIgnoredErrors; + $reportUnmatched = self::mergeReportUnmatched($existingReportUnmatched, $newReportUnmatched); $uniquedExpandedIgnoreErrors[$key] = [ 'message' => $ignoreError['message'] ?? null, @@ -159,4 +158,20 @@ public function initialize(): IgnoredErrorHelperResult return new IgnoredErrorHelperResult($this->fileHelper, $errors, $otherIgnoreErrors, $ignoreErrorsByFile, $expandedIgnoreErrors, $this->reportUnmatchedIgnoredErrors); } + /** + * @return bool|string true > 'warning' > false + */ + private static function mergeReportUnmatched(bool|string $a, bool|string $b): bool|string + { + if ($a === true || $b === true) { + return true; + } + + if ($a === 'warning' || $b === 'warning') { + return 'warning'; + } + + return false; + } + } diff --git a/src/Analyser/Ignore/IgnoredErrorHelperProcessedResult.php b/src/Analyser/Ignore/IgnoredErrorHelperProcessedResult.php index 67bcfa176c0..689959585a4 100644 --- a/src/Analyser/Ignore/IgnoredErrorHelperProcessedResult.php +++ b/src/Analyser/Ignore/IgnoredErrorHelperProcessedResult.php @@ -11,11 +11,13 @@ final class IgnoredErrorHelperProcessedResult * @param list $notIgnoredErrors * @param list $ignoredErrors * @param list $otherIgnoreMessages + * @param list $warnings */ public function __construct( private array $notIgnoredErrors, private array $ignoredErrors, private array $otherIgnoreMessages, + private array $warnings = [], ) { } @@ -44,4 +46,12 @@ public function getOtherIgnoreMessages(): array return $this->otherIgnoreMessages; } + /** + * @return list + */ + public function getWarnings(): array + { + return $this->warnings; + } + } diff --git a/src/Analyser/Ignore/IgnoredErrorHelperResult.php b/src/Analyser/Ignore/IgnoredErrorHelperResult.php index ea4c1295309..5f59555c79d 100644 --- a/src/Analyser/Ignore/IgnoredErrorHelperResult.php +++ b/src/Analyser/Ignore/IgnoredErrorHelperResult.php @@ -28,7 +28,7 @@ public function __construct( private array $otherIgnoreErrors, private array $ignoreErrorsByFile, private array $ignoreErrors, - private bool $reportUnmatchedIgnoredErrors, + private bool|string $reportUnmatchedIgnoredErrors, ) { } @@ -54,6 +54,7 @@ public function process( { $unmatchedIgnoredErrors = $this->ignoreErrors; $stringErrors = []; + $warnings = []; $processIgnoreError = function (Error $error, int $i, $ignore) use (&$unmatchedIgnoredErrors, &$stringErrors): bool { $shouldBeIgnored = false; @@ -199,12 +200,13 @@ public function process( if ($reportUnmatched === false) { continue; } + $isWarning = $reportUnmatched === 'warning'; if ( isset($unmatchedIgnoredError['count'], $unmatchedIgnoredError['realCount']) && (isset($unmatchedIgnoredError['realPath']) || !$onlyFiles) ) { if ($unmatchedIgnoredError['realCount'] < $unmatchedIgnoredError['count']) { - $errors[] = (new Error(sprintf( + $message = sprintf( '%s %s is expected to occur %d %s, but occurred only %d %s.', IgnoredError::getIgnoredErrorLabel($unmatchedIgnoredError), IgnoredError::stringifyPattern($unmatchedIgnoredError), @@ -212,7 +214,12 @@ public function process( $unmatchedIgnoredError['count'] === 1 ? 'time' : 'times', $unmatchedIgnoredError['realCount'], $unmatchedIgnoredError['realCount'] === 1 ? 'time' : 'times', - ), $unmatchedIgnoredError['file'], $unmatchedIgnoredError['line'], false))->withIdentifier('ignore.count'); + ); + if ($isWarning) { + $warnings[] = $message; + } else { + $errors[] = (new Error($message, $unmatchedIgnoredError['file'], $unmatchedIgnoredError['line'], false))->withIdentifier('ignore.count'); + } } } elseif (isset($unmatchedIgnoredError['realPath'])) { if (!array_key_exists($unmatchedIgnoredError['realPath'], $analysedFilesKeys)) { @@ -223,26 +230,36 @@ public function process( continue; } - $errors[] = (new Error( - sprintf( - '%s %s was not matched in reported errors.', - IgnoredError::getIgnoredErrorLabel($unmatchedIgnoredError), - IgnoredError::stringifyPattern($unmatchedIgnoredError), - ), - $unmatchedIgnoredError['realPath'], - canBeIgnored: false, - ))->withIdentifier('ignore.unmatched'); + $message = sprintf( + '%s %s was not matched in reported errors.', + IgnoredError::getIgnoredErrorLabel($unmatchedIgnoredError), + IgnoredError::stringifyPattern($unmatchedIgnoredError), + ); + if ($isWarning) { + $warnings[] = $message; + } else { + $errors[] = (new Error( + $message, + $unmatchedIgnoredError['realPath'], + canBeIgnored: false, + ))->withIdentifier('ignore.unmatched'); + } } elseif (!$onlyFiles) { - $stringErrors[] = sprintf( + $message = sprintf( '%s %s was not matched in reported errors.', IgnoredError::getIgnoredErrorLabel($unmatchedIgnoredError), IgnoredError::stringifyPattern($unmatchedIgnoredError), ); + if ($isWarning) { + $warnings[] = $message; + } else { + $stringErrors[] = $message; + } } } } - return new IgnoredErrorHelperProcessedResult($errors, $ignoredErrors, $stringErrors); + return new IgnoredErrorHelperProcessedResult($errors, $ignoredErrors, $stringErrors, $warnings); } } diff --git a/src/Command/AnalyseApplication.php b/src/Command/AnalyseApplication.php index e78212f492c..660bccfd430 100644 --- a/src/Command/AnalyseApplication.php +++ b/src/Command/AnalyseApplication.php @@ -129,11 +129,12 @@ public function analyse( $processedFiles = $intermediateAnalyserResult->getProcessedFiles(); $resultCacheResult = $resultCacheManager->process($intermediateAnalyserResult, $resultCache, $errorOutput, $onlyFiles, true); - $analyserResult = $this->analyserResultFinalizer->finalize( + $finalizerResult = $this->analyserResultFinalizer->finalize( $this->switchTmpFileInAnalyserResult($resultCacheResult->getAnalyserResult(), $insteadOfFile, $tmpFile), $onlyFiles, $debug, - )->getAnalyserResult(); + ); + $analyserResult = $finalizerResult->getAnalyserResult(); $internalErrors = $analyserResult->getInternalErrors(); $errors = array_merge( $analyserResult->getErrors(), @@ -172,6 +173,7 @@ public function analyse( $ignoredErrorHelperProcessedResult = $ignoredErrorHelperResult->process($errors, $onlyFiles, $files, $hasInternalErrors); $fileSpecificErrors = $ignoredErrorHelperProcessedResult->getNotIgnoredErrors(); $notFileSpecificErrors = $ignoredErrorHelperProcessedResult->getOtherIgnoreMessages(); + $warnings = array_merge($finalizerResult->getWarnings(), $ignoredErrorHelperProcessedResult->getWarnings()); $collectedData = $analyserResult->getCollectedData(); $savedResultCache = $resultCacheResult->isSaved(); } @@ -180,7 +182,7 @@ public function analyse( $fileSpecificErrors, $notFileSpecificErrors, $internalErrors, - [], + $warnings ?? [], $this->mapCollectedData($collectedData), $defaultLevelUsed, $projectConfigFile, diff --git a/src/DependencyInjection/ValidateIgnoredErrorsExtension.php b/src/DependencyInjection/ValidateIgnoredErrorsExtension.php index 6bcd2eb995d..7e763346ef5 100644 --- a/src/DependencyInjection/ValidateIgnoredErrorsExtension.php +++ b/src/DependencyInjection/ValidateIgnoredErrorsExtension.php @@ -169,9 +169,9 @@ public function getRegistry(): UnaryOperatorTypeSpecifyingExtensionRegistry } } - $reportUnmatched = (bool) $builder->parameters['reportUnmatchedIgnoredErrors']; + $reportUnmatched = $builder->parameters['reportUnmatchedIgnoredErrors']; - if ($reportUnmatched) { + if ($reportUnmatched !== false) { foreach ($ignoreErrors as $ignoreError) { if (!is_array($ignoreError)) { continue; diff --git a/tests/PHPStan/Analyser/AnalyserTest.php b/tests/PHPStan/Analyser/AnalyserTest.php index 39da6a5be9e..4b54849c01d 100644 --- a/tests/PHPStan/Analyser/AnalyserTest.php +++ b/tests/PHPStan/Analyser/AnalyserTest.php @@ -43,6 +43,9 @@ class AnalyserTest extends PHPStanTestCase { + /** @var list */ + private array $lastWarnings = []; + public function testReturnErrorIfIgnoredMessagesDoesNotOccur(): void { $result = $this->runAnalyser(['#Unknown error#'], true, __DIR__ . '/data/empty/empty.php', false); @@ -736,6 +739,63 @@ public function testIgnoreErrorExplicitReportUnmatchedEnableMulti(): void $this->assertSame('Ignored error pattern #Fail# was not matched in reported errors.', $result[0]); } + public function testReportUnmatchedIgnoredErrorsWarningGlobal(): void + { + $result = $this->runAnalyser(['#Unknown error#'], 'warning', __DIR__ . '/data/empty/empty.php', false); + $this->assertNoErrors($result); + $this->assertSame([ + 'Ignored error pattern #Unknown error# was not matched in reported errors.', + ], $this->lastWarnings); + } + + public function testReportUnmatchedIgnoredErrorsWarningPerError(): void + { + $ignoreErrors = [ + [ + 'message' => '#Fail#', + 'reportUnmatched' => 'warning', + ], + ]; + $result = $this->runAnalyser($ignoreErrors, true, __DIR__ . '/data/bootstrap.php', false); + $this->assertNoErrors($result); + $this->assertSame([ + 'Ignored error pattern #Fail# was not matched in reported errors.', + ], $this->lastWarnings); + } + + public function testReportUnmatchedIgnoredErrorsWarningPerErrorRaw(): void + { + $ignoreErrors = [ + [ + 'rawMessage' => 'Fail.', + 'reportUnmatched' => 'warning', + ], + ]; + $result = $this->runAnalyser($ignoreErrors, true, __DIR__ . '/data/bootstrap.php', false); + $this->assertNoErrors($result); + $this->assertSame([ + 'Ignored error "Fail." was not matched in reported errors.', + ], $this->lastWarnings); + } + + public function testReportUnmatchedIgnoredErrorsWarningLine(): void + { + $result = $this->runAnalyser([], 'warning', [ + __DIR__ . '/data/ignore-line.php', + ], true); + $this->assertCount(3, $result); + foreach ([10, 19, 22] as $i => $line) { + $this->assertArrayHasKey($i, $result); + $this->assertInstanceOf(Error::class, $result[$i]); + $this->assertSame('Fail.', $result[$i]->getMessage()); + $this->assertSame($line, $result[$i]->getLine()); + } + + $this->assertSame([ + 'No error to ignore is reported on line 26.', + ], $this->lastWarnings); + } + /** * @param mixed[] $ignoreErrors * @param string|string[] $filePaths @@ -743,7 +803,7 @@ public function testIgnoreErrorExplicitReportUnmatchedEnableMulti(): void */ private function runAnalyser( array $ignoreErrors, - bool $reportUnmatchedIgnoredErrors, + bool|string $reportUnmatchedIgnoredErrors, $filePaths, bool $onlyFiles, ): array @@ -779,7 +839,8 @@ private function runAnalyser( new LocalIgnoresProcessor(), $reportUnmatchedIgnoredErrors, ); - $analyserResult = $finalizer->finalize($analyserResult, $onlyFiles, false)->getAnalyserResult(); + $finalizerResult = $finalizer->finalize($analyserResult, $onlyFiles, false); + $analyserResult = $finalizerResult->getAnalyserResult(); $ignoredErrorHelperProcessedResult = $ignoredErrorHelperResult->process($analyserResult->getErrors(), $onlyFiles, $normalizedFilePaths, $analyserResult->hasReachedInternalErrorsCountLimit()); $errors = $ignoredErrorHelperProcessedResult->getNotIgnoredErrors(); @@ -788,6 +849,8 @@ private function runAnalyser( $errors[] = sprintf('Reached internal errors count limit of %d, exiting...', 50); } + $this->lastWarnings = array_merge($finalizerResult->getWarnings(), $ignoredErrorHelperProcessedResult->getWarnings()); + return array_merge( $errors, array_map(static fn (InternalError $internalError) => $internalError->getMessage(), $analyserResult->getInternalErrors()),