Build/Test Tools: Statically verify that hook documentation and arguments stay in sync - #12022
Conversation
…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>
Test using WordPress PlaygroundThe changes in this pull request can previewed and tested using a WordPress Playground instance. WordPress Playground is an experimental project that creates a full WordPress instance entirely within the browser. Some things to be aware of
For more details about these limitations and more, check out the Limitations page in the WordPress Playground documentation. |
There was a problem hiding this comment.
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.
|
What an ocean of bugs revealed! |
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>
…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>
Every finding from the review is addressed
The nits are addressed too: the Four things turned out worse than the review describedEach of these was demonstrated rather than reasoned about, and each was a real defect rather than the theoretical one I had written up:
What is deliberately not changed
One cost this introducesThe 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 VerificationRepeated after each change:
|
There was a problem hiding this comment.
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 ) {
…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>
There was a problem hiding this comment.
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.hookDocMissingerror 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
HookParamCountRuleskips count checks for filters whose inline docblock has zero@paramtags (becauseHookDocumentationRulereports that aswordpress.hookDocNoParams). But for filters documented via a reference comment, a canonical docblock with zero@paramtags will still be treated asparamCount = 0and this rule will reportwordpress.hookParamCountMismatchat every referencing call site, duplicating the canonical error. Referencing sites should also skip param-count comparison in this specific case so the missing-@paramproblem 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();
}
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>
There was a problem hiding this comment.
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' ) ) { |
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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.
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
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
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:
apply_filters()was typed asmixed, 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.ArgumentCountError(or aTypeErrorfor a typed parameter) in a callback registered for the documented count; passing too many silently drops the extra argument and misleads the documentation.@paramtags went unflagged.What's in this PR
Tooling (in
tests/phpstan/, wired in viabase.neonso it applies to both the level‑0phpstan.neon.distand the local level‑10 config):apply_filters()from the first documented@param. Adapted fromszepeviktor/phpstan-wordpress.{$type}_template_hierarchy).Core fixes surfaced by the tooling:
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 networkhandle_network_bulk_actions-{$screen}screens, and the XML‑RPCpre_option_enable_xmlrpc/option_enable_xmlrpcshim.the_contentindo_trackbacks()andthe_excerptinWP_REST_Revisions_Controller.editable_slugfilter inget_inline_data()was buried inside a string concatenation, so its reference comment did not attach to the call; and theusers_have_additional_contentreference comment inconfirm_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.@paramtags and a stale reference comment in bundled themes,the_permalinkcalls now passing the documentedget_post()argument, and an accuratearray<string, string>shape forwp_get_image_editor_output_format()and itsimage_editor_output_formatfilter.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
trunkmoves.src/)wp-admin/includes/class-wp-automatic-updater.phphttps_local_ssl_verifyParamCountMismatchwp-admin/includes/class-wp-site-health.php×3https_local_ssl_verifyParamCountMismatchwp-admin/includes/file.phphttps_local_ssl_verifyParamCountMismatchwp-admin/includes/ms.phpusers_have_additional_contentDocMissingwp-admin/includes/nav-menu.phpwp_update_nav_menuParamCountMismatchwp-admin/includes/template.phpeditable_slugDocMissingwp-admin/network/themes.phpParamCountMismatchwp-admin/network/users.phpParamCountMismatchwp-admin/plugin-install.phpinstall_plugins_uploadParamCountMismatchwp-admin/plugins.phpParamCountMismatchwp-content/themes/twentyeleven/author.phptwentyeleven_author_bio_avatar_sizeParamCountMismatchwp-content/themes/twentyeleven/content-single.phptwentyeleven_author_bio_avatar_sizeParamCountMismatchwp-content/themes/twentyeleven/content-status.phptwentyeleven_status_avatarParamCountMismatchwp-content/themes/twentyeleven/functions.phpthe_permalinkParamCountMismatchwp-content/themes/twentyeleven/image.phptwentyeleven_attachment_sizeParamCountMismatchwp-content/themes/twentyeleven/inc/widgets.phpwidget_titlewp-includes/default-widgets.phpdocuments no such hookDocReferenceHookMissingwp-content/themes/twentyeleven/tag.phptag_archive_metaParamCountMismatchwp-content/themes/twentyfifteen/inc/template-tags.phpDocMissingwp-content/themes/twentyfourteen/inc/widgets.phpDocMissingwp-content/themes/twentyten/functions.phptwentyten_header_image_heightParamCountMismatchwp-content/themes/twentyten/functions.phptwentyten_header_image_widthParamCountMismatchwp-content/themes/twentythirteen/functions.phpDocMissingwp-content/themes/twentytwentyone/functions.phptwentytwentyone_html_classesParamCountMismatchwp-includes/abilities-api/class-wp-ability.phpDocMissingwp-includes/class-wp-xmlrpc-server.php×2DocMissingwp-includes/comment.phpthe_contentParamCountMismatchwp-includes/cron.phphttps_local_ssl_verifyParamCountMismatchwp-includes/rest-api/endpoints/class-wp-rest-autosaves-controller.phpwp_creating_autosaveParamCountMismatchwp-includes/rest-api/endpoints/class-wp-rest-menus-controller.phpwp_update_nav_menuParamCountMismatchwp-includes/rest-api/endpoints/class-wp-rest-revisions-controller.phpthe_excerptParamCountMismatchwp-includes/taxonomy.php×2edit_term_taxonomyParamCountMismatchwp-includes/taxonomy.phpedit_termsParamCountMismatchwp-includes/taxonomy.php×2edited_term_taxonomyParamCountMismatchwp-includes/taxonomy.phpedited_termsParamCountMismatchwp-includes/taxonomy.phpterm_id_filterParamCountMismatchStatic-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 overtrunkand 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-cacheis 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. Baselinetrunkatc5690fd7e8, branch at1a377052b0.This is a local-config measurement, not a CI one. CI runs
phpstan.neon.distat 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 +bleedingEdgeconfig, which is where filter return types actually change the analysis.trunkErrors fixed (3,133)
argument.typereturn.typeoffsetAccess.nonOffsetAccessiblebinaryOp.invalidencapsedStringPart.nonStringecho.nonStringproperty.nonObjectforeach.nonIterableoffsetAccess.invalidOffsetassign.propertyTypemissingType.returnmethod.nonObjectassignOp.invalidmissingType.iterableValuenew.nonObjectcast.stringphpDoc.parseErrorvariable.undefinedparameterByRef.typeoffsetAccess.notFoundproperty.nameNotStringoffsetAccess.nonArrayarray.invalidKeyThe 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
src/)wp-includes/general-template.phpwp-includes/class-wp-query.phpwp-includes/user.phpwp-includes/link-template.phpwp-includes/comment-template.phpwp-includes/media.phpwp-includes/category-template.phpwp-includes/post-template.phpwp-includes/pluggable.phpwp-admin/includes/media.phpwp-includes/functions.phpwp-includes/formatting.phpwp-includes/class-wp-term-query.phpwp-includes/post.phpwp-admin/includes/file.phpwp-includes/blocks.phpwp-includes/comment.phpwp-includes/build/routes.phpwp-includes/ms-functions.phpwp-login.phpwp-admin/includes/upgrade.phpwp-admin/includes/ajax-actions.phpwp-includes/class-wp-http.phpwp-includes/class-wp.phpwp-includes/taxonomy.phpErrors introduced (226)
argument.typereturn.typeproperty.nonObjectproperty.notFoundbinaryOp.invalidassign.propertyTypeoffsetAccess.nonOffsetAccessibleoffsetAccess.invalidOffsetreturn.unusedTypeoffsetAccess.notFoundforeach.nonIterableargument.unresolvableTypefunction.alreadyNarrowedTypeecho.nonStringNone 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$urlargument tohttps_local_ssl_verify, yet its one new error is a return-shape mismatch inperform_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_eventis documented as returning a bareobject, so every->timestamp,->schedule,->hook,->argsand->intervalaccess on the result is now an undefined property. The docblock should describe a shape, notobject.class-wp-query.php(32 errors).the_postsis documented asarray<int|stdClass|WP_Post|null>. Taken at face value,$post->IDon 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 returnsarray", and likewise forget_the_tag_list(),wp_mime_type_icon()andwp_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 returnarray{label: string, status: 'critical'|'good'|'recommended', badge: array{label: string, color: string}, description: string, actions: string, test: string}but returnsarray.wp-admin/includes/file.php— [return.type]_wp_handle_upload()should returnarray{error: non-empty-string}|array{file: non-empty-string, url: non-empty-string, type: non-empty-string}but returnsarray.wp-admin/includes/file.php— [argument.type] Parameter #41488: Fix handling of backslashes encoded in in partial placement context data #2$lengthoffread()expectsint<1, max>,intgiven.wp-admin/includes/nav-menu.php— [argument.type] (×2) Parameter Remove post_ID requirement from parse_embed AJAX call #7$callback_argsofadd_meta_box()expectsarray|null,WP_Post_Typegiven.wp-admin/includes/template.php— [argument.type] Parameter Update class class-pclzip.php #1$array_argofnatcasesort()expects an array of values castable to string,arraygiven.wp-content/themes/twentyeleven/functions.php— [argument.type] Parameter #41488: Fix handling of backslashes encoded in in partial placement context data #2$widthofadd_image_size()expectsint<0, max>,intgiven.wp-content/themes/twentyeleven/functions.php— [argument.type] Parameter Build Tools: Don't send Slack notifications for Travis CI pull requests #3$heightofadd_image_size()expectsint<0, max>,intgiven.wp-content/themes/twentyeleven/functions.php— [return.type]twentyeleven_get_first_url()should returnstringbut returnsstring|true.wp-content/themes/twentythirteen/functions.php— [argument.type] Parameter #41488: Fix handling of backslashes encoded in in partial placement context data #2$sizeofwp_get_attachment_image()expectsarray<int>|string,arraygiven.wp-includes/class-wp-xmlrpc-server.php— [argument.type] Parameter Update class class-pclzip.php #1$strofstrip_tags()expectsstring,string|nullgiven.wp-includes/class-wp-xmlrpc-server.php— [argument.type] Parameter #41488: Fix handling of backslashes encoded in in partial placement context data #2$subjectofpreg_match()expectsstring,string|nullgiven.wp-includes/class-wp-xmlrpc-server.php— [argument.type] (×2) Parameter Build Tools: Don't send Slack notifications for Travis CI pull requests #3$subjectofpreg_replace()expectsarray<float|int|string>|string,string|nullgiven.wp-includes/comment.php— [return.type]wp_count_comments()should returnstdClassbut returnsarray|stdClass.wp-includes/cron.php— [property.notFound] (×3) Access to an undefined propertyobject::$args.wp-includes/cron.php— [property.notFound] (×2) Access to an undefined propertyobject::$hook.wp-includes/cron.php— [property.notFound] (×2) Access to an undefined propertyobject::$schedule.wp-includes/cron.php— [property.notFound] (×2) Access to an undefined propertyobject::$timestamp.wp-includes/cron.php— [property.notFound] Access to an undefined propertyobject::$interval.wp-includes/cron.php— [argument.unresolvableType] (×2) Parameter Update class class-pclzip.php #1$array_argofuksort()contains unresolvable type.wp-includes/media.php— [return.type]image_downsize()should returnarray|falsebut returnsnon-empty-array|true.wp-includes/media.php— [return.type]wp_get_attachment_id3_keys()should returnarray<string>but returnsarray.wp-includes/media.php— [return.unusedType]get_post_gallery()never returnsstringso it can be removed from the return type.wp-includes/media.php— [argument.type] Parameter Update class class-pclzip.php #1$functionofcall_user_func()expectscallable(): mixed,array{string, 'test'}given.wp-includes/media.php— [argument.type] (×2) Parameter Update class class-pclzip.php #1$functionofcall_user_func()expectscallable(): 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$l10nofwp_localize_script()expectsarray<string, mixed>,array<array|string>given.wp-includes/media.php— [foreach.nonIterable] Argument of an invalid typearray<stdClass>|nullsupplied for foreach.wp-includes/taxonomy.php— [return.type]get_terms()should returnarray<int|string|WP_Term>|string|WP_Errorbut returnsarray.wp-includes/taxonomy.php— [property.notFound] Access to an undefined propertyobject::$term_id.wp-includes/taxonomy.php— [property.notFound] Access to an undefined propertyobject::$term_taxonomy_id.Notes for reviewers
0is passed to satisfy the signature.src/wp-includes/buildtree is excluded from analysis; the corresponding fix lives in the Gutenbergwp-buildpage templates and is handled separately.phpstan.neon.distreports 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.