Repository navigation
Tools: Add PHPStan static analysis at rule level 5 - #66693
Merged
Merged
Conversation
|
👋 Thanks for your first Pull Request and for helping build the future of Gutenberg and WordPress, @justlevine! In case you missed it, we'd love to have you join us in our Slack community. If you want to learn more about WordPress development in general, check out the Core Handbook full of helpful information. |
justlevine
commented
Nov 2, 2024
This was referenced Nov 2, 2024
Closed
justlevine
force-pushed
the
tools/phpstan
branch
from
December 28, 2024 14:35
bd2e9c6 to
916c228
Compare
This was referenced Dec 30, 2024
Merged
Merged
Member
|
I've added demo videos to the description showing the different developer experiences when running PHPStan as it relates to the stubs. |
This was referenced Nov 1, 2026
This was referenced Nov 1, 2026
This was referenced Nov 1, 2026
This was referenced Nov 1, 2026
Member
Member
There was a problem hiding this comment.
@manzoorwanijk I've opened #86222 to implement this.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What?
Closes #66598
This adds PHPStan static analysis to Gutenberg, at rule level 5, with a baseline for the existing errors, and runs it in CI. It covers the plugin's own PHP and all the PHP that WordPress core ships from this repository, including the
wp-buildPHP templates.Why?
WordPress core has run PHPStan since Core-61175, and is now at rule level 5. Much of core's PHP comes from Gutenberg, though, and core cannot analyze the most important part of it: the block PHP,
core-fields,core-actions, and the PHP generated from thewp-buildtemplates are copied into core verbatim and excluded from core's analysis, since they are generated there. Gutenberg is the only place this code can be analyzed, because it is the only place it can be fixed.For the PHP in
lib/that is backported to core, analyzing it here catches problems before they reach core, and keeps the two codebases in sync as fixes are made on either side. The PHPStan fixes that preceded this PR (#68386, #68389, #68392, #68396, #68397, #68398, #68399, #68401, #68404, #68405, #68407) found, among others, a bug in core's shadow block support and a fatal error inWP_Theme_JSON::get_feature_declarations_for_node(), both since fixed in core in r64727.How?
This was originally opened by @justlevine with per-level baselines and custom handling for the generated functions. It has been reworked to follow core's setup more closely:
szepeviktor/phpstan-wordpress2 and the WordPress stubs (see below). As in core, these are pinned to exact versions: there is no Composer lock file, so a new release of PHPStan or of the stubs could otherwise change the results and fail CI on every pull request at once, whereas pinned, an upgrade is a deliberate change that regenerates the baseline. The Composer scripts arecomposer phpstanandcomposer phpstan:baselines, and the npm scriptstypecheck:phpandtypecheck:php:baselinesrun them inwp-env, aslint:phpdoes for PHPCS; all four are named as in core. The baretypecheckscript remains the TypeScript check, since it runs in CI and in editor workflows withoutwp-env, the waylintdoes not includelint:php.phpstan.neon.distsets level 5, as core does, with the 226 existing errors in one standardtools/phpstan/baseline.neon.reportUnmatchedIgnoredErrorsis on, so a fixed error has to be removed from the baseline too.composer phpstan:baselinesalways usesphpstan.neon.dist, so a localphpstan.neon, such as one with a higher level, can never end up in the baseline.phpunit/are not analyzed by default, since they do not ship in core, butphpstan/phpstan-phpunit, which core also uses, andphp-stubs/wordpress-tests-stubs, for the WordPress test library thatwp-envprovides at runtime, are installed. A localphpstan.neonthat addsphpunitto the paths can opt in to them, as the coding guidelines show.phpVersioncovers PHP 7.4 through 8.5, so the results do not depend on the PHP version PHPStan runs on, andtreatPhpDocTypesAsCertainisfalse, since PHPDoc types are not enforced at runtime.gutenberg.phpandlib/, plus the PHP inblock-library, the widget blocks,core-fields,core-actions,style-engine,block-serialization-default-parser, andicons.lib/calls manygutenberg_*functions and*_Gutenbergclasses that only exist once the build has renamed them intobuild/. That directory is scanned, so they are known with their real signatures, and the original ignore rule for unknowngutenberg_*functions is no longer needed. This meansnpm run buildhas to run beforecomposer phpstan.php-stubs/wordpress-stubspublishes stubs built from trunk,tools/phpstan/generate-wordpress-stubs.phpgenerates them with its generator, currently from core trunk at r65047. The generator is pinned to the head of Preserve existing phpstan-return tags when applying function map php-stubs/wordpress-stubs#498, without which it adds a second@phpstan-returnto the docblocks that now have their own in core, and the packages that determine its output, such as nikic/php-parser and phpdocumentor/reflection-docblock, are pinned to exact versions, so that a new release of one cannot change the stubs without a commit here. The stubs live intools/phpstan/wordpress-stubs/as a Composer path package namedphp-stubs/wordpress-stubs, which replaces the released package, sinceszepeviktor/phpstan-wordpressloads the stubs from that package's path. The path repository symlinks it, since the stubs are generated after Composer installs the package. The stubs themselves are not committed. At 6.3 MB, every update would carry them as a diff. Instead, the package'scomposer.jsonrecords the source they are generated from, andcomposer phpstangenerates them before analyzing, returning at once when they are up to date. The stub header records a key for the source, the generator, its pinned packages, and the script itself, so a pull that changes any of them generates the stubs again. Generating needs Git, and network access unless the commit is in the wordpress-develop checkout that Gutenberg is in. The script is written in PHP rather than Bash, since every contributor who runs PHPStan now runs it, including on Windows. The generator is set up once in.cache/wordpress-stubs-generator/, in a directory named for its commit and pinned packages, and setups that have not been used for a week are removed. While generating, the script reports the source, the generator, and the result in a few rows, along with the generator's warnings and any declarations it found more than once, of which the stubs keep the first. When the stubs are missing, such as whenvendor/bin/phpstanis run directly, as an IDE does,wordpress-stubs.phpthrows an exception explaining how to generate them. The source is changed with an option ofcomposer phpstan:generate-wordpress-stubs:--commit=<ref>, such astrunk;--pr=<number>, for the head of a wordpress-develop pull request; or--checkout, for the working tree of the wordpress-develop checkout, including uncommitted changes. Stubs generated from uncommitted changes are only used locally, since CI cannot reproduce them, so they have to be generated with--pror--commitbefore pushing. This supports companion pull requests: a Gutenberg change that depends on a wordpress-develop pull request records it as the source, so that the analysis knows what it adds, and switches to--commit=trunkonce the core change is committed. After changing the source, runcomposer updateandcomposer phpstan:baselines, and commit the package'scomposer.json. Scanning the checkout'ssrc/directly was also tried, since PHPStan prefers scanned declarations to the stubs, but the stubs are not a plain copy of core: the generator adds the types from the function map ofwordpress-stubs, so against rawsrc/, 32 baseline entries no longer matched and 2 new errors were reported. Core's global type aliases, which the stubs could not carry, were removed in r64756 for this, and the invalid PHPDoc types in core's AVIF info class, which the generator had to correct, were fixed in r65047. Generating the stubs here is meant to be temporary; see the roadmap below.wp_get_elements_class_name()requires in 7.0 but no longer takes, and themethod_exists()guard for aWP_REST_Comments_Controllermethod added in 7.2. The flip side is that calling something added since 7.0 without a guard is not reported.tools/phpstan/polyfills.stubdeclares the polyfills that WordPress trunk provides for newer PHP functions, such asstr_contains(),array_any(), andclamp(). The stubs leave outcompat.php, and could not include it, since they are executed as a bootstrap file, where a polyfill would redeclare a function the running PHP has.wp-buildtemplates: these contain{{PLACEHOLDER}}tokens, so they are not valid PHP until the build replaces them.tools/phpstan/src/WpBuildTemplateParser.phpwraps the parsers PHPStan uses for analyzed files and for discovering the functions a file declares, and substitutes a representative value for each token first. No substitution spans more than one line, so errors, including syntax errors, are reported on the template lines themselves rather than on each file generated from them. The templates are rendered with a prefix that no built file uses, so that a call in a template resolves to the template's own function rather than to the one built from it inbuild/, which may be out of date.PHP static analysisjob in the unit test workflow reuses thebuild-assetsartifact that the PHP unit test jobs already download, so the build does not run twice. Errors are reported as annotations on the pull request, and the job is part of the status check. Since an annotation only appears in the diff when its line was changed, and GitHub shows at most ten per step, the job summary also lists every error with a link to its line, up to 500, with the errors that belong to no file first, and explains an out-of-date baseline, the usual error with no changed line to attach to: when a baselined error occurs fewer times, it is expected after fixing it, andcomposer phpstan:baselinesresolves it, unless other errors are reported too, which are to be fixed first; when it occurs more times, the pull request likely added an occurrence, which should be fixed rather than baselined. The generated stubs are cached by their recorded source and the script, restored in every run, and saved only on pushes to trunk; on a miss, the job generates them, which takes about 25 seconds. The PHPStan result cache is likewise restored in every run, but only saved on pushes to trunk, since a cache saved for a pull request could only be restored by that pull request. It is keyed on the configuration, dependencies, and generated stubs, but not the baseline, which does not change the analysis. Unlike in core, errors stay errors rather than warnings, since Gutenberg merges on GitHub and relies on its checks to keep new errors out. Zizmor reports every cache in this workflow as open to cache poisoning, including those the PHP unit test and PHPCS jobs already restore, because it runs on pushes to therelease/*branches and so is taken to be a release workflow. Nothing built in it is published, so that finding is ignored, with the reasoning in a comment on thepushtrigger. The version comments on theramsey/composer-installpins are also corrected to4.0.0, the tag the pinned commit has.Follow-ups
This PR analyzes the PHP that ships, in the plugin and in core. The rest of the PHP in the repository could be analyzed in follow-up PRs:
phpunit/, which can already be opted in to locally. At level 5, they add only a few errors to the baseline.packages/e2e-tests/pluginsandpackages/e2e-tests/mu-plugins, whichwp-envloads for the end-to-end tests.test/php/gutenberg-coding-standards, which would need the PHPCS classes invendorto be scanned.wp-envruntime:packages/env/lib/runtime/php/install.phpandrouter.php, which are new in trunk.test/emptythemeandtest/gutenberg-test-themes, andtools/validation/fixtures/theme-compatibility/theme-compatibility.php.test/test-parser.phpinblock-serialization-default-parser, which is excluded on purpose for now, and inblock-serialization-spec-parser.tools/phpstan, including the template parser.The analysis of code for older WordPress versions could also be improved:
@sinceis newer than the plugin'sRequires at least, outside afunction_exists()guard, along with the functions that Gutenberg itself declares, such as those inlib/compat, and report the calls to functions that Gutenberg does not declare. The polyfills intools/phpstan/polyfills.stubwould get@sincetags for it, and methods and classes could be checked the same way, with theirmethod_exists()andclass_exists()guards.Roadmap for the WordPress stubs
Generating the stubs in Gutenberg is a stopgap. The goal is for
php-stubs/wordpress-stubsto publish the stubs Gutenberg needs, so that Gutenberg can require them like any other dependency:@phpstan-returnto the docblocks that now have their own in core. Until it is merged, the generator is pinned to the head of that PR, and then to the release that includes it.wordpress-stubspublish stubs built from core trunk, such as nightly. Each build needs to be pinnable, ideally by a version of its own derived from the SVN revision, like the7.2.0-beta3.65047used here, or at least by commit. A movingdev-branch alone would not do: Gutenberg has no Composer lock file, so a floating build would let any change in core trunk fail the analysis on every pull request at once.tools/phpstan/generate-wordpress-stubs.php, thetools/phpstan/wordpress-stubs/package and its path repository incomposer.json, thephpstan:generate-wordpress-stubsscript and its call incomposer phpstan, and the steps that cache and generate the stubs in CI.tools/phpstan/polyfills.stubstays, since the upstream stubs also leave outcompat.php, as they are executed as a bootstrap file, and so do the inline ignores for WordPress 7.0, since the stubs are still newer than the minimum supported version. Generating stubs from a core pull request, for companion pull requests, would still need the generator, unless the upstream builds cover pull requests too, so a slimmed-down script may be kept for that alone.composer phpstan:generate-wordpress-stubs -- --commit=trunkinstead.Testing Instructions
composer installandnpm run build. Have Git available, and network access unless Gutenberg is in a wordpress-develop checkout that has r65047.composer phpstan, and confirm that it first generates the WordPress stubs intools/phpstan/wordpress-stubs/, then reports no errors. Run it again, and confirm that it skips generating them.str_contains()in a file inlib/, and confirm that it is reported. Do the same in a template inpackages/wp-build/templates, and confirm that the error is reported on the template line.tools/phpstan/baseline.neon, and confirm that the error it covered is reported again. Then runcomposer phpstan:baselines, and confirm that the entry is restored.PHP static analysisjob passes in CI.Testing Instructions for Keyboard
Not applicable, since there are no UI changes.
Screenshots or screencast
Initial PHPStan run
This shows the worst case, where
wordpress-stubsneeds to be installed and the stubs need to be generated:run-with-stub-install-and-stub-generation.mov
PHPStan run with stale wordpress-stubs
This shows where
wordpress-stubsis installed, but the stubs need to be re-generated:run-with-stub-generation.mov
PHPStan run with with stubs generated
This is the normal case where the stubs are already generated:
run-with-stubs-cached.mov
Use of AI Tools
The rework was done with Claude Code (Claude Opus 5.5): upgrading the configuration, writing the template parser and the polyfill stubs, generating the WordPress stubs from core trunk, and on demand with a PHP generator, adding the CI job, regenerating the baseline, and drafting the commit messages and this description, under the direction and review of @westonruter.
🤖 Generated with Claude Code