Skip to content

Build/Test Tools: Statically verify that hook documentation and arguments stay in sync - #12022

Closed
westonruter wants to merge 55 commits into
WordPress:trunkfrom
westonruter:add/phpstan-apply-filters-return-type-extension
Closed

Build/Test Tools: Statically verify that hook documentation and arguments stay in sync#12022
westonruter wants to merge 55 commits into
WordPress:trunkfrom
westonruter:add/phpstan-apply-filters-return-type-extension

Conversation

@westonruter

@westonruter westonruter commented May 30, 2026

Copy link
Copy Markdown
Member

Teaches PHPStan to read WordPress's existing hook docblocks so that hook documentation, "documented elsewhere" references, and the arguments passed to hooks are verified automatically instead of by manual review — then fixes the issues this surfaces in core.

See the Trac ticket for the full problem framing. In short:

  • The value returned from apply_filters() was typed as mixed, discarding the type already declared in the hook's docblock.
  • /** This filter is documented in <file> */ reference comments were never verified, so they could point at a missing file or a file that no longer documents the hook.
  • Nothing checked that a hook is fired with the number of arguments its docblock describes. Passing too few risks an ArgumentCountError (or a TypeError for a typed parameter) in a callback registered for the documented count; passing too many silently drops the extra argument and misleads the documentation.
  • Undocumented hooks and malformed @param tags went unflagged.

What's in this PR

Tooling (in tests/phpstan/, wired in via base.neon so it applies to both the level‑0 phpstan.neon.dist and the local level‑10 config):

  • A dynamic return-type extension that types apply_filters() from the first documented @param. Adapted from szepeviktor/phpstan-wordpress.
  • Resolution of the "documented elsewhere" convention, including dynamic hook names (e.g. {$type}_template_hierarchy).
  • A rule requiring every hook invocation to be documented (inline or via a valid reference).
  • A rule requiring each invocation to pass the documented number of arguments.

Core fixes surfaced by the tooling:

  • Hooks fired with fewer arguments than documented: edit_terms / edited_terms / edit_term_taxonomy / edited_term_taxonomy / term_id_filter, https_local_ssl_verify, wp_update_nav_menu, wp_creating_autosave, install_plugins_upload, activate_{$plugin}, the network handle_network_bulk_actions-{$screen} screens, and the XML‑RPC pre_option_enable_xmlrpc / option_enable_xmlrpc shim.
  • Hooks fired with more arguments than documented: the_content in do_trackbacks() and the_excerpt in WP_REST_Revisions_Controller.
  • Docblocks the tooling could not associate with their call: the editable_slug filter in get_inline_data() was buried inside a string concatenation, so its reference comment did not attach to the call; and the users_have_additional_content reference comment in confirm_delete_users() used a single-asterisk /* */ block — the only such instance in core, against 715 using the canonical /** */ form — which PhpParser does not expose as a docblock.
  • Documentation fixes: malformed @param tags and a stale reference comment in bundled themes, the_permalink calls now passing the documented get_post() argument, and an accurate array<string, string> shape for wp_get_image_editor_output_format() and its image_editor_output_format filter.
All 40 PHPStan errors identified by the new extensions and fixed in this PR

Line numbers are omitted deliberately, so the table stays accurate as trunk moves.

File (under src/) Hook Issue Rule
wp-admin/includes/class-wp-automatic-updater.php https_local_ssl_verify passes 1, documented 2 ParamCountMismatch
wp-admin/includes/class-wp-site-health.php ×3 https_local_ssl_verify passes 1, documented 2 ParamCountMismatch
wp-admin/includes/file.php https_local_ssl_verify passes 1, documented 2 ParamCountMismatch
wp-admin/includes/ms.php users_have_additional_content no docblock or reference comment DocMissing
wp-admin/includes/nav-menu.php wp_update_nav_menu passes 1, documented 2 ParamCountMismatch
wp-admin/includes/template.php editable_slug no docblock or reference comment DocMissing
wp-admin/network/themes.php (dynamic) passes 3, documented 4 ParamCountMismatch
wp-admin/network/users.php (dynamic) passes 3, documented 4 ParamCountMismatch
wp-admin/plugin-install.php install_plugins_upload passes 0, documented 1 ParamCountMismatch
wp-admin/plugins.php (dynamic) passes 0, documented 1 ParamCountMismatch
wp-content/themes/twentyeleven/author.php twentyeleven_author_bio_avatar_size passes 1, documented 0 ParamCountMismatch
wp-content/themes/twentyeleven/content-single.php twentyeleven_author_bio_avatar_size passes 1, documented 0 ParamCountMismatch
wp-content/themes/twentyeleven/content-status.php twentyeleven_status_avatar passes 1, documented 0 ParamCountMismatch
wp-content/themes/twentyeleven/functions.php the_permalink passes 1, documented 2 ParamCountMismatch
wp-content/themes/twentyeleven/image.php twentyeleven_attachment_size passes 1, documented 0 ParamCountMismatch
wp-content/themes/twentyeleven/inc/widgets.php widget_title reference to wp-includes/default-widgets.php documents no such hook DocReferenceHookMissing
wp-content/themes/twentyeleven/tag.php tag_archive_meta passes 1, documented 0 ParamCountMismatch
wp-content/themes/twentyfifteen/inc/template-tags.php no docblock or reference comment DocMissing
wp-content/themes/twentyfourteen/inc/widgets.php no docblock or reference comment DocMissing
wp-content/themes/twentyten/functions.php twentyten_header_image_height passes 1, documented 0 ParamCountMismatch
wp-content/themes/twentyten/functions.php twentyten_header_image_width passes 1, documented 0 ParamCountMismatch
wp-content/themes/twentythirteen/functions.php no docblock or reference comment DocMissing
wp-content/themes/twentytwentyone/functions.php twentytwentyone_html_classes passes 1, documented 0 ParamCountMismatch
wp-includes/abilities-api/class-wp-ability.php no docblock or reference comment DocMissing
wp-includes/class-wp-xmlrpc-server.php ×2 no docblock or reference comment DocMissing
wp-includes/comment.php the_content passes 2, documented 1 ParamCountMismatch
wp-includes/cron.php https_local_ssl_verify passes 1, documented 2 ParamCountMismatch
wp-includes/rest-api/endpoints/class-wp-rest-autosaves-controller.php wp_creating_autosave passes 1, documented 2 ParamCountMismatch
wp-includes/rest-api/endpoints/class-wp-rest-menus-controller.php wp_update_nav_menu passes 1, documented 2 ParamCountMismatch
wp-includes/rest-api/endpoints/class-wp-rest-revisions-controller.php the_excerpt passes 2, documented 1 ParamCountMismatch
wp-includes/taxonomy.php ×2 edit_term_taxonomy passes 2, documented 3 ParamCountMismatch
wp-includes/taxonomy.php edit_terms passes 2, documented 3 ParamCountMismatch
wp-includes/taxonomy.php ×2 edited_term_taxonomy passes 2, documented 3 ParamCountMismatch
wp-includes/taxonomy.php edited_terms passes 2, documented 3 ParamCountMismatch
wp-includes/taxonomy.php term_id_filter passes 2, documented 3 ParamCountMismatch

Static-analysis impact

Beyond the 40 hook-rule errors above, typing apply_filters() from its docblock changes what PHPStan can see across the whole codebase. To quantify that, PHPStan was run over trunk and over this branch and the two result sets compared.

Method. Both passes ran at level 10 with a cold result cache (the cache directory deleted outright before each pass — clear-result-cache is not sufficient, and a warm cache silently drops findings in exactly the files a branch edits). Errors are matched as a multiset keyed on file + identifier + message, deliberately ignoring line numbers, so the diff is not polluted by line shifts. Baseline trunk at c5690fd7e8, branch at 1a377052b0.

This is a local-config measurement, not a CI one. CI runs phpstan.neon.dist at level 0 with a baseline, where none of these type-level errors are reported in the first place; that config is clean both before and after. The numbers below come from a local level‑10 + bleedingEdge config, which is where filter return types actually change the analysis.

Metric Value
Errors on trunk 32,651
Errors on this branch 29,744
Net change −2,907
Errors fixed 3,133
Errors introduced 226
% of baseline fixed 9.60%
Net reduction 8.90%

Errors fixed (3,133)

Count Identifier
964 argument.type
753 return.type
658 offsetAccess.nonOffsetAccessible
143 binaryOp.invalid
125 encapsedStringPart.nonString
107 echo.nonString
105 property.nonObject
70 foreach.nonIterable
57 offsetAccess.invalidOffset
50 assign.propertyType
25 missingType.return
21 method.nonObject
16 assignOp.invalid
9 missingType.iterableValue
8 new.nonObject
6 cast.string
5 phpDoc.parseError
3 variable.undefined
3 parameterByRef.type
2 offsetAccess.notFound
1 property.nameNotString
1 offsetAccess.nonArray
1 array.invalidKey

The shape of that list is the point: the top three identifiers are all "PHPStan could not reason about this value at all". Every one of those was a filtered value that the docblock already described.

Most-improved files
Count File (under src/)
115 wp-includes/general-template.php
106 wp-includes/class-wp-query.php
103 wp-includes/user.php
98 wp-includes/link-template.php
95 wp-includes/comment-template.php
83 wp-includes/media.php
82 wp-includes/category-template.php
75 wp-includes/post-template.php
69 wp-includes/pluggable.php
68 wp-admin/includes/media.php
59 wp-includes/functions.php
58 wp-includes/formatting.php
56 wp-includes/class-wp-term-query.php
56 wp-includes/post.php
47 wp-admin/includes/file.php
44 wp-includes/blocks.php
43 wp-includes/comment.php
42 wp-includes/build/routes.php
37 wp-includes/ms-functions.php
37 wp-login.php
35 wp-admin/includes/upgrade.php
32 wp-admin/includes/ajax-actions.php
32 wp-includes/class-wp-http.php
32 wp-includes/class-wp.php
30 wp-includes/taxonomy.php

Errors introduced (226)

Count Identifier
93 argument.type
35 return.type
21 property.nonObject
20 property.notFound
15 binaryOp.invalid
9 assign.propertyType
7 offsetAccess.nonOffsetAccessible
7 offsetAccess.invalidOffset
7 return.unusedType
6 offsetAccess.notFound
2 foreach.nonIterable
2 argument.unresolvableType
1 function.alreadyNarrowedType
1 echo.nonString

None of these are regressions from the code changes in this PR. 188 of the 226 are in files this PR never edits — they are only reachable because a filter's return type stopped being mixed. The remaining 38 are in files this PR does edit, but every edit in those files is an argument-count fix or a comment relocation; none changes a type, and each introduced error sits at an unrelated site in the same file. class-wp-site-health.php, for example, only gains a $url argument to https_local_ssl_verify, yet its one new error is a return-shape mismatch in perform_test().

What these really are is a list of places where core's own hook documentation is less precise than the code requires. Three clusters stand out:

  • cron.php (12 errors). pre_get_scheduled_event is documented as returning a bare object, so every ->timestamp, ->schedule, ->hook, ->args and ->interval access on the result is now an undefined property. The docblock should describe a shape, not object.
  • class-wp-query.php (32 errors). the_posts is documented as array<int|stdClass|WP_Post|null>. Taken at face value, $post->ID on its result is invalid — the documented type is looser than what the class actually requires of it.
  • return.unusedType (7 errors). Documented union members that provably cannot occur, e.g. "plugins_api() never returns array", and likewise for get_the_tag_list(), wp_mime_type_icon() and wp_get_code_editor_settings().

Fixing those docblocks is out of scope here — this PR deliberately does not chase the newly-visible errors, only the hook-rule ones. They are listed so the trade-off is explicit.

The 38 introduced errors in files this PR edits

Listed in full because these are the ones where a regression could plausibly hide. Counts in parentheses where an identical error occurs more than once.

  • wp-admin/includes/class-wp-site-health.php — [return.type] WP_Site_Health::perform_test() should return array{label: string, status: 'critical'|'good'|'recommended', badge: array{label: string, color: string}, description: string, actions: string, test: string} but returns array.
  • wp-admin/includes/file.php — [return.type] _wp_handle_upload() should return array{error: non-empty-string}|array{file: non-empty-string, url: non-empty-string, type: non-empty-string} but returns array.
  • wp-admin/includes/file.php — [argument.type] Parameter #41488: Fix handling of backslashes encoded in in partial placement context data #2 $length of fread() expects int<1, max>, int given.
  • wp-admin/includes/nav-menu.php — [argument.type] (×2) Parameter Remove post_ID requirement from parse_embed AJAX call #7 $callback_args of add_meta_box() expects array|null, WP_Post_Type given.
  • wp-admin/includes/template.php — [argument.type] Parameter Update class class-pclzip.php #1 $array_arg of natcasesort() expects an array of values castable to string, array given.
  • wp-content/themes/twentyeleven/functions.php — [argument.type] Parameter #41488: Fix handling of backslashes encoded in in partial placement context data #2 $width of add_image_size() expects int<0, max>, int given.
  • wp-content/themes/twentyeleven/functions.php — [argument.type] Parameter Build Tools: Don't send Slack notifications for Travis CI pull requests #3 $height of add_image_size() expects int<0, max>, int given.
  • wp-content/themes/twentyeleven/functions.php — [return.type] twentyeleven_get_first_url() should return string but returns string|true.
  • wp-content/themes/twentythirteen/functions.php — [argument.type] Parameter #41488: Fix handling of backslashes encoded in in partial placement context data #2 $size of wp_get_attachment_image() expects array<int>|string, array given.
  • wp-includes/class-wp-xmlrpc-server.php — [argument.type] Parameter Update class class-pclzip.php #1 $str of strip_tags() expects string, string|null given.
  • wp-includes/class-wp-xmlrpc-server.php — [argument.type] Parameter #41488: Fix handling of backslashes encoded in in partial placement context data #2 $subject of preg_match() expects string, string|null given.
  • wp-includes/class-wp-xmlrpc-server.php — [argument.type] (×2) Parameter Build Tools: Don't send Slack notifications for Travis CI pull requests #3 $subject of preg_replace() expects array<float|int|string>|string, string|null given.
  • wp-includes/comment.php — [return.type] wp_count_comments() should return stdClass but returns array|stdClass.
  • wp-includes/cron.php — [property.notFound] (×3) Access to an undefined property object::$args.
  • wp-includes/cron.php — [property.notFound] (×2) Access to an undefined property object::$hook.
  • wp-includes/cron.php — [property.notFound] (×2) Access to an undefined property object::$schedule.
  • wp-includes/cron.php — [property.notFound] (×2) Access to an undefined property object::$timestamp.
  • wp-includes/cron.php — [property.notFound] Access to an undefined property object::$interval.
  • wp-includes/cron.php — [argument.unresolvableType] (×2) Parameter Update class class-pclzip.php #1 $array_arg of uksort() contains unresolvable type.
  • wp-includes/media.php — [return.type] image_downsize() should return array|false but returns non-empty-array|true.
  • wp-includes/media.php — [return.type] wp_get_attachment_id3_keys() should return array<string> but returns array.
  • wp-includes/media.php — [return.unusedType] get_post_gallery() never returns string so it can be removed from the return type.
  • wp-includes/media.php — [argument.type] Parameter Update class class-pclzip.php #1 $function of call_user_func() expects callable(): mixed, array{string, 'test'} given.
  • wp-includes/media.php — [argument.type] (×2) Parameter Update class class-pclzip.php #1 $function of call_user_func() expects callable(): mixed, array{string, 'supports_mime_type'} given.
  • wp-includes/media.php — [argument.type] Parameter Build Tools: Don't send Slack notifications for Travis CI pull requests #3 $l10n of wp_localize_script() expects array<string, mixed>, array<array|string> given.
  • wp-includes/media.php — [foreach.nonIterable] Argument of an invalid type array<stdClass>|null supplied for foreach.
  • wp-includes/taxonomy.php — [return.type] get_terms() should return array<int|string|WP_Term>|string|WP_Error but returns array.
  • wp-includes/taxonomy.php — [property.notFound] Access to an undefined property object::$term_id.
  • wp-includes/taxonomy.php — [property.notFound] Access to an undefined property object::$term_taxonomy_id.

Notes for reviewers

  • The argument-count fixes change which arguments callbacks receive, so they're worth reviewing against existing hook consumers. Where a call site had no meaningful value for a documented parameter (e.g. term-count callbacks, network-wide bulk screens), an empty array or 0 is passed to satisfy the signature.
  • The Gutenberg-generated src/wp-includes/build tree is excluded from analysis; the corresponding fix lives in the Gutenberg wp-build page templates and is handled separately.
  • The full analysis is clean: phpstan.neon.dist reports no errors, and no hook-rule errors remain at level 10.

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

Use of AI Tools

AI assistance: Yes
Tool(s): Claude Code
Model(s): Claude Opus 4.8, Claude Opus 5
Used for: Drafting the PHPStan extension and rules, locating and fixing the hook issues across core, and ecosystem impact research. All changes were reviewed and directed by me.

westonruter and others added 17 commits May 29, 2026 10:46
…cks in PHPStan.

Add a PHPStan dynamic return type extension, ported and adapted from
szepeviktor/phpstan-wordpress, that types the return value of apply_filters()
and its variants (apply_filters_deprecated(), apply_filters_ref_array()) from
the `@param` type documented in the docblock preceding the call, rather than
falling back to `mixed`.

Also supports WordPress core's "documented elsewhere" convention: when a hook is
invoked under a `/** This filter is documented in <file> */` reference comment,
the canonical docblock is looked up from the referenced file by matching the
hook name and used as if written at the call site.

Wired into tests/phpstan/base.neon so it applies to both the level-0 dist
configuration and the local level-10 override.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Document the hook invocations that were missing inline documentation, using the
standard "This filter is documented in <file>" reference comments per the
WordPress inline documentation standards:

- the_permalink in Twenty Thirteen and Twenty Fifteen link-format helpers.
- widget_title in the Twenty Fourteen ephemera widget.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- In WP_XMLRPC_Server::set_is_enabled(), the back-compat shim applies the
  pre_option_enable_xmlrpc and option_enable_xmlrpc filters directly. Add the
  "This filter is documented in wp-includes/option.php" references and pass the
  option name (and default value) so the calls match the documented signatures
  of those dynamic option filters.
- Move the wp_pre_execute_ability docblock in WP_Ability so it immediately
  precedes the apply_filters() call, rather than being separated from it by the
  sentinel assignment.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…elsewhere" references in PHPStan.

Build on the apply_filters() return-type extension with hook-documentation
support:

- Add HookDocumentationRule, reporting any apply_filters()/do_action() (and
  variants) invocation that is not preceded by a documenting docblock or a valid
  "This filter is documented in <file>" reference. References whose target file
  is missing, or that do not actually document the hook, are reported too. Calls
  whose hook name carries no literal text (e.g. the generic *_ref_array( $hook,
  $args ) forwarders) are exempt.
- Resolve the "documented elsewhere" convention when typing apply_filters(),
  matching dynamic canonical hook names (e.g. "{$type}_template_hierarchy")
  against the literal name used at the referencing call site.
- Rework HookDocsVisitor to follow the documentation standard: a docblock is
  captured from any node and propagated to the hook call it documents, including
  hooks inside a multi-line condition or used as an array element value, and is
  cleared at statement boundaries.
- Register the rule and exclude the Gutenberg-generated src/wp-includes/build
  tree from analysis.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The the_permalink filter is documented (in wp-includes/link-template.php) as
receiving the permalink and the post, but the Twenty Eleven, Twenty Thirteen,
and Twenty Fifteen link-format helpers fired it with only the permalink. Pass
get_post() as the second argument so callbacks receive the documented post
argument, matching the canonical the_permalink invocation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add the missing parameter name to the `@param` tag documenting several
theme-specific filters (twentyeleven_author_bio_avatar_size,
twentyeleven_status_avatar, twentyeleven_attachment_size, tag_archive_meta,
twentyten_header_image_width, twentyten_header_image_height, and
twentytwentyone_html_classes), which previously read e.g. "@param int The..."
with no `$variable`.

Also correct the Twenty Eleven Ephemera widget's widget_title reference, which
pointed at the obsolete wp-includes/default-widgets.php, to the canonical
wp-includes/widgets/class-wp-widget-pages.php.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…tion.

When updating an existing autosave, WP_REST_Autosaves_Controller fired
wp_creating_autosave with only the autosave array, omitting the $is_update
argument added in 6.4.0. This call path updates an existing autosave (it sets
the existing autosave ID and calls wp_update_post()), mirroring the update
branch in wp_create_post_autosave(), so pass true to match the documented
two-argument signature and give callbacks the expected $is_update value.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The https_local_ssl_verify filter is documented (in class-wp-http-streams.php /
class-wp-http-curl.php) as receiving the SSL-verify value and the request URL
(the $url parameter added in 5.1.0), but several loopback callers fired it with
only the value. Pass the loopback request URL as the second argument so filters
can vary SSL verification per URL, matching the canonical invocations.

Affects the loopback requests in WP_Automatic_Updater, WP_Site_Health
(REST availability, can_perform_loopback, check_for_page_caching), the plugin/
theme editor scrape in wp_edit_theme_plugin_file(), and spawn_cron().

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… updates.

The wp_update_nav_menu action is documented (in wp-includes/nav-menu.php) as
receiving the menu ID and an array of menu data, but the two sites that fire it
after updating only the "auto add pages" option -- wp_nav_menu_update_menu_items()
and WP_REST_Menus_Controller::handle_auto_add() -- passed only the menu ID.
These paths have no menu-data array, so pass an empty array() to satisfy the
documented two-argument signature and avoid an ArgumentCountError for callbacks
registered with two arguments.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… scraping.

The error_scrape handler re-fires the activate_{$plugin} action to reproduce a
fatal that occurred during activation, but passed no arguments. The action is
documented as receiving a bool $network_wide; relying on do_action()'s empty
argument padding meant a callback type-hinting the parameter would receive an
empty string, which fails under strict types (and for an int parameter would
fatal even in coercive mode). Pass false explicitly, matching the single-site
activation this admin screen reproduces.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
GlobalDocBlockVisitor previously injected @var types from @global tags only for
global statements inside a function or method body. WordPress also documents
file-scope globals with an @global docblock on the global statement itself (see
wp-admin/edit.php and other admin entry points), but PHPStan resolved those as
mixed.

Extend the visitor to also read @global tags from the docblock attached directly
to a file-scope global statement, merging them with any tags inherited from an
enclosing function (statement-level tags take precedence). Function-scope
behavior and handwritten @var precedence are unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The install_plugins_{$tab} action is documented as receiving the current page
number, and the canonical invocation passes $paged. The upload-tab form
rendered on non-upload screens fired install_plugins_upload with no arguments,
so pass $paged to match the documented signature.

Document the $tab and $paged globals (populated by
WP_Plugin_Install_List_Table::prepare_items()) with an @global docblock so they
resolve throughout the file.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Several term hooks are documented as receiving an $args array (added in 6.1.0)
as their final argument, but some invocations omitted it:

- In wp_insert_term(), the edit_terms and edited_terms actions fired on the
  empty-slug path are now passed the function's $args.
- In wp_update_term(), the term_id_filter (documented elsewhere in this file)
  is now passed $args, matching its canonical invocation.
- In the _update_post_term_count() and _update_generic_term_count() callbacks,
  the edit_term_taxonomy and edited_term_taxonomy actions are fired while only
  recounting, where no wp_update_term() args exist, so an empty array() is
  passed to satisfy the documented three-argument signature.

This gives callbacks the documented argument and avoids an ArgumentCountError
for callbacks registered with three arguments.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add HookParamCountRule, which reports any apply_filters()/do_action() (and
variant) invocation that passes a different number of arguments than its
documentation describes. WordPress passes a hook's arguments straight through to
its callbacks, so a documented-but-unpassed parameter is a real defect: a
callback registered for the documented argument count triggers an
ArgumentCountError (or a TypeError for a typed parameter) when the hook fires
with fewer arguments.

The provided count is read from the call: positional arguments for the variadic
apply_filters()/do_action(), or the size of the array argument for the
*_ref_array()/*_deprecated() variants, using PHPStan's inferred constant array
size so literals and typed variables are both handled. Calls whose count cannot
be determined statically are skipped.

To support this, HookDocBlock gains getDocumentedParamCount() (which resolves a
hook's documented parameters inline or via a "documented in" reference, without
the return-type extension's fallback to the bare reference comment). Reference
resolution now walks up the directory tree to locate the named file from any
location instead of assuming a wp-includes/wp-admin root, and matches dynamic
hook names (e.g. "{$type}_template_hierarchy") between the referencing site and
the canonical invocation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…n network screens.

The handle_network_bulk_actions-{$screen} filter is documented as receiving a
fourth $site_id argument, which the per-site Network Admin screens (site-themes,
site-users, sites) pass. The network-wide Themes and Users bulk-action screens
omitted it. These screens act across the whole network rather than a single
site, so pass 0 -- the same int "no specific site" value site-themes.php uses
when no id is in the request -- to match the documented four-argument signature.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ent and the_excerpt.

Both the_content and the_excerpt are documented (in wp-includes/post-template.php)
as receiving only the content/excerpt string, but two callers passed an extra
argument:

- do_trackbacks() passed $post->ID to the_content.
- WP_REST_Revisions_Controller::prepare_excerpt_response() passed $post to
  the_excerpt.

Neither argument is part of the documented signature, and callbacks attached to
these filters do not receive it. Drop the extra argument so the invocations match
the documented one-parameter signature. The $post parameter of
prepare_excerpt_response() is left in place as it is part of the method's
established (overridable) signature.

Co-Authored-By: Claude Opus 4.8 (1M context) <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.

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.

Pull request overview

This PR adds PHPStan support for validating WordPress hook documentation against hook invocations, then updates core and bundled theme call sites/docblocks to satisfy the new checks.

Changes:

  • Adds PHPStan hook docblock resolution, return-type inference for filters, documentation validation, and hook argument-count validation.
  • Extends global docblock handling for file-scope globals used by PHPStan.
  • Fixes discovered hook argument mismatches and malformed/missing hook documentation across core and bundled themes.

Reviewed changes

Copilot reviewed 35 out of 35 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
tests/phpstan/HookParamCountRule.php Adds rule comparing provided hook arguments with documented parameters.
tests/phpstan/HookDocumentationRule.php Adds rule requiring hook docs or valid documented-elsewhere references.
tests/phpstan/HookDocsVisitor.php Adds visitor that propagates hook docblocks to nested hook calls.
tests/phpstan/HookDocBlock.php Adds hook docblock/reference resolution and dynamic hook-name matching.
tests/phpstan/GlobalDocBlockVisitor.php Supports file-scope @global annotations on global statements.
tests/phpstan/base.neon Registers new PHPStan services/rules and excludes generated build output.
tests/phpstan/ApplyFiltersDynamicFunctionReturnTypeExtension.php Infers filter return types from documented first hook parameter.
src/wp-includes/taxonomy.php Passes documented taxonomy hook arguments.
src/wp-includes/rest-api/endpoints/class-wp-rest-revisions-controller.php Aligns the_excerpt filter call with documented signature.
src/wp-includes/rest-api/endpoints/class-wp-rest-menus-controller.php Passes documented menu data argument.
src/wp-includes/rest-api/endpoints/class-wp-rest-autosaves-controller.php Passes documented autosave update flag.
src/wp-includes/media.php Fixes PHPDoc array types for image editor output format.
src/wp-includes/cron.php Passes cron URL to https_local_ssl_verify.
src/wp-includes/comment.php Aligns the_content filter call with documented signature.
src/wp-includes/class-wp-xmlrpc-server.php Adds option hook references and documented arguments.
src/wp-includes/abilities-api/class-wp-ability.php Moves sentinel creation before the hook docblock.
src/wp-content/themes/twentytwentyone/functions.php Adds missing hook parameter variable name.
src/wp-content/themes/twentythirteen/functions.php Documents and updates the_permalink filter call.
src/wp-content/themes/twentyten/functions.php Adds missing hook parameter variable names.
src/wp-content/themes/twentyfourteen/inc/widgets.php Adds documented reference for widget_title.
src/wp-content/themes/twentyfifteen/inc/template-tags.php Documents and updates the_permalink filter call.
src/wp-content/themes/twentyeleven/tag.php Adds missing hook parameter variable name.
src/wp-content/themes/twentyeleven/inc/widgets.php Updates stale widget_title documentation reference.
src/wp-content/themes/twentyeleven/image.php Adds missing hook parameter variable name.
src/wp-content/themes/twentyeleven/functions.php Updates the_permalink filter call arguments.
src/wp-content/themes/twentyeleven/content-status.php Adds missing hook parameter variable name.
src/wp-content/themes/twentyeleven/author.php Adds missing hook parameter variable name.
src/wp-admin/plugins.php Passes documented activation hook argument.
src/wp-admin/plugin-install.php Adds file-scope globals and passes paged argument.
src/wp-admin/network/users.php Passes documented network bulk action site ID placeholder.
src/wp-admin/network/themes.php Passes documented network bulk action site ID placeholder.
src/wp-admin/includes/nav-menu.php Passes documented menu data argument.
src/wp-admin/includes/file.php Passes final request URL to SSL verification filter.
src/wp-admin/includes/class-wp-site-health.php Passes request URLs to SSL verification filter.
src/wp-admin/includes/class-wp-automatic-updater.php Passes loopback URL to SSL verification filter.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread tests/phpstan/HookDocsVisitor.php
@westonruter

Copy link
Copy Markdown
Member Author
@szepeviktor

Copy link
Copy Markdown

What an ocean of bugs revealed!

westonruter and others added 2 commits May 30, 2026 17:30
HookDocsVisitor cleared the tracked docblock only at statement boundaries, so a
docblock attached to an array item (or another non-statement expression) leaked
to following sibling items. That could make an undocumented hook in a later
array element appear documented by an earlier element's docblock, a false
negative for the hook documentation and argument-count rules.

Track the docblock on a stack and restore the previous value when the
introducing node is left, so a docblock applies only to that node's descendants
while a statement-level docblock still flows into its nested expressions.

This surfaced one genuinely undocumented-looking call: in get_inline_data() the
editable_slug reference comment sat inside an echo concatenation rather than
before a statement. Hoist the filtered value into a documented $editable_slug
variable before the echo so the reference precedes the call, with identical
output.

Co-Authored-By: Copilot <198982749+Copilot@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@westonruter
westonruter requested a review from Copilot May 31, 2026 01:20
@westonruter
westonruter marked this pull request as ready for review May 31, 2026 01:21
westonruter and others added 7 commits July 29, 2026 19:25
…ine.

The array shapes describing the hook documentation tooling had grown long enough to
run well past a comfortable line length, which made them hard to read: one of them
nested a shape inside a list inside a shape, all on a single line.

Write each key on its own line, and give the matcher shape a name of its own, since it
was spelled out in full in two places.

Formatting only. The names resolve to the same types, as the analysis still enforcing
them confirms.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both discarded a parameter the method is required to accept by the interface it
implements, which has no effect and reads as though something were being cleaned up.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…o it.

A filter's return type comes from the first `@param` its docblock declares, which was
read with a foreach that returned on its first iteration and fell through to the
default when there was nothing to iterate. Say that directly instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both rules describe the node and the scope their `processNode()` receives without
aligning the names and descriptions, unlike every other docblock in the directory.
Nothing enforces it, since `tests/phpstan` is excluded from the coding standards.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
One comment introduced three services at once, and described the last of them: the
node visitor it sat above does not type anything, it attaches each hook's docblock to
the call so the return type extension and both rules can read it, and the resolver
between them went unmentioned.

Give each service its own comment saying what it does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…g was adapted from.

Three of the files adapted work from szepeviktor/phpstan-wordpress and credited it with
a bare URL on the branch, which says nothing about the terms the work came under and
points at a moving target: once upstream changes, the link no longer shows what was
adapted, and there is nothing to diff against to pick up later fixes.

Credit it with `@link` instead, naming the MIT license the project is released under and
pinning the URL to the commit the files were adapted from, so upstream changes since then
can be found by comparing against it.

Follows the form core already uses for adapted work, as in
`WP_Community_Events::get_unsafe_client_ip()`. The fuller notice reproducing the license
text, as `WP_Duotone` carries for colord, is not warranted here: what was taken is small,
and two of the three files have since been substantially rewritten.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The PHPStan documentation covered running the analysis, configuring it, and baselining
errors, but said nothing about the extensions this directory adds. A contributor whose
build failed on `wordpress.hookParamCountMismatch` had nowhere to read what that means.

Describe what the extensions teach PHPStan — the `@global` convention, filter return
types taken from a hook's documentation, and core's "documented elsewhere" convention —
and list each error the hook rules report alongside what it means and how to resolve it.

Also note the two behaviors that otherwise look like surprises: a hook invoked without a
literal name is not required to be documented, and editing a hook docblock other files
inherit discards the result cache.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@westonruter

Copy link
Copy Markdown
Member Author

From Claude Opus 5 (1M context), via Claude Code, at @westonruter's request. Follow-up to my review comment, which still reads as though its findings were open. Every finding in it has now been addressed on the branch, each change directed and reviewed by @westonruter. Commit subjects are given rather than SHAs, since the commits are not pushed as of this comment and may be rebased.

Every finding from the review is addressed

# Finding Resolution
1 Loose dynamic hook patterns satisfied the doc rule Require a specific anchor to resolve a hook doc reference. A pattern anchored only on separators is no longer honored, and among matching patterns the most specifically anchored wins.
2 Cross-file docblock reads bypassed the dependency graph Invalidate cached results when inherited hook docs change. A ResultCacheMetaExtension folds the inheritable hook docblocks, and the tooling's own sources, into the result cache key.
3 An attribute was written to every AST node Record hook docblocks only where they are read. Written on function calls only, and only when a docblock applies: 543,762 nodes carrying it across src/wp-includes/*.php became 2,511, and ~21.5 MB of retained attributes is no longer measurable.
4 Non-hook docblocks counted as hook documentation Require a filter's docblock to document the filtered value. A filter always passes the value being filtered, so a filter documented with no @param is reported as undocumented rather than compared against a count of zero.
5 getParamTags() is keyed by parameter name Count every documented hook parameter, including repeated names. Counted from the parsed docblock nodes, which list every tag.
6 Duplicated resolution flows Resolve a hook's documentation in one place. One getHookDoc() replaces five overlapping methods; each rule resolves once instead of two or three times. Net 67 lines removed.
7 resolveReferencePath() walked to the filesystem root Stop resolving a hook doc reference outside the analyzed tree, then Confine a resolved hook doc reference to the analyzed tree. The walk stops at the WordPress root, and a candidate is canonicalized and accepted only when it resolves inside the tree.

The nits are addressed too: the unset() calls and the foreach that returned on its first iteration are gone, the two rules' @param tags are aligned, every service in base.neon now has a comment describing what it actually is, the adapted files credit szepeviktor/phpstan-wordpress with @link, name its MIT license and pin the upstream commit, and tests/phpstan/README.md now documents the extensions and all five error identifiers they report.

Four things turned out worse than the review described

Each of these was demonstrated rather than reasoned about, and each was a real defect rather than the theoretical one I had written up:

  • Finding 1 was a false negative, not just imprecision. A reference comment naming a hook that taxonomy.php does not document produced no hookDocReferenceHookMissing at all, because "{$taxonomy}_{$field}" compiles to #^.+_.+$# and matched it — so the rule silently accepted a broken reference and then reported an argument count borrowed from an unrelated hook. Whether a bad reference was caught came down to whether the hook name happened to contain an underscore.
  • Finding 5 also hid genuine mismatches. I had described the name collapse as producing false positives. It does, but it also suppresses real errors: a hook documenting two @param tags that share a name and passing one argument matched a collapsed count of one, and was reported as correct.
  • Finding 7 was not merely wasted is_file() calls. With a file present above the tree, resolution reached it and used its docblock to document, type and count the hook — so a broken reference went unreported because something unrelated answered to the same relative path.
  • Finding 2 extended to the tooling itself. PHPStan hashes its configuration but not extension logic, so editing a rule left the results that rule had already produced in the cache. The same metadata extension now covers both.

What is deliberately not changed

  • src/wp-includes/build stays excluded from analysis. The code is generated from Gutenberg rather than authored here, so the fix belongs upstream, as with src/wp-includes/blocks.
  • The return type extension still reads the first entry of getParamTags(). With two @param tags sharing a name, a later tag's type can displace the first. Resolving it properly needs TypeNodeResolver and a NameScope, and finding 5 makes the situation loud rather than silent: duplicates now count as two, so the call is reported.
  • Tests remain a follow-up, for the reasons in the previous comment.
  • PHPStan\Cache\Cache was tried and reverted. Persisting parsed docblocks across runs took a warm run from 1.86 s to 1.28 s, but PHPStan reports both calls as outside its backward compatibility promise, which would have required ignore entries in the shared base.neon. Not worth pinning core tooling to PHPStan internals for 0.6 s.

One cost this introduces

The result cache metadata added for finding 2 costs about 1.2 s per run — roughly 0.25 s to scan the tree for reference comments, and 1 s to parse the 145 files they name. That is noise against a cold run of the full analysis, but it is most of the cost of an otherwise near-instant warm run. The alternatives are worse: hashing file contents rather than parsed docblocks would flush the whole cache whenever a frequently-referenced file like post.php is edited at all.

Verification

Repeated after each change:

  • The CI configuration (phpstan.neon.dist, level 0 with the baseline and reportUnmatchedIgnoredErrors) reports no errors from a cold cache, in an isolated cache directory so a warm cache cannot mask a finding.
  • Level 10 with bleedingEdge over tests/phpstan/ is clean.
  • All 712 reference sites in the analyzed tree still resolve, through the same 19 dynamic patterns and to the same docblocks as before.
  • Four rule fixtures covering missing documentation, both broken-reference forms, _ref_array counting, argument spreads of unknown size, @var above a filter and repeated @param names produce byte-identical output across the refactors.
  • Filter return types were re-checked with PHPStan\dumpType() for inline, inherited, unresolvable, zero-parameter and multi-parameter docblocks, since the rule fixtures do not cover the return type extension.
  • Each finding's fix was confirmed against a reproduction that failed before it and passes after, including a warm-cache versus cold-cache comparison for finding 2.
Copilot AI review requested due to automatic review settings July 30, 2026 03: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.

Pull request overview

Copilot reviewed 8 out of 8 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (2)

tests/phpstan/README.md:65

  • The README describes the reference-comment convention as only "This filter is documented in ", but the tooling and regex also support action references ("This action is documented in "). Updating the wording here avoids misleading users about which reference forms are accepted.
- **Hooks documented elsewhere are resolved.** Core's `/** This filter is documented in <file> */` convention is followed, so a hook documented in another file is analyzed against its canonical docblock. A dynamic canonical name such as `"{$type}_template_hierarchy"` is matched against the literal name used at the referencing site.

tests/phpstan/HookDocBlock.php:256

  • getReferencedHookDocsHash() scans all PHP files under wp-includes, which includes large generated trees that are excluded from PHPStan analysis (e.g. wp-includes/blocks and wp-includes/build). Skipping those paths here avoids reading/parsing large generated files when building the cache meta hash, improving PHPStan performance without affecting reference-comment resolution.
			$iterator = new RecursiveIteratorIterator(
				new RecursiveDirectoryIterator( $path, FilesystemIterator::SKIP_DOTS )
			);

			foreach ( $iterator as $file ) {
westonruter and others added 3 commits July 29, 2026 20:16
…t `@see`.

`@see` refers to a related symbol, while a URL belongs in `@link`, which is what the
three references to the developer handbook's pages for the filter functions are.

The remaining `@see` tags in the directory all name a class or method, and stay as they
are.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reading the hook documentation a reference comment points at parsed the referenced file
with the parser's default error handling, which throws. A single unparseable referenced
file therefore aborted the entire analysis with a bare `Syntax error, unexpected …`
followed by the command's usage text, naming neither the file that could not be parsed
nor the one referring to it. That is reachable while editing — a file is incomplete for
as long as it takes to finish typing it — and whenever a file uses syntax newer than the
PHP version running PHPStan.

Collect the parse errors instead of throwing, and treat a file that yields no statements
as documenting nothing. The reference then resolves to no documentation and is reported
as such, which is a diagnostic naming both files rather than a crash. Where the parser
can recover, the hooks documented ahead of the error are still read.

Co-Authored-By: Copilot <175728472+Copilot@users.noreply.github.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The documentation described the convention by its filter form alone, though the tooling
accepts the action form equally, and core writes 264 of them.

Co-Authored-By: Copilot <175728472+Copilot@users.noreply.github.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 30, 2026 03:50

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.

Pull request overview

Copilot reviewed 8 out of 8 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (2)

tests/phpstan/HookDocumentationRule.php:104

  • The wordpress.hookDocMissing error message doesn’t identify which hook is missing documentation. Including the (literal) hook name when available makes the report more actionable and aligns it with the other hook-documentation errors that include the hook name.
				RuleErrorBuilder::message(
					sprintf(
						'%s() call is not preceded by a docblock documenting the hook, nor by a "This filter/action is documented in <file>" reference comment.',
						$function_name
					)
				)

tests/phpstan/HookParamCountRule.php:125

  • HookParamCountRule skips count checks for filters whose inline docblock has zero @param tags (because HookDocumentationRule reports that as wordpress.hookDocNoParams). But for filters documented via a reference comment, a canonical docblock with zero @param tags will still be treated as paramCount = 0 and this rule will report wordpress.hookParamCountMismatch at every referencing call site, duplicating the canonical error. Referencing sites should also skip param-count comparison in this specific case so the missing-@param problem is reported only where the canonical docblock lives.
		// A filter whose docblock documents no parameters is not documented at all,
		// which HookDocumentationRule reports. Comparing counts as well would report
		// one defect twice.
		if ( HookDocBlock::isFilterMissingParamDocs( $node, $hook_doc ) ) {
			return array();
		}
westonruter and others added 2 commits July 29, 2026 21:17
A filter documented with no `@param` tag is a documentation problem rather than an
argument count problem, and the argument count rule skipped it on that basis — but only
where the docblock was written at the call. A filter that inherits such a docblock
through a reference comment still had its arguments compared against a documented count
of zero, so one missing `@param` in a canonical docblock was reported at every site
inheriting it, each time as though the docblock were the referencing site's own.

Skip the comparison wherever the docblock was found. The documentation rule already
reports this only for a docblock written at the call, which is where the `@param` tag
belongs, so the problem is now reported exactly once and in the file that can fix it.

Co-Authored-By: Copilot <175728472+Copilot@users.noreply.github.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The errors reporting a broken reference comment and a mismatched argument count name the
hook they are about, while the two reporting a missing docblock and a docblock without
parameters named only the function called. A file firing several hooks left the reader
mapping line numbers back to calls to see which one was meant.

Name the hook in those two as well, rendering a dynamic name as it is written, so
`default_option_{$option}` reads as such.

Co-Authored-By: Copilot <175728472+Copilot@users.noreply.github.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The argument count error named the hook only when its name was written literally, so a
mismatch on a hook assembled from a variable read as `apply_filters() provides 2
arguments, …`, identifying the call by line number alone. The other errors render such a
name as it appears in the source.

Render it the same way here, which also removes the helper that existed to omit the name.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 30, 2026 04:31

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.

Pull request overview

Copilot reviewed 8 out of 8 changed files in this pull request and generated 2 comments.

Comments suppressed due to low confidence (2)

tests/phpstan/HookDocBlock.php:798

  • str_starts_with() is PHP 8+ only, which will fatal on PHP 7.4. Replace with a PHP 7.4-compatible prefix check in this loop condition.
		while ( $dir === $this->wordpressRoot || str_starts_with( $dir, $this->wordpressRoot . '/' ) ) {

tests/phpstan/HookDocBlock.php:844

  • str_starts_with() is PHP 8+ only, which will fatal on PHP 7.4. Use a PHP 7.4-compatible prefix check when verifying the resolved path is inside the WordPress root.
		return str_starts_with( $canonical, $this->canonicalWordpressRoot . '/' ) ? $canonical : null;
foreach ( $this->getScannedFiles() as $file ) {
$code = file_get_contents( $file );

if ( false === $code || ! str_contains( $code, 'is documented in' ) ) {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

From Claude Opus 5 (1M context), via Claude Code, at @westonruter's request.

Checked this, and it does not fatal under PHP 7.4 — though only because of where this code runs, which is worth recording.

These classes are loaded exclusively inside PHPStan's own process, as services declared in tests/phpstan/base.neon. The pinned phpstan/phpstan 2.2.5 phar bundles symfony/polyfill-php80 and loads it through Composer's file autoloading:

vendor/composer/autoload_files.php (inside phpstan.phar)
  'a4a119a56e50fbb293281d9a48007e0e' => $vendorDir . '/symfony/polyfill-php80/bootstrap.php',

vendor/symfony/polyfill-php80/bootstrap.php
  if (\PHP_VERSION_ID >= 80000) { return; }
  …
  if (!function_exists('str_contains'))    { function str_contains(…)    { … } }
  if (!function_exists('str_starts_with')) { function str_starts_with(…) { … } }
  if (!function_exists('str_ends_with'))   { function str_ends_with(…)   { … } }

So the chain under composer phpstan is: vendor/bin/phpstan → the phar stub's require 'phar://phpstan.phar/bin/phpstan'autoload_real.php runs its file-autoload loop → the polyfill defines those three functions globally whenever PHP_VERSION_ID < 80000 → PHPStan then instantiates these extension classes. That is also how PHPStan itself manages to require PHP 7.4+ while using these functions throughout its own source.

A caveat on the strength of that evidence: it is a verified code path rather than an executed one. Only PHP 8.2 through 8.5 were available where I checked, so I could not run the analysis under 7.4 end to end. What I did confirm is the polyfill's presence in the pinned phar, its PHP_VERSION_ID guard, its registration in autoload_files.php, and the loader that consumes that list.

There is one real limitation in the neighborhood of this concern, which is narrower than the comment but true: the guarantee holds only inside PHPStan's process. A script that requires these files directly — an ad hoc harness, for instance — would fatal on 7.4, since nothing polyfills there. Anything that loads these classes outside PHPStan needs its own polyfill, or PHP 8.

No change here, then. Reverting to strpos() would trade readability for a fatal that cannot occur in the only context this code runs in.

private function getRootRelativePath( string $path ): string {
$prefix = $this->canonicalWordpressRoot . '/';

return str_starts_with( $path, $prefix ) ? substr( $path, strlen( $prefix ) ) : $path;

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

From Claude Opus 5 (1M context), via Claude Code, at @westonruter's request.

Same answer as for str_contains() above: the pinned phpstan/phpstan 2.2.5 phar loads symfony/polyfill-php80 through Composer's file autoloading, which defines str_starts_with() globally whenever PHP_VERSION_ID < 80000, and these classes only ever run inside PHPStan's process. That covers the two further occurrences noted here as well, in resolveReferencePath() and resolveWithinRoot().

One detail specific to this line: the prefix test is also what lets the substr() after it stay clean at level 10. Under the configured PHP 7.4 floor substr() is typed string|false, since the offset may exceed the subject's length, so returning it unguarded from a method declared : string is a return.type error. Testing the prefix first narrows the offset enough for PHPStan to rule false out — and to be fair to the suggestion, I checked, and 0 === strpos( $path, $prefix ) narrows it identically. So that is not an argument against the rewrite; the reason to keep str_starts_with() here is simply that it reads as what it is.

pento pushed a commit that referenced this pull request Jul 30, 2026
Add PHPStan extensions that read the docblock documenting each hook where the hook is fired. The value `apply_filters()` returns is typed from the first `@param` that docblock documents rather than `mixed`; core's `/** This filter is documented in <file> */` convention is resolved, so a hook documented elsewhere is analyzed against its canonical docblock, including a dynamic canonical name such as `"{$type}_template_hierarchy"`; and two rules require every hook invocation to be documented, and to pass as many arguments as its documentation describes.

These conventions were previously enforced by review alone. A reference comment could name a file that no longer documents the hook, and a call site could pass fewer arguments than documented, which raises an `ArgumentCountError` in a callback registered for the documented count, or more, which drops the extra argument and leaves the documentation wrong. Both are now reported where they occur. The hook issues this surfaced in core were fixed in preceding commits.

The generated `src/wp-includes/build` tree is excluded from analysis, as its sources live in the Gutenberg plugin.

Developed in #12022.
Follow-up to r61699, r62292, r62893, r62894, r62925, r62926, r62927, r62928, r62930, r62931, r62932, r62933, r62934, r62935.

Props westonruter, szepeviktor, khokansardar.
See #64898.
Fixes #65376.


git-svn-id: https://develop.svn.wordpress.org/trunk@62939 602fd350-edb4-49c9-b593-d223f7449a82
@github-actions

Copy link
Copy Markdown

A commit was made that fixes the Trac ticket referenced in the description of this pull request.

SVN changeset: 62939
GitHub commit: 7404a51

This PR will be closed, but please confirm the accuracy of this and reopen if there is more work to be done.

@github-actions github-actions Bot closed this Jul 30, 2026
markjaquith pushed a commit to markjaquith/WordPress that referenced this pull request Jul 30, 2026
Add PHPStan extensions that read the docblock documenting each hook where the hook is fired. The value `apply_filters()` returns is typed from the first `@param` that docblock documents rather than `mixed`; core's `/** This filter is documented in <file> */` convention is resolved, so a hook documented elsewhere is analyzed against its canonical docblock, including a dynamic canonical name such as `"{$type}_template_hierarchy"`; and two rules require every hook invocation to be documented, and to pass as many arguments as its documentation describes.

These conventions were previously enforced by review alone. A reference comment could name a file that no longer documents the hook, and a call site could pass fewer arguments than documented, which raises an `ArgumentCountError` in a callback registered for the documented count, or more, which drops the extra argument and leaves the documentation wrong. Both are now reported where they occur. The hook issues this surfaced in core were fixed in preceding commits.

The generated `src/wp-includes/build` tree is excluded from analysis, as its sources live in the Gutenberg plugin.

Developed in WordPress/wordpress-develop#12022.
Follow-up to r61699, r62292, r62893, r62894, r62925, r62926, r62927, r62928, r62930, r62931, r62932, r62933, r62934, r62935.

Props westonruter, szepeviktor, khokansardar.
See #64898.
Fixes #65376.

Built from https://develop.svn.wordpress.org/trunk@62939


git-svn-id: http://core.svn.wordpress.org/trunk@62183 1a063a9b-81f0-0310-95a4-ce76da25c4cd
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

3 participants