Make WordPress Core

Changeset 65372


Ignore:
Timestamp:
11/10/2026 06:51:08 AM (14 hours ago)
Author:
westonruter
Message:

Build/Test Tools: Add a script to fix stale PHPStan baselines.

Since PHPStan 2.3.5, its --fix option removes stale ignores in place: it lowers the count of a baseline entry whose error now occurs fewer times, removes an entry that matches nothing, and removes an @phpstan-ignore comment that no longer ignores anything. That is quicker than regenerating the baselines, since it reuses the result cache, and it also clears the stale inline ignores, which regenerating leaves to be removed by hand.

The new composer phpstan:fix script (and npm run typecheck:php:fix) runs PHPStan with --fix, then the baseline generator with its new --prune option, which deletes any baseline left with no entries along with its includes entry, and then the analysis once more, since --fix reports only what it fixed and succeeds even when other errors remain. Both analyses use phpstan.neon.dist, because a local configuration at another rule level can report a baselined error with a different message, and --fix would then remove entries that the default configuration still needs.

The CI annotations, step summary, and pull request comment now recommend the new script, and the annotation of an error that PHPStan can fix itself says so.

Developed in ​https://github.com/WordPress/wordpress-develop/pull/15479.
Follow-up to r63019, r64539, r65310.

Props westonruter, apermo.
See #65817.

Location:
trunk
Files:
6 edited

Legend:

Unmodified
Added
Removed
  • trunk/.github/workflows/phpstan-pull-request-comments.yml

    r64539 r65372  
    262262                    '> **The PHPStan baselines are out of date, so the PHPStan check fails.**',
    263263                    '>',
    264                     '> An error recorded in a baseline no longer occurs, or occurs a different number of times. If this pull request fixed it, that is expected: nothing needs to change in the code, but each baseline entry records an exact count for a specific file, and the baselines need to catch up. Regenerate them and commit the result:',
     264                    '> An error recorded in a baseline no longer occurs, or occurs a different number of times. If this pull request fixed it, that is expected: nothing needs to change in the code, but each baseline entry records an exact count for a specific file, and the baselines need to catch up. Remove the stale entries, and commit the result:',
    265265                    '>',
    266266                    '> ```bash',
    267                     '> npm run typecheck:php:baselines',
     267                    '> npm run typecheck:php:fix',
    268268                    '> ```',
    269269                    '>',
    … …  
    271271                    '>',
    272272                    '> ```bash',
    273                     '> composer phpstan:baselines',
     273                    '> composer phpstan:fix',
    274274                    '> ```',
    275275                    '>',
    276                     '> Where an error below names an `@phpstan-ignore` comment in the code rather than a baseline entry, remove that comment instead; regenerating will not clear it.',
    277                     '>',
    278                     `> See [Regenerating the baselines](${ readmeUrl }#regenerating-the-baselines) for details.`,
     276                    '> This also deletes any baseline left with no entries, and removes any `@phpstan-ignore` comment in the code that no longer ignores anything.',
     277                    '>',
     278                    `> See [Removing stale entries with \`--fix\`](${ readmeUrl }#removing-stale-entries-with---fix) for details, and for regenerating the baselines instead.`,
    279279                  ].join( '\n' ) );
    280280                } else {
  • trunk/.github/workflows/reusable-phpstan-static-analysis-v1.yml

    r64539 r65372  
    151151                  tip: message.tip ?? null,
    152152                  ignorable: message.ignorable,
     153                  fixable: message.fixable ?? false,
    153154                } );
    154155              }
    155156            }
    156157
    157             // Errors that are not specific to a file, such as an `ignoreErrors` entry without a
    158             // path that no longer matches. These cannot be ignored either.
     158            // Errors that are not specific to a file, such as an `ignoreErrors` entry from a config
     159            // file PHPStan cannot locate it in. These cannot be ignored either.
    159160            for ( const message of report.errors ) {
    160               errors.push( { file: null, line: null, message, identifier: null, tip: null, ignorable: false } );
     161              errors.push( { file: null, line: null, message, identifier: null, tip: null, ignorable: false, fixable: false } );
    161162            }
     163
     164            // PHPStan marks an error it can fix itself, such as a stale baseline entry, as fixable.
     165            const fixHint = 'This can be fixed automatically by running `composer phpstan:fix` (or `npm run typecheck:php:fix`).';
    162166
    163167            for ( const error of errors ) {
    … …  
    169173                properties.startLine = error.line;
    170174              }
    171               const message = error.tip ? `${ error.message }\n${ error.tip }` : error.message;
     175              const message = [ error.message, error.tip, error.fixable ? fixHint : null ].filter( Boolean ).join( '\n' );
    172176              if ( error.ignorable ) {
    173177                core.warning( message, properties );
    … …  
    177181            }
    178182
    179             // Tips are left out of the results, since they are only shown in the annotations, next to the code.
     183            // Tips and whether an error is fixable are left out of the results, since they are only shown in the
     184            // annotations, next to the code.
    180185            //
    181186            // The commit analyzed is the pull request merged into its base branch, whose second parent is the head
    … …  
    197202                pull_request: context.payload.pull_request.number,
    198203                changed_in_base: changedInBase,
    199                 errors: errors.map( ( { tip, ...error } ) => error ),
     204                errors: errors.map( ( { tip, fixable, ...error } ) => error ),
    200205              } ) );
    201206            }
    … …  
    267272          **If you fixed the error, this is expected.** Each baseline entry records an exact count for a specific file, so that a new occurrence of an already baselined error is reported rather than absorbed. That same exactness means fixing one leaves the baseline describing a state that no longer exists. There is nothing to fix in the code; the baselines just need to catch up.
    268273
    269           Regenerate them and commit the result:
     274          Remove the stale entries, and commit the result:
    270275
    271276          ```bash
    272           npm run typecheck:php:baselines
     277          npm run typecheck:php:fix
    273278          ```
    274279
    … …  
    276281
    277282          ```bash
    278           composer phpstan:baselines
     283          composer phpstan:fix
    279284          ```
    280285
    281           That rewrites the files under [`tests/phpstan/baselines`][baselines], deletes any whose errors are now all fixed, and updates the list of them in `phpstan.neon.dist`.
    282 
    283           Where the report names an `@phpstan-ignore` annotation in the code rather than a baseline entry, remove that annotation instead; regenerating will not clear it.
     286          That runs PHPStan with `--fix`, which lowers the counts in the files under [`tests/phpstan/baselines`][baselines], removes the entries that no longer match, and removes any `@phpstan-ignore` annotation in the code that no longer ignores anything. It then deletes any baseline left with no entries, and updates the list of them in `phpstan.neon.dist`.
     287
     288          Alternatively, regenerate the baselines with `npm run typecheck:php:baselines` or `composer phpstan:baselines`. That also deletes any baseline whose errors are now all fixed, and updates the list of them in `phpstan.neon.dist`, but it does not clear an `@phpstan-ignore` annotation.
    284289
    285290          See [`tests/phpstan/README.md`][readme] for details.
    … …  
    295300            } >> "${GITHUB_STEP_SUMMARY}"
    296301
    297             echo "::error title=PHPStan baselines are out of date::An ignored error no longer occurs, or occurs a different number of times. If you fixed it, that is expected: run \`npm run typecheck:php:baselines\` or \`composer phpstan:baselines\` and commit the updated baselines. See ${README_URL}"
     302            echo "::error title=PHPStan baselines are out of date::An ignored error no longer occurs, or occurs a different number of times. If you fixed it, that is expected: run \`npm run typecheck:php:fix\` or \`composer phpstan:fix\` and commit the updated baselines. See ${README_URL}"
    298303          else
    299304            echo "::error title=PHPStan reported errors that cannot be ignored::PHPStan reported ${unignorable} error(s) that cannot be ignored or reported as warnings. See the error annotations for details, and ${README_URL} for how to run the analysis locally."
  • trunk/composer.json

    r65310 r65372  
    7878                "phpstan": "@php ./vendor/bin/phpstan analyse --memory-limit=2G",
    7979                "phpstan:baselines": [ "Composer\\Config::disableProcessTimeout", "@php ./tests/phpstan/generate-baselines.php" ],
     80                "phpstan:fix": [ "Composer\\Config::disableProcessTimeout", "@phpstan --configuration=phpstan.neon.dist --fix", "@phpstan:baselines --prune", "@phpstan --configuration=phpstan.neon.dist" ],
    8081                "compat": "@php ./vendor/squizlabs/php_codesniffer/bin/phpcs --standard=phpcompat.xml.dist --report=summary,source",
    8182                "format": "@php ./vendor/squizlabs/php_codesniffer/bin/phpcbf --report=summary,source",
  • trunk/package.json

    r65315 r65372  
    155155                "typecheck:php": "node ./tools/local-env/scripts/docker.js run --rm php composer phpstan --",
    156156                "typecheck:php:baselines": "node ./tools/local-env/scripts/docker.js run --rm php composer phpstan:baselines --",
     157                "typecheck:php:fix": "node ./tools/local-env/scripts/docker.js run --rm php composer phpstan:fix --",
    157158                "gutenberg:copy": "node tools/gutenberg/copy.js",
    158159                "gutenberg:verify": "node tools/gutenberg/utils.js",
  • trunk/tests/phpstan/README.md

    r65265 r65372  
    5151- In a comment on the pull request, which lists every error in a table along with the commit it was first reported for. The comment is updated as new commits are pushed, moving each error that a commit resolves into a collapsed list of resolved errors, which also names that commit. Once all of the errors are resolved, the comment is marked as resolved and collapsed.
    5252
    53 Some errors cannot be ignored, and so cannot be reported as warnings either. These fail the check. The most common is an [out-of-date baseline](#regenerating-the-baselines), which is reported when an error recorded in a baseline was fixed without the baseline being regenerated. Such an error is reported against a file rather than a line in it, so its annotation does not appear alongside the changes, and it would easily go unnoticed if the check passed.
     53Some errors cannot be ignored, and so cannot be reported as warnings either. These fail the check. The most common is an [out-of-date baseline](#removing-stale-entries-with---fix), which is reported when an error recorded in a baseline was fixed without the baseline being updated. Such an error is reported against a file rather than a line in it, so its annotation does not appear alongside the changes, and it would easily go unnoticed if the check passed.
    5454
    5555## The PHPStan configuration
    … …  
    217217Both the path and the count matter. A new occurrence of an already baselined error does not match the entry, even in a file that is already listed, and is reported as a new error. That is the point of recording them this way: the baselines describe exactly what exists today, so nothing new slips in behind them.
    218218
    219 The consequence is that **fixing a baselined error means regenerating its baseline as part of the same change**, because the count no longer matches. A count that no longer matches is reported as an `ignore.count` error, which PHPStan does not allow to be ignored or baselined.
     219The consequence is that **fixing a baselined error means updating its baseline as part of the same change**, because the count no longer matches. A count that no longer matches is reported as an `ignore.count` error, and an entry that matches nothing at all as an `ignore.unmatched` error, neither of which PHPStan allows to be ignored or baselined. Either [remove the stale entries with `--fix`](#removing-stale-entries-with---fix), or [regenerate the baselines](#regenerating-the-baselines).
     220
     221### Removing stale entries with `--fix`
     222
     223PHPStan marks an error it can fix automatically with 🔧, and ends its report by saying how many there are. Stale ignores are among them: a baseline entry whose count is now too high, or that matches nothing at all, and an `@phpstan-ignore` comment that no longer ignores anything. Fix them in place with:
     224
     225```bash
     226npm run typecheck:php:fix
     227```
     228
     229or, outside the Docker environment:
     230
     231```bash
     232composer phpstan:fix
     233```
     234
     235This runs three steps, each only when the one before it succeeds:
     236
     2371. PHPStan with its `--fix` option, which lowers the count of the entry, or removes the entry, leaving the rest of the baseline as it was. It is usually much faster than regenerating, since it reuses the result cache of the run that reported the errors. Unlike regenerating, it also removes an `@phpstan-ignore` comment that no longer matches.
     2382. `composer phpstan:baselines -- --prune`, which deletes any baseline left with no entries, along with its `includes` entry in `phpstan.neon.dist`. Removing the last entry of a baseline leaves the file behind, as `ignoreErrors: []`. Pruning analyzes nothing, so it can also be run on its own.
     2393. The analysis once more, which reuses the result cache and so is quick. PHPStan's `--fix` reports only what it fixed, and succeeds even when other errors remain, so this is what shows the errors that are left.
     240
     241Keep in mind:
     242
     243- **It fails when nothing is fixable.** PHPStan then says `No fixable errors found` and stops the script there, so run `composer phpstan` to see whether any errors remain.
     244- **It always analyzes the whole tree with `phpstan.neon.dist`**, whatever a local `phpstan.neon` says. PHPStan only reports stale ignores when it analyzes every path, and a configuration at another rule level can report a baselined error with a different message, in which case `--fix` would remove an entry that the default configuration still needs.
     245- **It fixes every fixable error the run reports**, not only stale ignores. A few other errors can also be fixed automatically, such as a [useless cast](https://phpstan.org/error-identifiers/cast.useless), so check the diff before committing it.
     246- PHPStan's `--fix-ignored`, run as `composer phpstan -- --fix-ignored`, goes further and also fixes the fixable errors which a baseline currently ignores. That is a way to work through a baseline, but expect larger diffs, and follow it with `composer phpstan:baselines -- --prune`.
     247
     248The feature is marked experimental in PHPStan. See [the PHPStan documentation](https://phpstan.org/user-guide/command-line-usage) for details.
    220249
    221250### Regenerating the baselines
    222251
    223 The baselines are generated, and should not be edited by hand. Regenerate them with:
     252The baselines are generated, and should not be edited by hand, other than by `--fix` as described [above](#removing-stale-entries-with---fix). Regenerate them with:
    224253
    225254```bash
    … …  
    242271npm run typecheck:php:baselines -- --combined
    243272
     273# without analyzing anything, delete the baselines that list no entries:
     274npm run typecheck:php:baselines -- --prune
     275
    244276# the remaining options:
    245277npm run typecheck:php:baselines -- --help
    … …  
    253285composer phpstan:baselines -- --identifier=variable.undefined
    254286composer phpstan:baselines -- --combined
     287composer phpstan:baselines -- --prune
    255288composer phpstan:baselines -- --help
    256289```
  • trunk/tests/phpstan/generate-baselines.php

    r63460 r65372  
    1919 * edit of the configuration.
    2020 *
     21 * With `--prune`, nothing is analyzed: the baselines that no longer list any
     22 * entries are deleted, and the list in the configuration is updated to match. That
     23 * completes running PHPStan with `--fix`, which removes the entries of fixed
     24 * errors in place but leaves a baseline it has emptied behind, and so
     25 * `composer phpstan:fix` runs it with `--prune` after `--fix`.
     26 *
    2127 * The intermediate baseline is generated in PHPStan's PHP format and read back
    2228 * with `require`, so the entries arrive as an array. Nothing has to parse, or
    … …  
    3036 *     composer phpstan:baselines -- --identifier=isset.variable --identifier=empty.variable
    3137 *     composer phpstan:baselines -- --combined > all-errors.neon
     38 *     composer phpstan:baselines -- --prune
    3239 *
    3340 * @package WordPress
    … …  
    7784$only_identifiers = array();
    7885$combined         = false;
     86$prune            = false;
    7987
    8088foreach ( $args as $arg ) {
    … …  
    8694        if ( '--combined' === $arg ) {
    8795                $combined = true;
     96                continue;
     97        }
     98
     99        if ( '--prune' === $arg ) {
     100                $prune = true;
    88101                continue;
    89102        }
    … …  
    124137        fwrite( STDERR, "Configuration not found: $config_option\n" );
    125138        exit( 1 );
     139}
     140
     141/*
     142 * Pruning needs no analysis. Running PHPStan with `--fix` lowers the counts of the
     143 * entries whose errors were fixed and removes those that no longer match, but when
     144 * it removes the last entry of a baseline it leaves the file behind, as
     145 * `ignoreErrors: []`. That file suppresses nothing, so all that remains is to
     146 * delete it and drop it from the configuration's `includes`, which is known from
     147 * the file alone.
     148 */
     149if ( $prune ) {
     150        if ( $combined ) {
     151                fwrite( STDERR, "--prune and --combined cannot be used together.\n" );
     152                exit( 1 );
     153        }
     154
     155        $pruned = 0;
     156
     157        foreach ( find_baselines( $output_dir ) as $file ) {
     158                $identifier = basename( $file, '.neon' );
     159
     160                if ( $only_identifiers && ! in_array( $identifier, $only_identifiers, true ) ) {
     161                        continue;
     162                }
     163
     164                $is_empty = is_empty_baseline( read_file( $file ) );
     165
     166                if ( null === $is_empty ) {
     167                        fwrite( STDERR, sprintf( "%s: no `ignoreErrors` list found, left untouched.\n", $output_option . '/' . basename( $file ) ) );
     168                        continue;
     169                }
     170
     171                if ( ! $is_empty ) {
     172                        continue;
     173                }
     174
     175                if ( ! unlink( $file ) ) {
     176                        fwrite( STDERR, sprintf( "%s: could not be deleted.\n", $output_option . '/' . basename( $file ) ) );
     177                        exit( 1 );
     178                }
     179
     180                printf( "%s: no entries remain, file deleted.\n", $output_option . '/' . basename( $file ) );
     181                ++$pruned;
     182        }
     183
     184        if ( 0 === $pruned ) {
     185                echo "No empty baselines found.\n";
     186        }
     187
     188        update_config_includes( $config_path, $config_option, $output_dir );
     189        exit( 0 );
    126190}
    127191
    … …  
    347411                  --combined           Print one combined baseline to stdout instead of writing
    348412                                       per-identifier files. Nothing is written to disk.
     413                  --prune              Without analyzing anything, delete the baselines that no
     414                                       longer list any entries, such as one emptied by running
     415                                       PHPStan with --fix, and update the configuration's
     416                                       `includes` to match. With --identifier, only those
     417                                       baselines are considered.
    349418                  --memory-limit=<v>   Passed through to PHPStan. Default: 2G
    350419                  -h, --help           Show this message.
    … …  
    364433                    composer phpstan:baselines -- --combined
    365434
     435                  Remove the entries of fixed errors, and any baseline left empty:
     436                    composer phpstan:fix
     437
     438                  Remove only the baselines that list no entries:
     439                    composer phpstan:baselines -- --prune
     440
    366441                TEXT;
    367442}
    … …  
    370445 * Reads a file, failing loudly rather than continuing with false.
    371446 *
    372  * @param non-falsy-string $path Absolute path to the file.
     447 * @param non-empty-string $path Absolute path to the file.
    373448 * @return string File contents.
    374449 */
    … …  
    531606
    532607        return $files;
     608}
     609
     610/**
     611 * Determines whether a baseline ignores no errors.
     612 *
     613 * That is the case when its `ignoreErrors` is an empty inline list, as PHPStan's
     614 * `--fix` writes after removing the last entry, or a key with nothing nested under
     615 * it. Only whole-line comments are skipped, since a message pattern such as
     616 * `'#^…$#'` contains `#` and so a trailing comment cannot be told apart from it
     617 * without parsing the NEON.
     618 *
     619 * @param string $contents Baseline file contents.
     620 * @return bool|null Whether the baseline is empty, or null when it has no `ignoreErrors` key.
     621 */
     622function is_empty_baseline( string $contents ): ?bool {
     623        $lines = explode( "\n", $contents );
     624
     625        foreach ( $lines as $i => $line ) {
     626                if ( 1 !== preg_match( '/^(\s*)ignoreErrors:\s*(.*)$/', $line, $matches ) ) {
     627                        continue;
     628                }
     629
     630                $value = trim( $matches[2] );
     631
     632                if ( '' !== $value && '#' !== $value[0] ) {
     633                        return 1 === preg_match( '/^\[\s*\](\s*#.*)?$/', $value );
     634                }
     635
     636                // Nothing after the key, so its value is whatever is nested beneath it.
     637                $indent = strlen( $matches[1] );
     638
     639                foreach ( array_slice( $lines, $i + 1 ) as $next ) {
     640                        $trimmed = trim( $next );
     641
     642                        if ( '' === $trimmed || '#' === $trimmed[0] ) {
     643                                continue;
     644                        }
     645
     646                        $next_indent = strlen( $next ) - strlen( ltrim( $next ) );
     647
     648                        return $next_indent <= $indent;
     649                }
     650
     651                return true;
     652        }
     653
     654        return null;
    533655}
    534656
Note: See TracChangeset for help on using the changeset viewer.