Repository: vimeo/psalm (PHP)

Issue:
[Feature request] Report unmatched baseline rules
It would be great IMO to force contributors to show the positive effect of their changes by erroring out if there are unused baseline elements or [issue handlers](https://github.com/vimeo/psalm/issues/3262):
1. It validates further that what they are doing is right.
2. It allows the baseline to be kept in sync avoiding people to get confused when `--update-baseline` results in changes unrelated to their own changes.

There already is `--find-unused-psalm-suppress` for `@psalm-suppress`, but there does not seem to be something for other ignore mechanisms such as the baseline or issue handlers.


Discussion: Hey @greg0ire, can you reproduce the issue on https://psalm.dev ?



Fix (patch):
diff --git a/config.xsd b/config.xsd
index 046547eeeed..4f4b7f82a8b 100644
--- a/config.xsd
+++ b/config.xsd
@@ -44,6 +44,8 @@
         <xs:attribute name="findUnusedCode" type="xs:boolean" default="false" />
         <xs:attribute name="findUnusedVariablesAndParams" type="xs:boolean" default="false" />
         <xs:attribute name="findUnusedPsalmSuppress" type="xs:boolean" default="false" />
+        <!-- TODO: Update default to true in Psalm 6 -->
+        <xs:attribute name="findUnusedBaselineEntry" type="xs:boolean" default="false" />
         <xs:attribute name="hideExternalErrors" type="xs:boolean" default="false" />
         <xs:attribute name="hoistConstants" type="xs:boolean" default="false" />
         <xs:attribute name="ignoreInternalFunctionFalseReturn" type="xs:boolean" default="true" />
@@ -476,6 +478,7 @@
             <xs:element name="UnsafeGenericInstantiation" type="IssueHandlerType" minOccurs="0" />
             <xs:element name="UnsafeInstantiation" type="IssueHandlerType" minOccurs="0" />
             <xs:element name="UnsupportedReferenceUsage" type="IssueHandlerType" minOccurs="0" />
+            <xs:element name="UnusedBaselineEntry" type="IssueHandlerType" minOccurs="0" />
             <xs:element name="UnusedClass" type="ClassIssueHandlerType" minOccurs="0" />
             <xs:element name="UnusedClosureParam" type="IssueHandlerType" minOccurs="0" />
             <xs:element name="UnusedConstructor" type="MethodIssueHandlerType" minOccurs="0" />
diff --git a/docs/running_psalm/configuration.md b/docs/running_psalm/configuration.md
index 8b8d5a385fc..c642eb3f08b 100644
--- a/docs/running_psalm/configuration.md
+++ b/docs/running_psalm/configuration.md
@@ -491,6 +491,10 @@ class PremiumCar extends StandardCar {
 ```
 `ImplementedReturnTypeMismatch - The inherited return type 'list{'motor', 'brakes', 'wheels'}' for StandardCar::getSystems is different to the implemented return type for PremiumCar::getsystems 'list{'motor', 'brakes', 'wheels', 'rear parking sensor'}'`
 
+#### findUnusedBaselineEntry
+
+Emits [UnusedBaselineEntry](issues/UnusedBaselineEntry.md) when a baseline entry
+is not being used to suppress an issue.
 
 ## Project settings
 
diff --git a/docs/running_psalm/issues.md b/docs/running_psalm/issues.md
index 615dd2d97cc..b232476a3bc 100644
--- a/docs/running_psalm/issues.md
+++ b/docs/running_psalm/issues.md
@@ -282,6 +282,7 @@
  - [UnsafeGenericInstantiation](issues/UnsafeGenericInstantiation.md)
  - [UnsafeInstantiation](issues/UnsafeInstantiation.md)
  - [UnsupportedReferenceUsage](issues/UnsupportedReferenceUsage.md)
+ - [UnusedBaselineEntry](issues/UnusedBaselineEntry.md)
  - [UnusedClass](issues/UnusedClass.md)
  - [UnusedClosureParam](issues/UnusedClosureParam.md)
  - [UnusedConstructor](issues/UnusedConstructor.md)
diff --git a/docs/running_psalm/issues/UnusedBaselineEntry.md b/docs/running_psalm/issues/UnusedBaselineEntry.md
new file mode 100644
index 00000000000..16e238eb417
--- /dev/null
+++ b/docs/running_psalm/issues/UnusedBaselineEntry.md
@@ -0,0 +1,22 @@
+# UnusedBaselineEntry
+
+Emitted when a baseline entry is not being used to suppress an issue.
+
+Enabled by [findUnusedBaselineEntry](../configuration.md#findunusedbaselineentry) 
+
+```php
+<?php
+$a = 'Hello, World!';
+echo $a;
+```
+```xml
+<?xml version="1.0" encoding="UTF-8"?>
+<files>
+    <file src="example.php">
+        <UnusedVariable>
+            <!-- The following entry is unused and should be removed. -->
+            <code>$a</code>
+        </UnusedVariable>
+    </file>
+</files>
+```
diff --git a/psalm-baseline.xml b/psalm-baseline.xml
index 590089a8471..516695fa20b 100644
--- a/psalm-baseline.xml
+++ b/psalm-baseline.xml
@@ -701,11 +701,6 @@
       <code>hasLowercaseString</code>
     </PossiblyUnusedMethod>
   </file>
-  <file src="tests/Internal/Codebase/InternalCallMapHandlerTest.php">
-    <UnusedPsalmSuppress>
-      <code>UndefinedMethod</code>
-    </UnusedPsalmSuppress>
-  </file>
   <file src="vendor/nikic/php-parser/lib/PhpParser/Node/Expr/ArrowFunction.php">
     <PossiblyUndefinedStringArrayOffset>
       <code>$subNodes['expr']</code>
diff --git a/psalm.xml.dist b/psalm.xml.dist
index f81ee56d449..6cee5a64bbf 100644
--- a/psalm.xml.dist
+++ b/psalm.xml.dist
@@ -12,6 +12,7 @@
     limitMethodComplexity="true"
     errorBaseline="psalm-baseline.xml"
     findUnusedPsalmSuppress="true"
+    findUnusedBaselineEntry="true"
 >
     <stubs>
         <file name="stubs/phpparser.phpstub"/>
diff --git a/src/Psalm/Config.php b/src/Psalm/Config.php
index 5efbf8c27aa..faee01d9f25 100644
--- a/src/Psalm/Config.php
+++ b/src/Psalm/Config.php
@@ -460,6 +460,11 @@ class Config
      */
     public $find_unused_psalm_suppress = false;
 
+    /**
+     * TODO: Psalm 6: Update default to be true and remove warning.
+     */
+    public bool $find_unused_baseline_entry = false;
+
     /**
      * @var bool
      */
@@ -1061,6 +1066,7 @@ private static function fromXmlAndPaths(
             'allowInternalNamedArgumentsCalls' => 'allow_internal_named_arg_calls',
             'allowNamedArgumentCalls' => 'allow_named_arg_calls',
             'findUnusedPsalmSuppress' => 'find_unused_psalm_suppress',
+            'findUnusedBaselineEntry' => 'find_unused_baseline_entry',
             'reportInfo' => 'report_info',
             'restrictReturnTypes' => 'restrict_return_types',
             'limitMethodComplexity' => 'limit_method_complexity',
@@ -1164,6 +1170,10 @@ private static function fromXmlAndPaths(
             $config->use_igbinary = version_compare($igbinary_version, '2.0.5') >= 0;
         }
 
+        if (!isset($config_xml['findUnusedBaselineEntry']) && !defined('__IS_TEST_ENV__')) {
+            fwrite(STDERR, 'Warning: "findUnusedBaselineEntry" will be defaulted to "true" in Psalm 6. You should'
+                         . ' explicitly enable or disable this setting.' . PHP_EOL);
+        }
 
         if (isset($config_xml['findUnusedCode'])) {
             $attribute_text = (string) $config_xml['findUnusedCode'];
diff --git a/src/Psalm/Config/Creator.php b/src/Psalm/Config/Creator.php
index 8b3f102a8ae..2ce9f3c5c02 100644
--- a/src/Psalm/Config/Creator.php
+++ b/src/Psalm/Config/Creator.php
@@ -45,6 +45,7 @@ final class Creator
     xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
     xmlns="https://getpsalm.org/schema/config"
     xsi:schemaLocation="https://getpsalm.org/schema/config vendor/vimeo/psalm/config.xsd"
+    findUnusedBaselineEntry="true"
 >
     <projectFiles>
         <directory name="src" />
diff --git a/src/Psalm/Issue/UnusedBaselineEntry.php b/src/Psalm/Issue/UnusedBaselineEntry.php
new file mode 100644
index 00000000000..7397a33d3f3
--- /dev/null
+++ b/src/Psalm/Issue/UnusedBaselineEntry.php
@@ -0,0 +1,11 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Psalm\Issue;
+
+class UnusedBaselineEntry extends ClassIssue
+{
+    public const ERROR_LEVEL = -1;
+    public const SHORTCODE = 316;
+}
diff --git a/src/Psalm/IssueBuffer.php b/src/Psalm/IssueBuffer.php
index 4d55976650f..50f347b9815 100644
--- a/src/Psalm/IssueBuffer.php
+++ b/src/Psalm/IssueBuffer.php
@@ -14,6 +14,7 @@
 use Psalm\Issue\ConfigIssue;
 use Psalm\Issue\MixedIssue;
 use Psalm\Issue\TaintedInput;
+use Psalm\Issue\UnusedBaselineEntry;
 use Psalm\Issue\UnusedPsalmSuppress;
 use Psalm\Plugin\EventHandler\Event\AfterAnalysisEvent;
 use Psalm\Plugin\EventHandler\Event\BeforeAddIssueEvent;
@@ -611,6 +612,39 @@ public static function finish(
                         $issues_data[$file_path][$key] = $issue_data;
                     }
                 }
+
+                if ($codebase->config->find_unused_baseline_entry) {
+                    foreach ($issue_baseline as $file_path => $issues) {
+                        foreach ($issues as $issue_name => $issue) {
+                            if ($issue['o'] !== 0) {
+                                $issues_data[$file_path][] = new IssueData(
+                                    Config::REPORT_ERROR,
+                                    0,
+                                    0,
+                                    UnusedBaselineEntry::getIssueType(),
+                                    sprintf(
+                                        'Baseline for issue "%s" has %d extra %s.',
+                                        $issue_name,
+                                        $issue['o'],
+                                        $issue['o'] === 1 ? 'entry' : 'entries',
+                                    ),
+                                    $file_path,
+                                    '',
+                                    '',
+                                    '',
+                                    0,
+                                    0,
+                                    0,
+                                    0,
+                                    0,
+                                    0,
+                                    UnusedBaselineEntry::SHORTCODE,
+                                    UnusedBaselineEntry::ERROR_LEVEL,
+                                );
+                            }
+                        }
+                    }
+                }
             }
         }