diff --git a/.github/workflows/phpstan-static-analysis.yml b/.github/workflows/phpstan-static-analysis.yml index 62061a83a2688..7d7043ac0c1ec 100644 --- a/.github/workflows/phpstan-static-analysis.yml +++ b/.github/workflows/phpstan-static-analysis.yml @@ -18,7 +18,7 @@ on: # These files configure PHPStan. Changes could affect the outcome. - 'phpstan.neon.dist' - 'tests/phpstan/base.neon' - - 'tests/phpstan/baseline.php' + - 'tests/phpstan/baselines/**' # Confirm any changes to relevant workflow files. - '.github/workflows/phpstan-static-analysis.yml' - '.github/workflows/reusable-phpstan-static-analysis-v1.yml' diff --git a/.github/workflows/reusable-phpstan-static-analysis-v1.yml b/.github/workflows/reusable-phpstan-static-analysis-v1.yml index a69b3b46fdea4..26a14ba8d890f 100644 --- a/.github/workflows/reusable-phpstan-static-analysis-v1.yml +++ b/.github/workflows/reusable-phpstan-static-analysis-v1.yml @@ -32,6 +32,7 @@ jobs: # - Builds WordPress. # - Configures caching for PHPStan static analysis scans. # - Runs PHPStan static analysis (with Pull Request annotations). + # - Checks whether the baselines need regenerating. # - Saves the PHPStan result cache. # - Ensures version-controlled files are not modified or deleted. phpstan: @@ -93,7 +94,99 @@ jobs: - name: Run PHP static analysis tests id: phpstan - run: composer run phpstan -- -vvv --error-format=checkstyle | cs2pr --errors-as-warnings --graceful-warnings + run: | + # The report is written to a file as well as piped to cs2pr, so that the step below + # can look at it. + # + # cs2pr exits successfully so that reported errors annotate the pull request without + # failing the run. A pipeline reports only the status of its last command, so that + # also discards the status of the analysis itself. Recover it from PIPESTATUS. + composer run phpstan -- -vvv --error-format=checkstyle | tee "${RUNNER_TEMP}/phpstan-report.xml" | cs2pr --errors-as-warnings --graceful-warnings + status="${PIPESTATUS[0]}" + + # PHPStan exits 1 when it has errors to report, which is the expected case here and + # is what the annotations are for. Anything higher means it did not finish at all, + # which would otherwise pass silently, since the discarded status was the only sign. + if [ "${status}" -gt 1 ]; then + echo "::error title=PHPStan did not complete::The analysis exited with status ${status}, so the code was not fully checked. This is a failure of the run itself rather than a problem found in the code." + exit "${status}" + fi + + # An ignored error that no longer occurs, or occurs a different number of times, is + # reported under an `ignore.*` identifier. That is not something to fix in the code: the + # usual cause is that the error *was* fixed, leaving a baseline describing a state that + # no longer exists. PHPStan does not allow those reports to be ignored or baselined. + # + # The analysis above is reported as warnings, so this would otherwise surface as a + # passing run carrying an annotation that reads like a complaint about a fix. Call it + # out on its own, and say what to do about it. + # + # Detection is on the identifier rather than the message, which is prose and may be + # reworded in any release. The checkstyle format carries it in the `source` attribute. + - name: Check whether the baselines need regenerating + if: ${{ !cancelled() }} + env: + BASELINES_URL: ${{ github.server_url }}/${{ github.repository }}/tree/${{ github.sha }}/tests/phpstan/baselines + README_URL: ${{ github.server_url }}/${{ github.repository }}/blob/${{ github.sha }}/tests/phpstan/README.md + run: | + # This step runs even when the analysis above it failed, in which case the report may + # never have been written. That failure is reported there, so there is nothing to add + # here beyond staying quiet about a file that was never going to exist. + if [ ! -f "${RUNNER_TEMP}/phpstan-report.xml" ]; then + exit 0 + fi + + # Leave the run alone unless PHPStan reported an ignore error, because everything + # below concerns an ignore configuration that no longer describes the code, and + # nothing else. Those errors are `ignore.unmatched`, where a pattern matched nothing + # at all, and `ignore.count`, where it matched a different number of times than the + # entry records. The `ignore.` prefix is matched rather than those two names so that + # any later addition to the group is caught as well. + if ! grep -q 'source="ignore\.' "${RUNNER_TEMP}/phpstan-report.xml"; then + exit 0 + fi + + # The summary is Markdown, and its code spans and fences are written literally, so the + # heredoc is quoted to keep the backticks out of the shell's hands. The links are + # written in reference style for the same reason: the URLs are the only part needing + # a variable, so defining them afterwards keeps the whole of the prose in here. + # + # A newline renders as a line break rather than a space, so each paragraph is one + # line however long that makes it, and the rendered summary wraps to its own width. + cat >> "${GITHUB_STEP_SUMMARY}" <<'SUMMARY' + ## PHPStan baselines are out of date + + An ignored error no longer occurs, or occurs a different number of times, so PHPStan reported it under an `ignore.unmatched` or `ignore.count` identifier. + + **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. + + Regenerate them and commit the result: + + ```bash + npm run typecheck:php:baselines + ``` + + or, outside the Docker environment: + + ```bash + composer phpstan:baselines + ``` + + 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`. + + 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. + + See [`tests/phpstan/README.md`][readme] for details. + + SUMMARY + + { + echo "[baselines]: ${BASELINES_URL}" + echo "[readme]: ${README_URL}" + } >> "${GITHUB_STEP_SUMMARY}" + + 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}" + exit 1 - name: "Save result cache" uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 diff --git a/composer.json b/composer.json index 1bff1b4d62dd7..a04cdedd18d84 100644 --- a/composer.json +++ b/composer.json @@ -68,6 +68,7 @@ }, "scripts": { "phpstan": "@php ./vendor/bin/phpstan analyse --memory-limit=2G", + "phpstan:baselines": [ "Composer\\Config::disableProcessTimeout", "@php ./tests/phpstan/generate-baselines.php" ], "compat": "@php ./vendor/squizlabs/php_codesniffer/bin/phpcs --standard=phpcompat.xml.dist --report=summary,source", "format": "@php ./vendor/squizlabs/php_codesniffer/bin/phpcbf --report=summary,source", "lint": "@php ./vendor/squizlabs/php_codesniffer/bin/phpcs --report=summary,source", diff --git a/package.json b/package.json index d854406d50d7e..5264d752b8e4e 100644 --- a/package.json +++ b/package.json @@ -141,7 +141,8 @@ "test:coverage": "npm run test:php -- --coverage-html ./coverage/html/ --coverage-php ./coverage/php/report.php --coverage-text=./coverage/text/report.txt", "test:e2e": "wp-scripts test-playwright --config tests/e2e/playwright.config.js", "test:visual": "wp-scripts test-playwright --config tests/visual-regression/playwright.config.js", - "typecheck:php": "node ./tools/local-env/scripts/docker.js run --rm php composer phpstan", + "typecheck:php": "node ./tools/local-env/scripts/docker.js run --rm php composer phpstan --", + "typecheck:php:baselines": "node ./tools/local-env/scripts/docker.js run --rm php composer phpstan:baselines --", "gutenberg:copy": "node tools/gutenberg/copy.js", "gutenberg:verify": "node tools/gutenberg/utils.js", "gutenberg:download": "node tools/gutenberg/download.js && grunt build:gutenberg" diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 93e6c1f6653b3..778b24b78c465 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -14,15 +14,35 @@ includes: # new strict rules. - vendor/phpstan/phpstan-phpunit/extension.neon - # The baseline file includes preexisting errors in the codebase that should be ignored. + # Preexisting errors that should be ignored, one baseline per error identifier + # so that the remaining work on each is visible as a single shrinking file. + # Each is meant to reach zero and be deleted, taking its line below with it. # https://phpstan.org/user-guide/baseline - - tests/phpstan/baseline.php + # + # Regenerate with `composer phpstan:baselines`, which rewrites both the files + # and the list between the markers. Do not edit that list by hand. + # phpstan:baselines start + - tests/phpstan/baselines/empty.variable.neon + - tests/phpstan/baselines/isset.variable.neon + - tests/phpstan/baselines/variable.undefined.neon + # phpstan:baselines end parameters: # https://phpstan.org/user-guide/rule-levels - level: 0 + level: 1 reportUnmatchedIgnoredErrors: true + # The following ignored errors are not intended to be fixed, as distinct from the baselines + # included above. + # + # A baseline records work still to be done. Every entry in one is in scope to be fixed, and + # each file is meant to reach zero and then be deleted. An entry here is the opposite: a + # decision that the code is right as written and the report is not actionable, whether + # because PHPStan cannot see what makes the code safe, or because satisfying it would mean + # changing code that has no other reason to change. + # + # So prefer fixing an error, and baseline it when it cannot be fixed yet. Add it here only + # when it should never be fixed, and say why. ignoreErrors: # Level 0: - # Inner functions aren't supported by PHPStan. @@ -40,6 +60,7 @@ parameters: identifier: function.inner path: src/wp-includes/canonical.php count: 1 + # Level 2: # ValueError is PHP 8.0+; core throws it conditionally so the docblocks are correct for WP's 7.4+ range, # but bleedingEdge's version-aware check treats the class as non-existent against the PHP 7.4 floor. diff --git a/tests/phpstan/README.md b/tests/phpstan/README.md index 036f4b98432e3..edf96fefdc093 100644 --- a/tests/phpstan/README.md +++ b/tests/phpstan/README.md @@ -39,6 +39,8 @@ composer run phpstan -- src/wp-includes/template.php composer run phpstan -- -vvv --debug ``` +Note the `--` in each of those. Composer needs it in order to pass the flags on to PHPStan rather than reading them as its own, and without it they are discarded silently. The npm script supplies it, which is why only one is needed there. + For available flags, see https://phpstan.org/user-guide/command-line-usage. ## The PHPStan configuration @@ -91,20 +93,70 @@ PHPStan errors can be ignored in the following ways: - Adding the error pattern to the `ignoreErrors` section of the `phpstan.neon.dist` configuration file. This should be used to handle conflicts with WordPress Coding Standards or similar project decisions, or to allowlist legacy code that is not worth refactoring solely to satisfy the tests. -- Adding an error to the "tech debt" baseline. This should be used for code that needs to be addressed eventually - by fixing, refactoring, or ignoring via one of the above methods - but is not worth addressing right now. +- Adding an error to a "tech debt" baseline. This should be used for code that needs to be addressed eventually - by fixing, refactoring, or ignoring via one of the above methods - but is not worth addressing right now. Baselines are a useful triage tool for handling PHPStan errors in legacy code, as they allow us to enforce stricter code quality checks on new code, while gradually chipping away at the existing issues over time. **Avoid adding PHPStan errors from new code whenever possible, and use baselines as a last resort.** - The baseline file is located at `tests/phpstan/baseline.php` and generated by running PHPStan with the `--generate-baseline` flag: +### How the baselines are organized + +The baselines live in [`baselines/`](baselines), one file per error identifier, such as `variable.undefined.neon`. Splitting them this way keeps each kind of error visible as a single file that should shrink to nothing and then be deleted, rather than as part of one large file in which every kind is mixed together. + +Every entry is scoped to the file the error occurs in and carries an exact occurrence count: + +```neon +- + message: '#^Variable \$wpdb might not be defined\.$#' + identifier: variable.undefined + count: 4 + path: ../../../src/wp-trackback.php +``` + +Both 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. + +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. + +### Regenerating the baselines + +The baselines are generated, and should not be edited by hand. Regenerate them with: + +```bash +npm run typecheck:php:baselines +``` + +which will run the generator in the Docker container. + +As with the analysis itself, flags are passed by adding `--` followed by the flags themselves: + +```bash +# a single identifier: +npm run typecheck:php:baselines -- --identifier=variable.undefined + +# several, either comma separated or by repeating the option: +npm run typecheck:php:baselines -- --identifier=variable.undefined,isset.variable +npm run typecheck:php:baselines -- --identifier=isset.variable --identifier=empty.variable + +# print every error as one baseline, writing nothing: +npm run typecheck:php:baselines -- --combined + +# the remaining options: +npm run typecheck:php:baselines -- --help +``` + +If you are not using the Docker environment, you can run the generator via Composer directly: + +```bash +composer phpstan:baselines + +composer phpstan:baselines -- --identifier=variable.undefined +composer phpstan:baselines -- --combined +composer phpstan:baselines -- --help +``` - ```bash - npm run typecheck:php -- --generate-baseline=tests/phpstan/baseline.php +Note the `--` in each of those. Composer needs it in order to pass the flags on to the script rather than reading them as its own, and without it they are discarded silently, so `composer phpstan:baselines --identifier=variable.undefined` regenerates every baseline rather than that one. The npm script supplies it, which is why only one is needed there. - # or, with Composer directly: - composer run phpstan -- --generate-baseline=tests/phpstan/baseline.php - ``` +A run also deletes any baseline whose identifier no longer reports anything, and rewrites the list of them between the `# phpstan:baselines` markers in the `includes` of [`phpstan.neon.dist`](../../phpstan.neon.dist) to match. Adding a newly split out baseline, and retiring one that has reached zero, therefore need no edit of the configuration. - This will regenerate the baseline file with any new errors added to the existing ones. You can then commit the updated baseline file. +PHPStan's own `--generate-baseline` is deliberately not used directly. It captures every error a run reports, with no way to restrict it to one identifier, so it cannot refresh a single baseline without sweeping every other kind of error into it. ## Performance and troubleshooting diff --git a/tests/phpstan/base.neon b/tests/phpstan/base.neon index 71c0fa6ab6cdd..1c416cb3fe643 100644 --- a/tests/phpstan/base.neon +++ b/tests/phpstan/base.neon @@ -75,6 +75,8 @@ parameters: - ALLOW_SUBDIRECTORY_INSTALL - AUTH_SALT - AUTOMATIC_UPDATER_DISABLED + - BACKGROUND_COLOR + - BACKGROUND_IMAGE - COOKIEPATH - CUSTOM_TAGS - DISALLOW_FILE_EDIT @@ -82,8 +84,13 @@ parameters: - EMPTY_TRASH_DAYS - ENFORCE_GZIP - FORCE_SSL_LOGIN + - HEADER_IMAGE + - HEADER_IMAGE_HEIGHT + - HEADER_IMAGE_WIDTH + - HEADER_TEXTCOLOR - MEDIA_TRASH - MULTISITE + - NO_HEADER_TEXT - NOBLOGREDIRECT - SAVEQUERIES - SCRIPT_DEBUG diff --git a/tests/phpstan/baseline.php b/tests/phpstan/baseline.php deleted file mode 100644 index 646cbdbef630c..0000000000000 --- a/tests/phpstan/baseline.php +++ /dev/null @@ -1,3 +0,0 @@ - all-errors.neon + * + * @package WordPress + */ + +namespace WordPress\PHPStan; + +if ( 'cli' !== PHP_SAPI ) { + fwrite( STDERR, "This script must be run from the command line.\n" ); + exit( 1 ); +} + +$repo_root = dirname( __DIR__, 2 ); + +// $argv is only populated when register_argc_argv is on, so read it defensively. +$args = array(); +foreach ( (array) ( $_SERVER['argv'] ?? array() ) as $arg ) { + if ( is_string( $arg ) ) { + $args[] = $arg; + } +} +array_shift( $args ); + +$config_option = 'phpstan.neon.dist'; +$output_option = 'tests/phpstan/baselines'; +$memory_limit = '2G'; +$only_identifiers = array(); +$combined = false; + +foreach ( $args as $arg ) { + if ( '--help' === $arg || '-h' === $arg ) { + fwrite( STDOUT, get_usage() ); + exit( 0 ); + } + + if ( '--combined' === $arg ) { + $combined = true; + continue; + } + + if ( 1 === preg_match( '/^--identifier=(.+)$/', $arg, $matches ) ) { + foreach ( explode( ',', $matches[1] ) as $identifier ) { + $identifier = trim( $identifier ); + if ( '' !== $identifier ) { + $only_identifiers[] = $identifier; + } + } + continue; + } + + if ( 1 === preg_match( '/^--config=(.+)$/', $arg, $matches ) ) { + $config_option = $matches[1]; + continue; + } + + if ( 1 === preg_match( '/^--output-dir=(.+)$/', $arg, $matches ) ) { + $output_option = $matches[1]; + continue; + } + + if ( 1 === preg_match( '/^--memory-limit=(.+)$/', $arg, $matches ) ) { + $memory_limit = $matches[1]; + continue; + } + + fwrite( STDERR, "Unrecognized option: $arg\n\n" . get_usage() ); + exit( 1 ); +} + +$config_path = $repo_root . '/' . ltrim( $config_option, '/' ); +$output_dir = $repo_root . '/' . trim( $output_option, '/' ); + +if ( ! is_file( $config_path ) ) { + fwrite( STDERR, "Configuration not found: $config_option\n" ); + exit( 1 ); +} + +/* + * The temporary configuration has to sit beside the original, because a neon + * file's `includes` entries resolve relative to its own directory. + */ +/* + * Both temporary files sit beside the configuration, and so inside the + * repository, for two separate reasons. + * + * A neon file's `includes` resolve relative to its own directory, so the copy of + * the configuration has to live where the original did. + * + * PHPStan writes a PHP baseline's paths as __DIR__ followed by a relative chain, + * which it can only produce when the baseline shares an ancestry with the files + * it names. Generated somewhere else, the system temporary directory included, + * it emits `__DIR__ . '//absolute/path'` instead, and every path in it then + * resolves to somewhere under that directory rather than to the source file. + */ +$temp_config = dirname( $config_path ) . '/.phpstan-baselines-' . getmypid() . '.neon'; +$temp_baseline = dirname( $config_path ) . '/.phpstan-baselines-' . getmypid() . '.php'; + +register_shutdown_function( + static function () use ( $temp_config, $temp_baseline ): void { + foreach ( array( $temp_config, $temp_baseline ) as $file ) { + if ( is_file( $file ) ) { + unlink( $file ); + } + } + } +); + +file_put_contents( $temp_config, strip_baseline_includes( $config_path, $output_dir ) ); + +/* + * PHPStan reports on stdout, which --combined reserves for the baseline itself, + * so its output is sent to stderr. That keeps it visible on a terminal while + * leaving stdout parseable when it is redirected. + */ +$command = sprintf( + '%s analyse --configuration=%s --generate-baseline=%s --allow-empty-baseline --no-progress --memory-limit=%s 1>&2', + escapeshellarg( $repo_root . '/vendor/bin/phpstan' ), + escapeshellarg( $temp_config ), + escapeshellarg( $temp_baseline ), + escapeshellarg( $memory_limit ) +); + +fwrite( STDERR, "Analyzing with $config_option, existing baselines suppressed...\n" ); + +$exit_code = 0; +passthru( $command, $exit_code ); + +if ( 0 !== $exit_code || ! is_file( $temp_baseline ) ) { + fwrite( STDERR, "PHPStan failed, nothing written.\n" ); + exit( 1 ); +} + +/** + * The entries of each error, grouped by the identifier of the error it suppresses. + * + * @var array, path: non-empty-string}>> $grouped + */ +$grouped = array(); + +foreach ( read_baseline( $temp_baseline ) as $entry ) { + $grouped[ $entry['identifier'] ][] = $entry; +} +ksort( $grouped ); + +if ( $only_identifiers ) { + $grouped = array_intersect_key( $grouped, array_flip( $only_identifiers ) ); +} + +if ( $combined ) { + $all = array(); + foreach ( $grouped as $entries ) { + $all = array_merge( $all, $entries ); + } + echo build_baseline( $all, $output_dir, "# Every identifier, combined.\n" ); + exit( 0 ); +} + +if ( ! is_dir( $output_dir ) && ! mkdir( $output_dir, 0755, true ) ) { + fwrite( STDERR, "Could not create $output_option\n" ); + exit( 1 ); +} + +foreach ( $grouped as $identifier => $entries ) { + file_put_contents( + $output_dir . '/' . $identifier . '.neon', + build_baseline( $entries, $output_dir, build_baseline_header( $identifier, $config_option ) ) + ); + + printf( + "%s: %d entries, %d errors\n", + $output_option . '/' . $identifier . '.neon', + count( $entries ), + count_errors( $entries ) + ); +} + +/* + * An identifier that reports nothing has been driven to zero, so retire its file + * rather than leaving a stale one behind whose entries would then be reported as + * unmatched ignores. + * + * A run restricted to particular identifiers only knows about those, so it may + * only retire those. A full run has seen everything and may retire any file that + * no longer corresponds to a reported identifier. + */ +$retired = $only_identifiers; + +if ( ! $only_identifiers ) { + foreach ( find_baselines( $output_dir ) as $file ) { + $retired[] = basename( $file, '.neon' ); + } +} + +foreach ( $retired as $identifier ) { + if ( isset( $grouped[ $identifier ] ) ) { + continue; + } + + $file = $output_dir . '/' . $identifier . '.neon'; + if ( is_file( $file ) && unlink( $file ) ) { + printf( "%s: no errors remain, file deleted.\n", $output_option . '/' . $identifier . '.neon' ); + } else { + printf( "%s: no errors reported.\n", $identifier ); + } +} + +update_config_includes( $config_path, $config_option, $output_dir ); + +/** + * Returns the usage message. + * + * @return non-falsy-string Usage message. + */ +function get_usage(): string { + return <<<'TEXT' + Generates PHPStan baselines split by error identifier. + + Writes one baseline per identifier, retires any whose identifier no longer + reports anything, and rewrites the list of them between the + `# phpstan:baselines` markers in the configuration's `includes`, so that + neither addition nor removal has to be done by hand. + + Usage: + composer phpstan:baselines [-- ] + + Options: + --identifier= Only write the baseline for this identifier. Repeatable, + or comma separated. When an identifier is named and the + analysis reports none of it, its baseline file is deleted + rather than left behind empty. + Default: every identifier reported. + --config= Configuration to analyze with, relative to the repository + root. Default: phpstan.neon.dist + --output-dir= Where the per-identifier baselines are written, relative + to the repository root. Paths inside them are written + relative to this directory. + Default: tests/phpstan/baselines + --combined Print one combined baseline to stdout instead of writing + per-identifier files. Nothing is written to disk. + --memory-limit= Passed through to PHPStan. Default: 2G + -h, --help Show this message. + + Examples: + Refresh every baseline: + composer phpstan:baselines + + Refresh one: + composer phpstan:baselines -- --identifier=variable.undefined + + Refresh several, either comma separated or by repeating the option: + composer phpstan:baselines -- --identifier=variable.undefined,isset.variable + composer phpstan:baselines -- --identifier=isset.variable --identifier=empty.variable + + Inspect everything as one baseline without writing any files: + composer phpstan:baselines -- --combined + + TEXT; +} + +/** + * Reads a file, failing loudly rather than continuing with false. + * + * @param non-falsy-string $path Absolute path to the file. + * @return string File contents. + */ +function read_file( string $path ): string { + $contents = file_get_contents( $path ); + + if ( false === $contents ) { + fwrite( STDERR, "Could not read $path\n" ); + exit( 1 ); + } + + return $contents; +} + +/** + * Reads a baseline generated in PHPStan's PHP format. + * + * The file returns the entries as an array, so it is required rather than + * parsed. Its `path` values are built from __DIR__ and so arrive absolute. + * + * @param non-falsy-string $path Absolute path to the generated baseline. + * @return list, path: non-empty-string}> Baseline entries. + */ +function read_baseline( string $path ): array { + $data = require $path; + + $parameters = is_array( $data ) ? ( $data['parameters'] ?? null ) : null; + $ignore_errors = is_array( $parameters ) ? ( $parameters['ignoreErrors'] ?? null ) : null; + + if ( ! is_array( $ignore_errors ) ) { + fwrite( STDERR, "Unexpected baseline structure in $path\n" ); + exit( 1 ); + } + + $entries = array(); + + foreach ( $ignore_errors as $entry ) { + if ( ! is_array( $entry ) + || ! isset( $entry['message'], $entry['identifier'], $entry['count'], $entry['path'] ) + || ! is_string( $entry['message'] ) + || ! is_string( $entry['identifier'] ) + || ! is_int( $entry['count'] ) + || $entry['count'] < 0 + || ! is_string( $entry['path'] ) + || '' === $entry['path'] + ) { + fwrite( STDERR, "Unexpected baseline entry in $path.\n" ); + exit( 1 ); + } + + /* + * PHPStan attaches an identifier to every error it reports, so an entry + * without a usable one means this is not a baseline that can be split by + * identifier. Skipping it would quietly drop a suppression. + */ + if ( '' === $entry['identifier'] || '0' === $entry['identifier'] ) { + fwrite( STDERR, "Baseline entry in $path has no identifier.\n" ); + exit( 1 ); + } + + $entries[] = array( + 'message' => $entry['message'], + 'identifier' => $entry['identifier'], + 'count' => $entry['count'], + 'path' => $entry['path'], + ); + } + + return $entries; +} + +/** + * Returns the configuration with any `includes` of the baseline directory removed. + * + * Those files suppress the very errors being regenerated, so they have to be out + * of the way for the analysis to report anything. + * + * @param non-falsy-string $config_path Absolute path to the configuration file. + * @param non-falsy-string $output_dir Absolute path to the baseline directory. + * @return string Configuration contents. + */ +function strip_baseline_includes( string $config_path, string $output_dir ): string { + $config_dir = dirname( $config_path ); + $in_block = false; + $kept = array(); + + foreach ( explode( "\n", read_file( $config_path ) ) as $line ) { + if ( 1 === preg_match( '/^includes:/', $line ) ) { + $in_block = true; + $kept[] = $line; + continue; + } + + // A non-indented, non-blank line ends the block. + if ( $in_block && '' !== trim( $line ) && 1 !== preg_match( '/^\s/', $line ) ) { + $in_block = false; + } + + if ( $in_block && 1 === preg_match( '/^\s*-\s*(\S+)\s*$/', $line, $matches ) ) { + $included = $matches[1]; + $absolute = ( '/' === $included[0] ) ? $included : $config_dir . '/' . $included; + + if ( 0 === strpos( normalize_path( $absolute ), normalize_path( $output_dir ) . '/' ) ) { + continue; + } + } + + $kept[] = $line; + } + + return implode( "\n", $kept ); +} + +/** + * Lists the per-identifier baselines present on disk. + * + * @param non-empty-string $output_dir Absolute path to the baseline directory. + * @return list Absolute paths, sorted by name. + */ +function find_baselines( string $output_dir ): array { + $found = glob( $output_dir . '/*.neon' ); + + if ( false === $found ) { + return array(); + } + + sort( $found ); + + $files = array(); + foreach ( $found as $file ) { + if ( '' !== $file ) { + $files[] = $file; + } + } + + return $files; +} + +/** + * Rewrites the managed region of the configuration's `includes` list. + * + * The region is delimited by marker comments, so the hand written entries around + * it are never touched. Where the markers are absent they are appended to the end + * of the `includes` block, which is what happens the first time this is run + * against a configuration. + * + * @param non-falsy-string $config_path Absolute path to the configuration file. + * @param non-empty-string $config_option Configuration path, as passed on the command line. + * @param non-empty-string $output_dir Absolute path to the baseline directory. + */ +function update_config_includes( string $config_path, string $config_option, string $output_dir ): void { + $start_marker = '# phpstan:baselines start'; + $end_marker = '# phpstan:baselines end'; + + $before = read_file( $config_path ); + $lines = explode( "\n", $before ); + + $start = null; + $end = null; + foreach ( $lines as $i => $line ) { + if ( $start_marker === trim( $line ) ) { + $start = $i; + } + if ( $end_marker === trim( $line ) ) { + $end = $i; + } + } + + $region = array( "\t" . $start_marker ); + foreach ( find_baselines( $output_dir ) as $file ) { + $region[] = "\t- " . get_relative_path( dirname( $config_path ), $file ); + } + $region[] = "\t" . $end_marker; + + if ( null !== $start && null !== $end && $start < $end ) { + $updated = array_merge( + array_slice( $lines, 0, $start ), + $region, + array_slice( $lines, $end + 1 ) + ); + } else { + $insert = find_includes_end( $lines ); + + if ( null === $insert ) { + fwrite( STDERR, "No `includes` block found in $config_option, left untouched.\n" ); + return; + } + + $updated = array_merge( + array_slice( $lines, 0, $insert ), + array( '' ), + $region, + array_slice( $lines, $insert ) + ); + } + + $after = implode( "\n", $updated ); + + if ( $before === $after ) { + return; + } + + file_put_contents( $config_path, $after ); + printf( "%s: `includes` updated.\n", $config_option ); +} + +/** + * Finds where the `includes` block ends. + * + * @param list $lines Configuration lines. + * @return int|null Index of the first line after the block, or null when there is none. + */ +function find_includes_end( array $lines ): ?int { + $in_block = false; + $last = null; + + foreach ( $lines as $i => $line ) { + if ( 1 === preg_match( '/^includes:/', $line ) ) { + $in_block = true; + $last = $i; + continue; + } + + if ( ! $in_block || '' === trim( $line ) ) { + continue; + } + + // A non-indented line ends the block. + if ( 1 !== preg_match( '/^\s/', $line ) ) { + break; + } + + $last = $i; + } + + return null === $last ? null : $last + 1; +} + +/** + * Resolves ".." segments in a path without requiring it to exist. + * + * @param non-empty-string $path Path to normalize. + * @return non-falsy-string Normalized path, always absolute. + */ +function normalize_path( string $path ): string { + $parts = array(); + + foreach ( explode( '/', $path ) as $part ) { + if ( '' === $part || '.' === $part ) { + continue; + } + if ( '..' === $part ) { + array_pop( $parts ); + continue; + } + $parts[] = $part; + } + + return '/' . implode( '/', $parts ); +} + +/** + * Expresses one absolute path relative to a directory. + * + * A result of "0" is possible in principle, when the target is a single segment + * named "0" directly inside $from_dir, so this is non-empty rather than non-falsy. + * + * @param non-empty-string $from_dir Directory to express the path relative to. + * @param non-empty-string $to_path Path to express. + * @return non-empty-string Relative path, or "." when the two are the same. + */ +function get_relative_path( string $from_dir, string $to_path ): string { + $from = explode( '/', trim( normalize_path( $from_dir ), '/' ) ); + $to = explode( '/', trim( normalize_path( $to_path ), '/' ) ); + + while ( $from && $to && $from[0] === $to[0] ) { + array_shift( $from ); + array_shift( $to ); + } + + $relative = str_repeat( '../', count( $from ) ) . implode( '/', $to ); + + return '' === $relative ? '.' : $relative; +} + +/** + * Totals the `count` values across a set of entries. + * + * @param list, path: non-empty-string}> $entries Baseline entries. + * @return int<0, max> Total number of errors. + */ +function count_errors( array $entries ): int { + $total = 0; + + foreach ( $entries as $entry ) { + $total += $entry['count']; + } + + return $total; +} + +/** + * Builds a baseline file in PHPStan's NEON format. + * + * The entry layout matches what PHPStan itself writes, so a regenerated file can + * be diffed against one it produced. Paths are rewritten relative to the file's + * own directory, since that is what a NEON `path` resolves against. + * + * @param list, path: non-empty-string}> $entries Baseline entries. + * @param non-empty-string $output_dir Directory the file is written to. + * @param string $header Comment block, or an empty string. + * @return non-falsy-string Baseline file contents. + */ +function build_baseline( array $entries, string $output_dir, string $header ): string { + $contents = ( '' === $header ? '' : $header . "\n" ) . "parameters:\n\tignoreErrors:\n"; + + foreach ( $entries as $entry ) { + $contents .= "\t\t-\n" + . "\t\t\tmessage: " . quote_neon_value( $entry['message'] ) . "\n" + . "\t\t\tidentifier: " . $entry['identifier'] . "\n" + . "\t\t\tcount: " . $entry['count'] . "\n" + . "\t\t\tpath: " . get_relative_path( $output_dir, $entry['path'] ) . "\n"; + } + + return $contents; +} + +/** + * Quotes a value for NEON. + * + * A single quoted NEON string has no escape sequences other than a doubled + * quote, so the backslashes in a message pattern survive as written. This is the + * same quoting PHPStan applies when it generates a baseline itself. + * + * @param string $value Value to quote. + * @return non-falsy-string Quoted value. + */ +function quote_neon_value( string $value ): string { + return "'" . str_replace( "'", "''", $value ) . "'"; +} + +/** + * Builds the header comment for a per-identifier baseline. + * + * @param non-falsy-string $identifier Error identifier, a group followed by a code. + * @param non-empty-string $config Configuration path, as passed on the command line. + * @return non-falsy-string Comment block. + */ +function build_baseline_header( string $identifier, string $config ): string { + return <<