Skip to content

Build/Test Tools: Teach PHPStan WordPress hash notation - #13233

Open
swissspidy wants to merge 5 commits into
WordPress:trunkfrom
swissspidy:claude/phpstan-hash-notation-extension
Open

Build/Test Tools: Teach PHPStan WordPress hash notation#13233
swissspidy wants to merge 5 commits into
WordPress:trunkfrom
swissspidy:claude/phpstan-hash-notation-extension

Conversation

@swissspidy

@swissspidy swissspidy commented Aug 22, 2026

Copy link
Copy Markdown
Member

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.php is a parser node visitor, in the same shape as the GlobalDocBlockVisitor already in that directory. It reads hash notation from @param and @return tags and appends the equivalent shape:

/**
 * @param array $args {
 *     Optional. An array of arguments.
 *
 *     @type string $post_type   Post type. Default 'post'.
 *     @type int    $post_author Post author ID.
 * }
 */

becomes, to PHPStan:

@phpstan-param array{post_type?: string, post_author?: int, ...} $args

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-includes and 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-return stdClass&object{name: string, singular_name: string, …}

PHPStan's object shapes are structural, so a bare object{…} is not a stdClass and could not be assigned to WP_Taxonomy::$labels or WP_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 say stdClass rather than object, 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:

  • A hand-written @phpstan-param or @phpstan-return always 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.
  • The declared type has to name something a shape can go on: a bare array or object, or a class, alone or as one member of a union like string|array. A type already more specific than the hash, such as array<string, string|bool>, is left as written.
  • The hash must be well formed: every { closed by a } on its own line, every @type carrying a type and a $name.
  • By-reference parameters are skipped. PHPStan checks those in both directions, so a shape there is a contract every caller's variable has to satisfy before the call, which is not what the hash says.
  • @var hashes 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 @param hash 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 @return hash are required and its shape is sealed, since they describe a value core builds — unless the description marks one Optional., which the visitor honors, as WP_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.

.cache 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. 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.unmatched and ignore.count — down to register_setting() printing trunk's expects 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 on phpstan.neon.dist and the sources in tests/phpstan, and tests/phpstan/README.md says to clear .cache by 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() 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
WP_List_Table::get_views_links() documents url, label, current as keys of $link_data; they are keys of each link in it, which its own @return line says
wp_check_php_version() always sets is_lower_than_future_minimum, and both callers read it, but it is not documented
wpdb::parse_db_host() documents the port as string|null, right below the absint() cast and the comment "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 the method writes post_type into
WP_Http::request() documents headers as a CaseInsensitiveDictionary; a non-blocking request returns an empty array
wp_upload_bits() documents file, url and type alongside error; only error is set when the upload fails

wp_font_dir() and get_avatar_data() return one shape or another rather than one shape with optional keys, which a hash cannot say, so each gains a @phpstan-return beside its hash the way wp_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 phpstan is 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.

claude and others added 2 commits August 22, 2026 08:54
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>
@github-actions

Copy link
Copy Markdown

Test using WordPress Playground

The 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

  • All changes will be lost when closing a tab with a Playground instance.
  • All changes will be lost when refreshing the page.
  • A fresh instance is created each time the link below is clicked.
  • Every time this pull request is updated, a new ZIP file containing all changes is created. If changes are not reflected in the Playground instance,
    it's possible that the most recent build failed, or has not completed. Check the list of workflow runs to be sure.

For more details about these limitations and more, check out the Limitations page in the WordPress Playground documentation.

Test this pull request with WordPress Playground.

@swissspidy
swissspidy requested a review from westonruter August 22, 2026 10:06
claude and others added 2 commits August 22, 2026 10:40
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
@swissspidy
swissspidy marked this pull request as ready for review August 22, 2026 15:10
Copilot AI lite review requested due to automatic review settings August 22, 2026 15:10

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@github-actions

Copy link
Copy Markdown

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 props-bot label.

Unlinked Accounts

The 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:

Props swissspidy.

To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants