Build/Test Tools: Teach PHPStan WordPress hash notation - #13233
Build/Test Tools: Teach PHPStan WordPress hash notation#13233swissspidy wants to merge 5 commits into
Conversation
Core documents the contents of an array argument with a nested list of `@type` tags. PHPStan reads that hash as free text, so the value stays a plain `array` and nothing inside it is typed, and a shape that should be visible to the analysis has to be written a second time as a `@phpstan-param` or `@phpstan-return` beside the hash that already describes it. `HashNotationVisitor` translates the hash into the array shapes PHPStan understands, so the documentation core already writes serves the reader and the analysis alike. Across `src/wp-admin`, `src/wp-includes` and the bundled themes it derives 394 tags from the 465 hashes it can see. A hash whose translation would be a guess is left alone, so the visitor only ever narrows a type and never contradicts one. A `@phpstan-param` or `@phpstan-return` written by hand always wins; the declared type has to name a bare `array`; the hash has to be well formed; and a parameter taken by reference is skipped, since PHPStan checks those in both directions and a shape there would constrain every caller's variable rather than describe what the function reads. Keys of a `@param` hash are optional and its shape is left open, because the hash lists the keys core reads rather than the only keys a caller may pass. Keys of a `@return` hash are required and its shape is sealed, since they describe a value core itself builds, unless the description marks one `Optional.` Two kinds of hash are left for later. A `@var` hash on a property is inherited by every subclass and has to accept its own default, so a shape there would say more than the hash does. An `object` hash, such as the one on `get_taxonomy_labels()`, would need the docblock to name the class rather than `object`, because PHPStan's object shapes are structural and one derived for a `stdClass` is no longer assignable to a property declared `stdClass`. The translation follows the one php-stubs/wordpress-stubs performs when generating stubs, which is how the WordPress flavor of PHPDoc reaches PHPStan today for plugins and themes. Co-authored-by: Pascal Birchler <pascal.birchler@gmail.com> Co-Authored-By: Claude <noreply@anthropic.com>
With hash notation translated into array shapes, the analysis checks these hashes against the code for the first time, and reports where the two disagree: - `WP_Http::processHeaders()` documents a `newheaders` key. The array it returns has `headers`. - `wp_edit_attachments_query()` documents `post_mime_types` and `avail_post_mime_types` keys. It returns the two values positionally, so they are `$0` and `$1`, as `wpdb::parse_db_host()` already writes them. - `WP_List_Table::get_views_links()` documents `url`, `label` and `current` as keys of `$link_data`. They are keys of each link in it, which its own `@return` line says: "Keys match the `$link_data` input array." Every caller passes a keyed array of links. - `wp_check_php_version()` sets `is_lower_than_future_minimum` on every array it returns, and both callers read it, but it is not documented. - `wpdb::parse_db_host()` documents the port as `string|null`, right below the line of code that casts it with `absint()` and the comment saying "Port cannot be a string; must be null or an integer." - `wp_xmlrpc_server::wp_editPage()` documents its content argument as a string. It is the content struct, which the method writes `post_type` into before passing it on. - `WP_Http::request()` documents `headers` as a `CaseInsensitiveDictionary`. A non-blocking request returns an empty array for it. - `wp_upload_bits()` documents `file`, `url` and `type` alongside `error`. Only `error` is set when the upload fails. Two returns cannot be described by a hash at all, because they are one shape or another rather than one shape with optional keys, so they gain a `@phpstan-return` beside the hash, as `wp_upload_dir()` and `_wp_handle_upload()` already have: `wp_font_dir()`, which returns what `wp_upload_dir()` returns, and `get_avatar_data()`, whose returned array also carries every argument passed to it, as its own description says. What remains are call sites passing an argument the documented shape does not accept, which is recorded in the baselines rather than resolved here. Each is a hash and a caller disagreeing about a key, and worth its own look. Co-authored-by: Pascal Birchler <pascal.birchler@gmail.com> Co-Authored-By: Claude <noreply@anthropic.com>
Test using WordPress PlaygroundThe changes in this pull request can previewed and tested using a WordPress Playground instance. WordPress Playground is an experimental project that creates a full WordPress instance entirely within the browser. Some things to be aware of
For more details about these limitations and more, check out the Limitations page in the WordPress Playground documentation. |
An object shape in PHPStan is structural, so one derived from a bare
`@return object { ... }` describes the members and nothing else. That made
it useless where core actually puts those values: `WP_Taxonomy::$labels`
and `WP_Post_Type::$cap` are declared `stdClass`, which a bare
`object{...}` is not, so assigning one was an error and the hashes had to
be skipped.
Naming the class in the docblock resolves it. A hash on a class produces
an intersection, `stdClass&object{...}`, which is still the class and now
also carries the members, so it is assignable to a property declared
`stdClass` and reads of those members are typed. The three returns that
build one with a cast say `stdClass` rather than `object` to match what
they return: `get_taxonomy_labels()`, `get_post_type_capabilities()` and
`wp_get_scheduled_event()`.
An intersection inside a union is parenthesized, so
`wp_get_scheduled_event()` reads `(stdClass&object{...})|false`.
Co-authored-by: Pascal Birchler <pascal.birchler@gmail.com>
Co-Authored-By: Claude <noreply@anthropic.com>
The docblock the visitor builds is longer than the one in the file, so the original start line and file position no longer describe where its text lives. `GlobalDocBlockVisitor` leaves both off for the same reason; this one was passing them through. No change to what is derived locally, and the analysis stays green. Co-authored-by: Pascal Birchler <pascal.birchler@gmail.com> Co-Authored-By: Claude <noreply@anthropic.com>
The cache CI keeps for PHPStan holds more than the analysis results. PHPStan also stores what it read out of each source file there, the docblocks and signatures it found, keyed by that file's contents and nothing else. A parser node visitor changes what reading a file yields without changing the file, so a cache written before `HashNotationVisitor` existed answers with the docblocks core had before it, and no shape is derived from any hash. That is what the run on this branch was reporting. It restored the cache trunk's run on the base commit wrote, so every file this branch does not touch came back from that cache without a shape, the baselines written against those shapes matched nothing, and PHPStan reported them under `ignore.unmatched` and `ignore.count`. Reproduced by analysing the base commit with an empty `.cache` and then analysing this branch on top of the cache that left behind: the same reports, on the same lines, down to `register_setting` printing trunk's `expects array, string given`. With `.cache` cleared the branch is green, which is why it looked green locally. Keying the cache on `phpstan.neon.dist` and the sources in `tests/phpstan` keeps a run from restoring a cache that predates either. The baselines are left out of the key: they only decide which reported errors are ignored, PHPStan invalidates the results cache on a configuration change by itself, and including them would discard the whole cache every time one is regenerated. Nothing keys the cache for a local run, so `tests/phpstan/README.md` says to clear it by hand after changing anything in that directory. Co-authored-by: Pascal Birchler <pascal.birchler@gmail.com> Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Are1pyfSoWBPabAmc4vPP1
|
The following accounts have interacted with this PR and/or linked issues. I will continue to update these lists as activity occurs. You can also manually ask me to refresh this list by adding the Unlinked AccountsThe following contributors have not linked their GitHub and WordPress.org accounts: @claude. Contributors, please read how to link your accounts to ensure your work is properly credited in WordPress releases. Core Committers: Use this line as a base for the props when committing in SVN: To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook. |
Follows up on the suggestion in #13220: rather than writing PHPStan types beside the hashes that already describe the same shapes, teach PHPStan to read the hashes.
tests/phpstan/HashNotationVisitor.phpis a parser node visitor, in the same shape as theGlobalDocBlockVisitoralready in that directory. It reads hash notation from@paramand@returntags and appends the equivalent shape:becomes, to PHPStan:
Nothing is written to disk; the docblock is rewritten in the AST, so the source keeps only the hash. Across
src/wp-admin,src/wp-includesand the bundled themes it derives 383 tags in 137 files, from the 465 hashes it can see that do not already carry a hand-written one.The translation is the one php-stubs/wordpress-stubs performs when generating stubs, which is how the WordPress flavor of PHPDoc reaches PHPStan today for plugins and themes. Doing it here means core's own analysis gets the same types, and that a shape has one source rather than two that can drift apart.
Objects
A hash on an object works too, including the one
get_taxonomy_labels()carries that #13220 documents. The shape is intersected with the class rather than derived bare:PHPStan's object shapes are structural, so a bare
object{…}is not astdClassand could not be assigned toWP_Taxonomy::$labelsorWP_Post_Type::$cap, which are declared as one. Intersecting keeps both: the value stays the class it is documented as, and its members are typed. The three returns that build one with a cast now saystdClassrather thanobject, matching what they return.What it does not translate
A hash whose translation would be a guess is left alone, so the visitor only ever narrows a type and never contradicts one:
@phpstan-paramor@phpstan-returnalways wins. Hash notation cannot express everything a type can — a function returning one shape or another, for instance — so a shape tuned in the source is never overwritten. 35 hashes are covered that way today.arrayorobject, or a class, alone or as one member of a union likestring|array. A type already more specific than the hash, such asarray<string, string|bool>, is left as written.{closed by a}on its own line, every@typecarrying a type and a$name.@varhashes on properties are skipped. A property declaration is inherited by every subclass and has to accept its own default, so a shape there would say more than the hash does.Keys of a
@paramhash are optional at every level, and the shape is left open with a trailing..., because a hash lists the keys core reads rather than the only keys a caller may pass. Keys of a@returnhash are required and its shape is sealed, since they describe a value core builds — unless the description marks oneOptional., which the visitor honors, asWP_Http::request()already writes for$filename.Hashes on hook docblocks — around 170 of them — are not attached to a function, so they are outside what a node visitor sees. The value a filter passes stays typed by the existing hook extensions.
The cache has to know about the extensions
The last commit is not about hash notation, but this pull request is what turned it up.
.cacheholds more than the analysis results. PHPStan also stores what it read out of each source file there — the docblocks and signatures it found — keyed by that file's contents and nothing else. A parser node visitor changes what reading a file yields without changing the file, so a cache written beforeHashNotationVisitorexisted answers with the docblocks core had before it, and no shape is derived from any hash. Nothing fails or warns; the analysis simply runs against types that are no longer derived.That is what the earlier runs on this branch were reporting. CI restored the cache trunk's run had written, so every file this branch does not touch came back without a shape, the baselines written against those shapes matched nothing, and PHPStan reported them under
ignore.unmatchedandignore.count— down toregister_setting()printing trunk'sexpects array, string given. Reproduced by analysing the base commit with an empty.cache, then analysing this branch on top of what that left behind.The results cache alone is not the problem: PHPStan invalidates that itself on a configuration change, and says so under
-vv. What survives is the per-file reflection, which it has no way to know is stale. So the workflow now keys its cache onphpstan.neon.distand the sources intests/phpstan, andtests/phpstan/README.mdsays to clear.cacheby hand after editing anything in that directory, since nothing keys it for a local run.What it found
Turning it on made the analysis check these hashes against the code for the first time. The second commit fixes what it reported:
WP_Http::processHeaders()newheaderskey; the array it returns hasheaderswp_edit_attachments_query()post_mime_typesandavail_post_mime_typeskeys; it returns the two values positionallyWP_List_Table::get_views_links()url,label,currentas keys of$link_data; they are keys of each link in it, which its own@returnline sayswp_check_php_version()is_lower_than_future_minimum, and both callers read it, but it is not documentedwpdb::parse_db_host()string|null, right below theabsint()cast and the comment "Port cannot be a string; must be null or an integer"wp_xmlrpc_server::wp_editPage()post_typeintoWP_Http::request()headersas aCaseInsensitiveDictionary; a non-blocking request returns an empty arraywp_upload_bits()file,urlandtypealongsideerror; onlyerroris set when the upload failswp_font_dir()andget_avatar_data()return one shape or another rather than one shape with optional keys, which a hash cannot say, so each gains a@phpstan-returnbeside its hash the waywp_upload_dir()and_wp_handle_upload()already do.It also surfaced call sites passing an argument the documented shape does not accept. Most of those are a docblock and a caller disagreeing about a key, and are a change of their own, so they are split out into #13235 — which also removes three baseline entries on its own. With that landed, what stays baselined here is only the handful no docblock change reaches, where the array arriving at the call has no statically known keys.
Testing
composer phpstanis green, on CI and locally, from a cleared result cache.Trac ticket: https://core.trac.wordpress.org/ticket/65817
This may want its own ticket rather than sharing 65817, which is about the docblocks themselves — happy to move it.
Use of AI Tools
AI assistance: Yes
Tool(s): Claude Code
Used for: writing the visitor and this description, measuring its effect on the analysis, and drafting the documentation corrections it surfaced.
This Pull Request is for code review only. Please keep all other discussion in the Trac ticket. Do not merge this Pull Request. See GitHub Pull Requests for Code Review in the Core Handbook for more details.