Skip to content

Build/Test Tools: Use szepeviktor/phpstan-wordpress for the extensions core does not need to write itself - #13437

Draft
swissspidy wants to merge 6 commits into
WordPress:trunkfrom
swissspidy:try/use-phpstan-ext
Draft

Build/Test Tools: Use szepeviktor/phpstan-wordpress for the extensions core does not need to write itself#13437
swissspidy wants to merge 6 commits into
WordPress:trunkfrom
swissspidy:try/use-phpstan-ext

Conversation

@swissspidy

@swissspidy swissspidy commented Sep 8, 2026

Copy link
Copy Markdown
Member

Explores the question raised on #13433: rather than adapting extensions from szepeviktor/phpstan-wordpress into tests/phpstan/ one at a time, can core depend on the package and load its extensions directly, without the stubs it bundles?

Short answer: yes, and this branch does it, but only three of its extensions earn a place in core. The rest are either already outdone by core's own versions, or do what a @phpstan-return in the function's docblock does, and a docblock is the better fix because it also types the function for every plugin through the generated stubs. The package has been moving in exactly that direction itself, and the second half of this branch follows it: the types that php-stubs/wordpress-stubs used to apply on top of core's docblocks are now in core's docblocks.

Trac ticket: https://core.trac.wordpress.org/ticket/65817

The commits

  1. Add szepeviktor/phpstan-wordpress to require-dev. Its extension.neon is not included: that file bootstraps php-stubs/wordpress-stubs, a declaration of every core function and class, which would declare the code under analysis a second time. Composer still installs the stubs as a transitive dependency; they are not read.
  2. Correct $accepted_args on three hook registrations that HookCallbackRule found: check_comment_flood_db(), wp_render_block_style_variation_support_styles() (also due in Gutenberg) and twenty_twenty_one_post_classes() declare fewer parameters than they were registered for.
  3. Register the extensions that apply to core in tests/phpstan/base.neon, by class name, with a note on each; the README records what was taken, what was not, and why.
  4. Correct nineteen hook docblocks that HookDocsRule found documenting a type the hook does not pass. Where the value was what the documentation promised all along, the value is corrected ($bulk becomes a bool, disable_captions is filtered on false, the IDs passed to duplicate_comment_id, update_{$meta_type}_meta and delete_term_taxonomy are cast to the documented int). Where the value is right and the documentation was not, the docblock is (wp_audio_shortcode passes an attachment post or null, not a file; {$adjacent}_image_link passes string|false; blog_details receives either a WP_Site or the plain-object copy WP_Site::get_details() makes deliberately).
  5. Ignore the action-return check inline. HookCallbackRule objects to an action callback that returns a value. WordPress discards it, and core registers such functions on actions on purpose 52 times (wp_save_post_revision() on post_updated, redirect_canonical() on template_redirect, …). Each registration carries an inline @phpstan-ignore return.void (…) saying so, rather than a message pattern in phpstan.neon.dist.
  6. Bring the types from wordpress-stubs' functionMap.php into core. See below.

composer run phpstan on the CI configuration is green at every commit. Net effect on the baselines: 118 entries removed, 8 added (7 of those reworded, 1 new).

What is loaded from the package, and what is not

Measured with PHPStan 2.2.13 and phpstan-wordpress 2.0.4 against trunk at r63526. At rule level 10 over the whole src/ tree, trunk reports 29,381 errors; each extension was registered on its own and the report compared. The CI configuration (level 5 plus baselines) was run with the rules as well.

Extension Level 10 Verdict
ShortcodeAttsDynamicFunctionReturnTypeExtension −21 (41 resolved, 20 introduced, all in media.php) Loaded. Types shortcode_atts() from its defaults, the same merge wp_parse_args() performs; a docblock cannot express it. The 20 introduced are precise: the video shortcode multiplies width, typed 360|640|string, and get_posts() is handed an include string where it documents int[].
HookCallbackRule +55 (3 $accepted_args mismatches, 52 action callbacks that return a value) Loaded. The three mismatches were real and are fixed. The 52 are deliberate and carry inline ignores.
HookDocsRule +661 at level 10, +19 at level 5 Loaded. Checks that the type a hook docblock documents accepts the value passed, which is what apply_filters() is typed from. All 19 were genuine documentation defects and are fixed. Only docblocks written at the call are checked; a "This filter is documented in" reference is not.
WpParseArgsDynamicFunctionReturnTypeExtension (szepeviktor/phpstan-wordpress#310, not released yet) −156 (197 resolved, 41 introduced) To load once released. Measured on this branch from the PR's head, it and the version in #13433 resolve and introduce exactly the same errors. The one difference is that #13433 models the query-string form of $args as a plain array where #310 leaves the documented type, which rewords 24 messages from array<mixed> to array and changes nothing else. So once #310 ships, core can register the package's class the way it registers ShortcodeAtts… rather than carry a copy.
EscSqlDynamicFunctionReturnTypeExtension −13 (15 resolved, 2 introduced) Not loaded. #12975 gets the same result with a conditional @phpstan-return.
WpSlashDynamicFunctionReturnTypeExtension −3 (7 resolved, 4 introduced) Not loaded. Core's wp_slash() docblock already carries a conditional type; the extension keeps array shapes through the call, which @phpstan-return ( T is string ? string : T ), as stripslashes_from_strings_only() is written, would also do.
WpParseUrlFunctionDynamicReturnTypeExtension +2 (2 resolved, 4 introduced) Not loaded. Core's docblock already covers it. The extension is right on one point the docblock misses: the component form can return false, and the introduced errors in pluggable.php are real. That is a one-line docblock fix.
SlashitFunctionsDynamicFunctionReturnTypeExtension 0 (8 messages reworded stringnon-falsy-string) Not loaded. trailingslashit() now carries @phpstan-return non-falsy-string from the function map, which is the whole effect.
NormalizeWhitespace…, StripslashesFromStringsOnly… 0 Not loaded. No effect at all; core already types the latter.
WpConstantFetchRule +70 Not loaded. It discourages reading MULTISITE, WP_NETWORK_ADMIN and the like where a function exists, but core is where those functions read them.
HookDocsVisitor, HookDocBlock, ApplyFiltersDynamicFunctionReturnTypeExtension Not loaded. Core's versions were adapted from these and go further: they resolve reference comments, bound a docblock's reach to the node it documents, and fold inherited docblocks into the result cache key.
AssertWpErrorTypeSpecifyingExtension Not loaded. Only relevant once tests/phpunit is analyzed, and then @phpstan-assert on WP_UnitTestCase_Base::assertWPError() itself is the way to express it.

Adopting the function map

wordpress-stubs' functionMap.php names 361 symbols. Each entry was applied to the corresponding core docblock as @phpstan-param, @phpstan-return, @phpstan-template, @phpstan-pure/-impure, @phpstan-assert-if-true, @phpstan-property-read, @phpstan-type or @final tags, formatted the way core already writes them (multi-line conditionals, no leading backslash on global classes).

  • 290 symbols gained tags across 77 files.
  • 71 were skipped: 50 because core already carries an equivalent @phpstan-return, template or hash-notation shape (get_posts(), get_terms(), wp_die(), wp_get_upload_dir(), the_title(), …), 20 because the function lives in a Gutenberg block (block_core_*, render_block_core_*), and check_admin_referer()/check_ajax_referer()/sanitize_post_field() because the map's parameter narrowing contradicts the conditional return core already has.
  • A few entries were wrong for core and are adjusted or dropped, each verified against the code: add_shortcode() callbacks receive a string rather than an array when a shortcode has no attributes; _get_list_table() accepts a WP_Screen as well as a name; get_tag_regex() and wp_get_inline_script_tag() do return ''; wp_update_comment() returns the row count wpdb::update() does, not 0|1; get_html_split_regex(), block_version() and wp_is_uuid() are not pure; wp_widget_rss_form()'s error is string|false; WP_Dependencies::query()'s template was not referenced by a parameter; and the WP_REST_Request generics report every $request['key'] = … in core as an error, so they are not adopted (that one needs rework upstream before it can be carried here).
  • Five calls the new types exposed are corrected: wp_upload_bits() and add_option() are given the value their deprecated parameter documents, iframe_header() is no longer given a deprecated argument, switch_to_blog() is given WP_Site::$blog_id as an int, and get_term_to_edit() documents the WP_Term it returns rather than int|string.
  • Effect on the baselines: 53 entries resolved outright, including every parameter.defaultValue and method.nonObject error, at the cost of one new entry, a WP_Term_Query::populate_terms() assignment on what get_term() may return.

With these in core, the corresponding entries in wordpress-stubs' map become redundant once the stubs are regenerated from a release carrying them, and a plugin analyzed with the stubs sees the same types core's own analysis does.

Two things worth knowing

The package's branches. phpstan-wordpress develops on two lines. Its 2.x branch (v2.0.4, PHPStan ^2.0) is the one core can depend on. Its master branch is the PHPStan 1.x line, and that is where szepeviktor/phpstan-wordpress#309, the wp_parse_args() extension, was first merged; szepeviktor/phpstan-wordpress#310 ports it to 2.x, and the measurement above shows it does for core exactly what #13433's version does.

Where the package's other extensions went. The 1.x line had extensions for get_post(), get_terms(), current_time(), wp_die(), is_wp_error() and many more. The 2.x branch removed them in favor of the function map, which this branch in turn moves into core.

What this suggests for upstream

  1. HookCallbackRule reports under PHPStan's own identifiers (arguments.count, return.void, return.missing), and HookDocsRule under parameter.phpDocType. Core's baselines are split by identifier, so these share files with PHPStan's own errors. Identifiers of its own, as WpConstantFetchRule has, would fix that; a parameter to disable the action-return check would be nicer still.
  2. HookDocBlock could resolve "This filter is documented in" reference comments the way core's does. Core's implementation is in tests/phpstan/HookDocBlock.php.
  3. The WP_REST_Request generics in the function map do not survive contact with code that assigns through ArrayAccess.

Follow-ups in core

Use of AI Tools

AI assistance: Yes
Tool(s): Claude Code
Model(s): Claude Fable 5.1
Used for: The survey of both branches of phpstan-wordpress and of the wordpress-stubs function map, the per-extension measurements above, the script that applied the function map to core's docblocks and the review of its output, the hook docblock fixes, the configuration and README changes, and drafting this description. Directed and reviewed by me.
Session transcript: https://claude.ai/code/session_013QJ9RphuttTR7cS5G4mU21


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.

🤖 Generated with Claude Code

https://claude.ai/code/session_013QJ9RphuttTR7cS5G4mU21

claude and others added 3 commits September 8, 2026 08:55
…dependency.

Pulls in the PHPStan extensions maintained for the WordPress ecosystem so that core can register the ones that apply to it, rather than carrying its own copies. Only the package is added here; nothing from it is loaded yet. Its `extension.neon` is deliberately not included, since that bootstraps the `php-stubs/wordpress-stubs` package, which describes the very code core analyzes.

Co-authored-by: Pascal Birchler <pascal.birchler@gmail.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013QJ9RphuttTR7cS5G4mU21
`check_comment_flood_db()`, `wp_render_block_style_variation_support_styles()` and `twenty_twenty_one_post_classes()` declare fewer parameters than the `$accepted_args` they are registered with, so the extra arguments were passed and discarded. Registering them for the arguments they take is what szepeviktor/phpstan-wordpress's `HookCallbackRule` asks for, and leaves nothing for it to report once it is registered in the next commit.

The block support lives in Gutenberg as well, where the same change is due.

Co-authored-by: Pascal Birchler <pascal.birchler@gmail.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013QJ9RphuttTR7cS5G4mU21
…y to core.

Loads three services from szepeviktor/phpstan-wordpress in `base.neon`, chosen by measuring each of the package's extensions against `src/` at rule level 10 and at the level CI enforces:

* `ShortcodeAttsDynamicFunctionReturnTypeExtension`, which types `shortcode_atts()` from the defaults passed to it. A docblock cannot express that merge.
* `HookCallbackRule`, for its check that `$accepted_args` agrees with the callback's signature. Its objection to an action callback that returns a value is ignored in `phpstan.neon.dist`, with the reason recorded there: core registers such functions on actions deliberately, and WordPress discards the value.
* `HookDocsRule`, for its check that the type a hook docblock documents accepts the value the hook passes. That documented type is what `apply_filters()` is typed from.

The package's other extensions are not loaded. Its hook docblock resolver, visitor and `apply_filters()` extension are what core's own were adapted from, and core's resolve the "This filter is documented in" reference comments. Its remaining return type extensions each do what a conditional `@phpstan-return` does, which core already carries for `wp_parse_url()`, `wp_slash()` and `stripslashes_from_strings_only()`, and loading them changed nothing measurable there. The README records the disposition of every extension and why.

The baselines gain the twenty hook docblocks whose documented type does not accept the value passed, and one `get_posts()` call that the shortcode attributes now show is handed a string where it expects an array of IDs.

Co-authored-by: Pascal Birchler <pascal.birchler@gmail.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013QJ9RphuttTR7cS5G4mU21
@github-actions

github-actions Bot commented Sep 8, 2026

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.

claude and others added 3 commits September 8, 2026 09:57
…accept the value passed.

szepeviktor/phpstan-wordpress's `HookDocsRule`, registered in `base.neon`, reports a hook docblock whose `@param` type does not accept the value the hook is fired with. That documented type is what `apply_filters()` is typed from, so each one misled every caller of the filter as well as every callback written against the documentation.

Where the value was the one the documentation promised all along, the value is corrected: `$bulk` in the Quick Edit filters is a bool rather than the loop counter, `disable_captions` is filtered on `false` rather than an empty string, and the IDs handed to `duplicate_comment_id`, `update_{$meta_type}_meta` and `delete_term_taxonomy` are cast to the documented int rather than passed as the strings the database returns. Where the value is right and the documentation was not, the docblock is corrected: `wp_audio_shortcode` and `wp_video_shortcode` pass an attachment post or null rather than a file, `{$adjacent}_image_link` passes `string|false`, `dashboard_secondary_items` an int, and `blog_details` receives either a `WP_Site` or the plain-object copy that `WP_Site::get_details()` makes deliberately.

Co-authored-by: Pascal Birchler <pascal.birchler@gmail.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013QJ9RphuttTR7cS5G4mU21
…inline.

`HookCallbackRule` objects to an action callback whose return type is not void. WordPress discards an action callback's return value, and core registers functions that happen to return one on actions as a matter of course, so the objection is not actionable here. Rather than matching the message away in `phpstan.neon.dist`, each of the fifty-two registrations now carries an inline `@phpstan-ignore` saying why, which keeps the decision next to the code it is about and lets a new registration be judged on its own.

Co-authored-by: Pascal Birchler <pascal.birchler@gmail.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013QJ9RphuttTR7cS5G4mU21
…to core.

php-stubs/wordpress-stubs, from which every plugin's static analysis takes its view of core, applies a map of PHPStan types on top of core's docblocks when it generates the stubs: conditional return types, narrowed parameters, templates, purity and a few class-level annotations. szepeviktor/phpstan-wordpress dropped its own return type extensions in favor of that map. Each entry is a type core could carry itself, and one carried here reaches core's own analysis, the stubs, and every plugin from one place.

This adds the map's entries to the docblocks of the 290 functions, methods, properties and classes it names, as `@phpstan-` tags. An entry is left out where core already carries a `@phpstan-return` or template for the symbol, where the parameter or return is documented with hash notation that `HashNotationVisitor` derives a shape from, or where the symbol lives in Gutenberg. A few entries were wrong for core and are adjusted or dropped: `add_shortcode()` callbacks receive a string rather than an array when a shortcode has no attributes, `_get_list_table()` accepts a `WP_Screen` as well as a name, `get_tag_regex()` and `wp_get_inline_script_tag()` do return an empty string, `wp_update_comment()` returns the row count `wpdb::update()` does, `size_format()` and `get_tags()` are stated in terms of what core's parameter and `get_terms()` types allow, `get_html_split_regex()`, `block_version()` and `wp_is_uuid()` are not pure, and the `WP_REST_Request` generics report every offset assignment in core as an error and are not adopted.

Five calls the new types show to be passing something other than the documented value are corrected: `wp_upload_bits()` and `add_option()` are given the value their deprecated parameter documents, `iframe_header()` is no longer given a deprecated argument, `switch_to_blog()` is given the site ID as an int, and `get_term_to_edit()` documents the `WP_Term` it returns.

The baselines lose 53 entries the new types resolve, including every `parameter.defaultValue` and `method.nonObject` error, and gain 8: seven reworded, and one `WP_Term_Query::populate_terms()` assignment on what `get_term()` may return that is worth a look of its own.

Co-authored-by: Pascal Birchler <pascal.birchler@gmail.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013QJ9RphuttTR7cS5G4mU21
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.

2 participants