Skip to content

Build/Test Tools: Load the phpstan-wordpress extensions that apply to core - #13437

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

swissspidy wants to merge 5 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 the stubs every plugin reads are generated from core's docblocks. #13530 carries that half: the types php-stubs/wordpress-stubs applies on top of core's docblocks, moved into core itself. The two PRs are independent and can land in either order.

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.

composer run phpstan is green at every commit, and the branch as a whole leaves the baselines where it found them: the rules are registered, what they report is fixed, and the only entry that changes is one argument.type message reworded by the shortcode_atts() extension.

What is loaded from the package, and what is not

Measured with PHPStan 2.2.13 and phpstan-wordpress 2.0.4. 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 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 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. @phpstan-return non-falsy-string on trailingslashit(), which #13530 adds, 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.

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; #310 ports it to 2.x.

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 favour of the stubs' function map, which #13530 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 stubs' 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 Opus 5
Used for: The survey of both branches of phpstan-wordpress and of the wordpress-stubs function map, the per-extension measurements above, 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

@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 5 commits September 15, 2026 09:32
…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
…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
@swissspidy swissspidy changed the title Build/Test Tools: Use szepeviktor/phpstan-wordpress for the extensions core does not need to write itself Build/Test Tools: Load the phpstan-wordpress extensions that apply to core Sep 15, 2026
@lancewillett lancewillett moved this from Backlog to In progress in WordPress Project Build Tooling Sep 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: In progress

Development

Successfully merging this pull request may close these issues.

3 participants