From 91e4d9e058ac00bee0504c89bcc983100985d17b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 8 Sep 2026 08:55:30 +0000 Subject: [PATCH 1/5] Build/Test Tools: Add szepeviktor/phpstan-wordpress as a development dependency. Pulls in the PHPStan extensions maintained for the WordPress ecosystem so that core can register the ones that apply to it, rather than carrying its own copies. Only the package is added here; nothing from it is loaded yet. Its `extension.neon` is deliberately not included, since that bootstraps the `php-stubs/wordpress-stubs` package, which describes the very code core analyzes. Co-authored-by: Pascal Birchler Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_013QJ9RphuttTR7cS5G4mU21 --- composer.json | 1 + 1 file changed, 1 insertion(+) diff --git a/composer.json b/composer.json index b510b99156d53..8adb519ac31da 100644 --- a/composer.json +++ b/composer.json @@ -54,6 +54,7 @@ "phpcompatibility/phpcompatibility-wp": "~2.1.3", "phpstan/phpstan": "2.2.13", "phpstan/phpstan-phpunit": "2.0.18", + "szepeviktor/phpstan-wordpress": "^2.0.4", "yoast/phpunit-polyfills": "^1.1.0" }, "config": { From 1b1cf41b77a339cc40219028f4f3d25795f4bcf3 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 8 Sep 2026 09:09:41 +0000 Subject: [PATCH 2/5] Plugins: Correct `$accepted_args` on three hook registrations. `check_comment_flood_db()`, `wp_render_block_style_variation_support_styles()` and `twenty_twenty_one_post_classes()` declare fewer parameters than the `$accepted_args` they are registered with, so the extra arguments were passed and discarded. Registering them for the arguments they take is what szepeviktor/phpstan-wordpress's `HookCallbackRule` asks for, and leaves nothing for it to report once it is registered in the next commit. The block support lives in Gutenberg as well, where the same change is due. Co-authored-by: Pascal Birchler Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_013QJ9RphuttTR7cS5G4mU21 --- .../themes/twentytwentyone/inc/template-functions.php | 2 +- src/wp-includes/block-supports/block-style-variations.php | 2 +- src/wp-includes/default-filters.php | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/wp-content/themes/twentytwentyone/inc/template-functions.php b/src/wp-content/themes/twentytwentyone/inc/template-functions.php index f141b99a8cf8f..6af17e19ff5d6 100644 --- a/src/wp-content/themes/twentytwentyone/inc/template-functions.php +++ b/src/wp-content/themes/twentytwentyone/inc/template-functions.php @@ -50,7 +50,7 @@ function twenty_twenty_one_post_classes( $classes ) { return $classes; } -add_filter( 'post_class', 'twenty_twenty_one_post_classes', 10, 3 ); +add_filter( 'post_class', 'twenty_twenty_one_post_classes' ); /** * Adds a pingback url auto-discovery header for single posts, pages, or attachments. diff --git a/src/wp-includes/block-supports/block-style-variations.php b/src/wp-includes/block-supports/block-style-variations.php index 746ed38177a57..8ade72e92f1db 100644 --- a/src/wp-includes/block-supports/block-style-variations.php +++ b/src/wp-includes/block-supports/block-style-variations.php @@ -262,7 +262,7 @@ function wp_enqueue_block_style_variation_styles() { // Register the block support. WP_Block_Supports::get_instance()->register( 'block-style-variation', array() ); -add_filter( 'render_block_data', 'wp_render_block_style_variation_support_styles', 10, 2 ); +add_filter( 'render_block_data', 'wp_render_block_style_variation_support_styles' ); add_filter( 'render_block', 'wp_render_block_style_variation_class_name', 10, 2 ); add_action( 'wp_enqueue_scripts', 'wp_enqueue_block_style_variation_styles', 1 ); diff --git a/src/wp-includes/default-filters.php b/src/wp-includes/default-filters.php index 025a371781200..6ba5d9fad90ed 100644 --- a/src/wp-includes/default-filters.php +++ b/src/wp-includes/default-filters.php @@ -307,7 +307,7 @@ add_filter( 'pre_kses', 'wp_pre_kses_less_than' ); add_filter( 'pre_kses', 'wp_pre_kses_block_attributes', 10, 3 ); add_filter( 'sanitize_title', 'sanitize_title_with_dashes', 10, 3 ); -add_action( 'check_comment_flood', 'check_comment_flood_db', 10, 4 ); +add_action( 'check_comment_flood', 'check_comment_flood_db' ); add_filter( 'comment_flood_filter', 'wp_throttle_comment_flood', 10, 3 ); add_filter( 'pre_comment_content', 'wp_rel_ugc', 15 ); From 24993e3d147a46f3d19417c0604dd298b5d4f5c7 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 8 Sep 2026 09:09:41 +0000 Subject: [PATCH 3/5] Build/Test Tools: Register the phpstan-wordpress extensions that apply to core. Loads three services from szepeviktor/phpstan-wordpress in `base.neon`, chosen by measuring each of the package's extensions against `src/` at rule level 10 and at the level CI enforces: * `ShortcodeAttsDynamicFunctionReturnTypeExtension`, which types `shortcode_atts()` from the defaults passed to it. A docblock cannot express that merge. * `HookCallbackRule`, for its check that `$accepted_args` agrees with the callback's signature. Its objection to an action callback that returns a value is ignored in `phpstan.neon.dist`, with the reason recorded there: core registers such functions on actions deliberately, and WordPress discards the value. * `HookDocsRule`, for its check that the type a hook docblock documents accepts the value the hook passes. That documented type is what `apply_filters()` is typed from. The package's other extensions are not loaded. Its hook docblock resolver, visitor and `apply_filters()` extension are what core's own were adapted from, and core's resolve the "This filter is documented in" reference comments. Its remaining return type extensions each do what a conditional `@phpstan-return` does, which core already carries for `wp_parse_url()`, `wp_slash()` and `stripslashes_from_strings_only()`, and loading them changed nothing measurable there. The README records the disposition of every extension and why. The baselines gain the twenty hook docblocks whose documented type does not accept the value passed, and one `get_posts()` call that the shortcode attributes now show is handed a string where it expects an array of IDs. Co-authored-by: Pascal Birchler Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_013QJ9RphuttTR7cS5G4mU21 --- phpstan.neon.dist | 18 ++++++ tests/phpstan/README.md | 22 ++++++++ tests/phpstan/base.neon | 36 ++++++++++++ tests/phpstan/baselines/argument.type.neon | 5 ++ .../baselines/parameter.phpDocType.neon | 55 +++++++++++++++++++ 5 files changed, 136 insertions(+) diff --git a/phpstan.neon.dist b/phpstan.neon.dist index cc4579365122b..1a3977055857b 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -142,3 +142,21 @@ parameters: message: '#^Parameter \#4 \$length of function substr_compare expects int, null given\.$#' path: src/wp-includes/html-api/class-wp-html-tag-processor.php reportUnmatched: false + + # szepeviktor/phpstan-wordpress's HookCallbackRule, registered in tests/phpstan/base.neon, + # also reports an action callback whose return type is not void. That is sound advice for a + # plugin, where a function returning a value is likely a filter callback registered on the + # wrong function, but core registers functions that happen to return something on actions + # as a matter of course: `wp_save_post_revision()` on `post_updated`, `redirect_canonical()` + # on `template_redirect`, `wp_delete_attachment()` on `importer_scheduled_cleanup`. WordPress + # discards an action callback's return value, so none of the 52 reported registrations is a + # defect, and changing each function's return type to satisfy the rule would change public + # API. The rule's other check, that `$accepted_args` agrees with the callback's signature, is + # kept: it is what the rule is registered for. + # + # The rule reports this under PHPStan's own `return.void` identifier rather than one of its + # own, so the message is matched instead of the identifier to leave PHPStan's `return.void` + # errors reported. + - + message: '#^Action callback returns .+ but should not return anything\.$#' + reportUnmatched: false diff --git a/tests/phpstan/README.md b/tests/phpstan/README.md index 3ab750a069471..4fab23d06d22c 100644 --- a/tests/phpstan/README.md +++ b/tests/phpstan/README.md @@ -106,6 +106,17 @@ Calls whose hook name contains no literal text, such as the `apply_filters_ref_a One consequence worth knowing: because a hook's documentation may live in a different file than the call inheriting it, editing a hook docblock in a file that reference comments point at discards PHPStan's result cache. Every call site inheriting that docblock has to be analyzed again, and PHPStan cannot infer that dependency on its own. +### Extensions from szepeviktor/phpstan-wordpress + +[szepeviktor/phpstan-wordpress](https://github.com/szepeviktor/phpstan-wordpress) is the set of PHPStan extensions maintained for the WordPress ecosystem, and the hook extensions above began as adaptations of it. It is installed as a Composer development dependency so that core can load its extensions directly rather than carry copies of them. Its own `extension.neon` is not included, because that loads [php-stubs/wordpress-stubs](https://github.com/php-stubs/wordpress-stubs), a declaration of every core function and class, which is the code being analyzed here. Instead, [`base.neon`](base.neon) registers the extensions that apply to core one at a time. Installing the package installs the stubs as well; they are not read. + +What the package provides falls into four groups, and only the first is loaded: + +- **Used, because a docblock cannot express what they do.** `ShortcodeAttsDynamicFunctionReturnTypeExtension` types the result of `shortcode_atts()` from the defaults passed to it, the same merge that `wp_parse_args()` performs. `HookCallbackRule` checks a callback registered with `add_action()` or `add_filter()` against its registration: that `$accepted_args` agrees with the parameters the callback declares, and that a filter callback returns a value. Its third check, that an action callback returns nothing, is switched off in [`phpstan.neon.dist`](../../phpstan.neon.dist), where the reason is recorded. `HookDocsRule` checks that the type each `@param` of a hook docblock documents accepts the value the hook passes. That documented type is what `apply_filters()` is typed from, so a wrong one misleads every caller. It reads only a docblock written at the call, so a hook documented elsewhere through a reference comment is not checked. +- **Not used, because core's own versions do the same and more.** `HookDocsVisitor`, `HookDocBlock` and `ApplyFiltersDynamicFunctionReturnTypeExtension` are what the [hook documentation](#hook-documentation) extensions were adapted from. Core's resolve the "This filter is documented in" reference comments, which the originals do not, and core's rules already check the `@param` counts that `HookDocsRule` also checks, so only its type check adds anything. +- **Not used, because a docblock does the same.** `EscSql`, `NormalizeWhitespace`, `StripslashesFromStringsOnly`, `SlashitFunctions`, `WpParseUrl` and `WpSlash` each narrow one function's return type from its arguments in a way a conditional `@phpstan-return` expresses. Where core has that docblock already, as `wp_parse_url()`, `wp_slash()` and `stripslashes_from_strings_only()` do, loading the extension changes nothing; where it does not yet, as `esc_sql()` and `trailingslashit()`, the docblock is the fix to make. A docblock in core also types the function for every plugin, since the stubs are generated from core. phpstan-wordpress itself has gone that way: its 2.x branch dropped the extensions it had for `get_post()`, `get_terms()`, `current_time()`, `wp_die()`, `is_wp_error()` and others in favor of types carried by the stubs, and the [function map](https://github.com/php-stubs/wordpress-stubs/blob/master/functionMap.php) those stubs apply on top of core's docblocks is a list of the ones core could adopt. +- **Not applicable to core.** `WpConstantFetchRule` discourages reading a constant such as `MULTISITE` where a function exists to read it, but core is where those functions read them. `AssertWpErrorTypeSpecifyingExtension` narrows the argument of `assertWPError()` in tests, which are not analyzed; when they are, `@phpstan-assert` on the methods themselves is the way to express it. + ### Errors these rules report These identifiers are specific to WordPress, and can be ignored or baselined like any other error, as described [below](#ignoring-and-baselining-errors). @@ -118,6 +129,17 @@ These identifiers are specific to WordPress, and can be ignored or baselined lik | `wordpress.hookDocReferenceHookMissing` | The referenced file exists, but documents no hook of that name. Either the reference is stale, or the canonical docblock has moved. | | `wordpress.hookParamCountMismatch` | The call passes a different number of arguments than the docblock documents `@param` tags for. Passing fewer risks an `ArgumentCountError` in a callback registered for the documented count; passing more silently drops the extra argument and leaves the documentation misleading. | +The rules loaded from szepeviktor/phpstan-wordpress report under PHPStan's own identifiers rather than ones of their own, so their errors share a baseline with the errors PHPStan itself reports under that identifier. They are told apart by their messages. + +| Identifier | Message | What it means | +| --- | --- | --- | +| `arguments.count` | `Callback expects N parameters, $accepted_args is set to M.` | The `$accepted_args` of an `add_action()` or `add_filter()` call does not fit the callback's signature. Fewer than the callback requires is an `ArgumentCountError` when the hook fires; more than it declares is misleading, and usually a leftover from an earlier signature. | +| `return.missing` | `Filter callback return statement is missing.` | A filter callback returns nothing, so the value being filtered becomes `null`. | +| `return.void` | `Action callback returns X but should not return anything.` | An action callback returns a value. Ignored for core in `phpstan.neon.dist`; see the note there. | +| `parameter.phpDocType` | `@param X $name does not accept actual type of parameter: Y.` | The type a hook docblock documents for a parameter does not accept the value the hook passes. Fix the docblock, or the value; the documented type is what callbacks and the `apply_filters()` return type rely on. | +| `paramTag.count` | `Expected N @param tags, found M.` | The same mismatch `wordpress.hookParamCountMismatch` reports, for a docblock written at the call. | +| `phpDoc.parseError` | `One or more @param tags has an invalid name or invalid syntax.` | A `@param` tag in a hook docblock could not be parsed, or is named `$this`. | + ## Ignoring and baselining errors As we adopt PHPStan iteratively, you may be faced with false positives due to legacy code, or code that is not worth changing at this time. diff --git a/tests/phpstan/base.neon b/tests/phpstan/base.neon index 4cf457f07e0f7..f7a5ad88bc4f2 100644 --- a/tests/phpstan/base.neon +++ b/tests/phpstan/base.neon @@ -62,6 +62,42 @@ services: tags: - phpstan.resultCacheMetaExtension + # The services below come from szepeviktor/phpstan-wordpress, the PHPStan extensions + # maintained for the WordPress ecosystem, installed through Composer. Its own + # extension.neon is deliberately not included: that bootstraps php-stubs/wordpress-stubs, + # which describes the very code analyzed here, so every function and class would be + # declared twice. Instead, the extensions that apply to core are registered one by one. + # See tests/phpstan/README.md for which ones are used, which are not, and why. + + # Types the return value of shortcode_atts() from the defaults it was called with, so + # a shortcode's attributes are an array shape rather than a plain array. The merge + # a call performs is not something a docblock can express. + - + class: SzepeViktor\PHPStan\WordPress\ShortcodeAttsDynamicFunctionReturnTypeExtension + tags: + - phpstan.broker.dynamicFunctionReturnTypeExtension + + # Checks the callback given to add_action() and add_filter() against the call: that + # `$accepted_args` agrees with the number of parameters the callback declares, that + # a filter callback returns a value, and that an action callback does not. The last + # check is switched off for core in phpstan.neon.dist; see the note there. + - + class: SzepeViktor\PHPStan\WordPress\HookCallbackRule + tags: + - phpstan.rules.rule + + # Checks that the type each `@param` of a hook docblock documents accepts the value + # the hook passes. That documented type is what apply_filters() is typed from above, + # so a wrong one misleads every caller. Only a docblock written at the call is checked: + # the rule reads the docblock through phpstan-wordpress's own resolver, registered + # beside it, which does not follow "This filter is documented in" reference comments. + - + class: SzepeViktor\PHPStan\WordPress\HookDocBlock + - + class: SzepeViktor\PHPStan\WordPress\HookDocsRule + tags: + - phpstan.rules.rule + # Runs the visitors above over every file PHPStan parses, not only the ones it # analyzes. # diff --git a/tests/phpstan/baselines/argument.type.neon b/tests/phpstan/baselines/argument.type.neon index 14681f13d929a..9a142a3246b37 100644 --- a/tests/phpstan/baselines/argument.type.neon +++ b/tests/phpstan/baselines/argument.type.neon @@ -1038,6 +1038,11 @@ parameters: identifier: argument.type count: 1 path: ../../../src/wp-includes/load.php + - + message: '#^Parameter \#1 \$args of function get_posts expects array\{numberposts\?\: int, category\?\: int\|string, include\?\: array\, exclude\?\: array\, suppress_filters\?\: bool, \.\.\.\}\|string\|null, array\{include\: non\-falsy\-string, post_status\: ''inherit'', post_type\: ''attachment'', post_mime_type\: ''image'', order\: string, orderby\: string\} given\.$#' + identifier: argument.type + count: 1 + path: ../../../src/wp-includes/media.php - message: '#^Parameter \#5 \$text of function wp_get_attachment_link expects string\|false, bool given\.$#' identifier: argument.type diff --git a/tests/phpstan/baselines/parameter.phpDocType.neon b/tests/phpstan/baselines/parameter.phpDocType.neon index db8a7f3b32466..18eefeafa1126 100644 --- a/tests/phpstan/baselines/parameter.phpDocType.neon +++ b/tests/phpstan/baselines/parameter.phpDocType.neon @@ -18,8 +18,63 @@ parameters: ignoreErrors: + - + message: '#^@param array\ \$inline_edit_statuses does not accept actual type of parameter\: array\{\-1\?\: string, pending\: string, draft\: string\}\|array\{\-1\?\: string, publish\: string, future\: string, private\?\: string, pending\: string, draft\: string\}\.$#' + identifier: parameter.phpDocType + count: 1 + path: ../../../src/wp-admin/includes/class-wp-posts-list-table.php + - + message: '#^@param bool \$bulk does not accept actual type of parameter\: 0\|1\.$#' + identifier: parameter.phpDocType + count: 3 + path: ../../../src/wp-admin/includes/class-wp-posts-list-table.php + - + message: '#^@param string \$items does not accept actual type of parameter\: 3\.$#' + identifier: parameter.phpDocType + count: 1 + path: ../../../src/wp-admin/includes/dashboard.php + - + message: '#^@param bool \$bool does not accept actual type of parameter\: ''''\.$#' + identifier: parameter.phpDocType + count: 1 + path: ../../../src/wp-admin/includes/media.php - message: '#^PHPDoc tag @param for parameter \$block_type with type array\ is incompatible with native type string\.$#' identifier: parameter.phpDocType count: 1 path: ../../../src/wp-includes/class-wp-block-processor.php + - + message: '#^@param stdClass \$details does not accept actual type of parameter\: WP_Site\.$#' + identifier: parameter.phpDocType + count: 1 + path: ../../../src/wp-includes/class-wp-site.php + - + message: '#^@param int \$dupe_id does not accept actual type of parameter\: non\-empty\-string\|null\.$#' + identifier: parameter.phpDocType + count: 1 + path: ../../../src/wp-includes/comment.php + - + message: '#^@param string \$audio does not accept actual type of parameter\: WP_Post\|null\.$#' + identifier: parameter.phpDocType + count: 1 + path: ../../../src/wp-includes/media.php + - + message: '#^@param string \$text does not accept actual type of parameter\: bool\.$#' + identifier: parameter.phpDocType + count: 1 + path: ../../../src/wp-includes/media.php + - + message: '#^@param string \$video does not accept actual type of parameter\: WP_Post\|null\.$#' + identifier: parameter.phpDocType + count: 1 + path: ../../../src/wp-includes/media.php + - + message: '#^@param int \$meta_id does not accept actual type of parameter\: non\-empty\-string\|null\.$#' + identifier: parameter.phpDocType + count: 4 + path: ../../../src/wp-includes/meta.php + - + message: '#^@param int \$tt_id does not accept actual type of parameter\: numeric\-string\.$#' + identifier: parameter.phpDocType + count: 4 + path: ../../../src/wp-includes/taxonomy.php From 0ce8439963c31df9cf7f42f6651512836d5d035e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 8 Sep 2026 09:57:37 +0000 Subject: [PATCH 4/5] Docs: Correct nineteen hook docblocks whose documented type does not accept the value passed. szepeviktor/phpstan-wordpress's `HookDocsRule`, registered in `base.neon`, reports a hook docblock whose `@param` type does not accept the value the hook is fired with. That documented type is what `apply_filters()` is typed from, so each one misled every caller of the filter as well as every callback written against the documentation. Where the value was the one the documentation promised all along, the value is corrected: `$bulk` in the Quick Edit filters is a bool rather than the loop counter, `disable_captions` is filtered on `false` rather than an empty string, and the IDs handed to `duplicate_comment_id`, `update_{$meta_type}_meta` and `delete_term_taxonomy` are cast to the documented int rather than passed as the strings the database returns. Where the value is right and the documentation was not, the docblock is corrected: `wp_audio_shortcode` and `wp_video_shortcode` pass an attachment post or null rather than a file, `{$adjacent}_image_link` passes `string|false`, `dashboard_secondary_items` an int, and `blog_details` receives either a `WP_Site` or the plain-object copy that `WP_Site::get_details()` makes deliberately. Co-authored-by: Pascal Birchler Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_013QJ9RphuttTR7cS5G4mU21 --- .../includes/class-wp-posts-list-table.php | 14 ++--- src/wp-admin/includes/dashboard.php | 2 +- src/wp-admin/includes/media.php | 4 +- src/wp-includes/comment.php | 2 +- src/wp-includes/media.php | 28 +++++----- src/wp-includes/meta.php | 1 + src/wp-includes/ms-blogs.php | 3 +- src/wp-includes/taxonomy.php | 2 +- tests/phpstan/baselines/argument.type.neon | 5 -- .../baselines/parameter.phpDocType.neon | 55 ------------------- 10 files changed, 30 insertions(+), 86 deletions(-) diff --git a/src/wp-admin/includes/class-wp-posts-list-table.php b/src/wp-admin/includes/class-wp-posts-list-table.php index d3d631b9b4467..9957a765dde47 100644 --- a/src/wp-admin/includes/class-wp-posts-list-table.php +++ b/src/wp-admin/includes/class-wp-posts-list-table.php @@ -1856,7 +1856,7 @@ public function inline_edit() { * @param array $users_opt An array of arguments passed to wp_dropdown_users(). * @param bool $bulk A flag to denote if it's a bulk action. */ - $users_opt = apply_filters( 'quick_edit_dropdown_authors_args', $users_opt, $bulk ); + $users_opt = apply_filters( 'quick_edit_dropdown_authors_args', $users_opt, (bool) $bulk ); $authors = wp_dropdown_users( $users_opt ); @@ -1961,7 +1961,7 @@ public function inline_edit() { * @param array $dropdown_args An array of arguments passed to wp_dropdown_pages(). * @param bool $bulk A flag to denote if it's a bulk action. */ - $dropdown_args = apply_filters( 'quick_edit_dropdown_pages_args', $dropdown_args, $bulk ); + $dropdown_args = apply_filters( 'quick_edit_dropdown_pages_args', $dropdown_args, (bool) $bulk ); wp_dropdown_pages( $dropdown_args ); ?> @@ -2108,12 +2108,12 @@ public function inline_edit() { * * @since 6.9.0 * - * @param array $inline_edit_statuses An array of statuses available in the Quick Edit UI. - * @param string $post_type The post type slug. - * @param bool $bulk A flag to denote if it's a bulk action. - * @param bool $can_publish A flag to denote if the user can publish posts. + * @param string[] $inline_edit_statuses An array of statuses available in the Quick Edit UI. + * @param string $post_type The post type slug. + * @param bool $bulk A flag to denote if it's a bulk action. + * @param bool $can_publish A flag to denote if the user can publish posts. */ - $inline_edit_statuses = apply_filters( 'quick_edit_statuses', $inline_edit_statuses, $screen->post_type, $bulk, $can_publish ); + $inline_edit_statuses = apply_filters( 'quick_edit_statuses', $inline_edit_statuses, $screen->post_type, (bool) $bulk, $can_publish ); foreach ( $inline_edit_statuses as $inline_status_value => $inline_status_text ) : ?> diff --git a/src/wp-admin/includes/dashboard.php b/src/wp-admin/includes/dashboard.php index 488e01aa92481..aafcaa9ac503b 100644 --- a/src/wp-admin/includes/dashboard.php +++ b/src/wp-admin/includes/dashboard.php @@ -1622,7 +1622,7 @@ function wp_dashboard_primary() { * * @since 4.4.0 * - * @param string $items How many items to show in the secondary feed. + * @param int $items How many items to show in the secondary feed. */ 'items' => apply_filters( 'dashboard_secondary_items', 3 ), 'show_summary' => 0, diff --git a/src/wp-admin/includes/media.php b/src/wp-admin/includes/media.php index beaf88101e648..3eedee6969695 100644 --- a/src/wp-admin/includes/media.php +++ b/src/wp-admin/includes/media.php @@ -211,9 +211,9 @@ function image_add_caption( $html, $id, $caption, $title, $align, $url, $size, $ * @since 2.6.0 * * @param bool $bool Whether to disable appending captions. Returning true from the filter - * will disable captions. Default empty string. + * will disable captions. Default false. */ - if ( empty( $caption ) || apply_filters( 'disable_captions', '' ) ) { + if ( empty( $caption ) || apply_filters( 'disable_captions', false ) ) { return $html; } diff --git a/src/wp-includes/comment.php b/src/wp-includes/comment.php index 8293c8b750314..2a19d99651688 100644 --- a/src/wp-includes/comment.php +++ b/src/wp-includes/comment.php @@ -785,7 +785,7 @@ function wp_allow_comment( $commentdata, $wp_error = false ) { wp_unslash( $commentdata['comment_content'] ) ); - $dupe_id = $wpdb->get_var( $dupe ); + $dupe_id = (int) $wpdb->get_var( $dupe ); /** * Filters the ID, if any, of the duplicate comment found when creating a new comment. diff --git a/src/wp-includes/media.php b/src/wp-includes/media.php index 1e0b86655e82a..58a86d5eed71a 100644 --- a/src/wp-includes/media.php +++ b/src/wp-includes/media.php @@ -3699,11 +3699,12 @@ function wp_audio_shortcode( $attr, $content = '' ) { * * @since 3.6.0 * - * @param string $html Audio shortcode HTML output. - * @param array $atts Array of audio shortcode attributes. - * @param string $audio Audio file. - * @param int $post_id Post ID. - * @param string $library Media library used for the audio shortcode. + * @param string $html Audio shortcode HTML output. + * @param array $atts Array of audio shortcode attributes. + * @param WP_Post|null $audio Audio attachment post when the shortcode has no source and + * an attached audio file is used, null otherwise. + * @param int $post_id Post ID. + * @param string $library Media library used for the audio shortcode. */ return apply_filters( 'wp_audio_shortcode', $html, $atts, $audio, $post_id, $library ); } @@ -3994,11 +3995,12 @@ function wp_video_shortcode( $attr, $content = '' ) { * * @since 3.6.0 * - * @param string $output Video shortcode HTML output. - * @param array $atts Array of video shortcode attributes. - * @param string $video Video file. - * @param int $post_id Post ID. - * @param string $library Media library used for the video shortcode. + * @param string $output Video shortcode HTML output. + * @param array $atts Array of video shortcode attributes. + * @param WP_Post|null $video Video attachment post when the shortcode has no source and + * an attached video file is used, null otherwise. + * @param int $post_id Post ID. + * @param string $library Media library used for the video shortcode. */ return apply_filters( 'wp_video_shortcode', $output, $atts, $video, $post_id, $library ); } @@ -4072,7 +4074,7 @@ function next_image_link( $size = 'thumbnail', $text = false ) { * @param bool $prev Optional. Whether to display the next (false) or previous (true) link. Default true. * @param string|int[] $size Optional. Image size. Accepts any registered image size name, or an array * of width and height values in pixels (in that order). Default 'thumbnail'. - * @param bool $text Optional. Link text. Default false. + * @param string|false $text Optional. Link text. Default false. * @return string Markup for image link. */ function get_adjacent_image_link( $prev = true, $size = 'thumbnail', $text = false ) { @@ -4128,7 +4130,7 @@ function get_adjacent_image_link( $prev = true, $size = 'thumbnail', $text = fal * @param int $attachment_id Attachment ID * @param string|int[] $size Requested image size. Can be any registered image size name, or * an array of width and height values in pixels (in that order). - * @param string $text Link text. + * @param string|false $text Link text, or false for the image itself. */ return apply_filters( "{$adjacent}_image_link", $output, $attachment_id, $size, $text ); } @@ -4143,7 +4145,7 @@ function get_adjacent_image_link( $prev = true, $size = 'thumbnail', $text = fal * @param bool $prev Optional. Whether to display the next (false) or previous (true) link. Default true. * @param string|int[] $size Optional. Image size. Accepts any registered image size name, or an array * of width and height values in pixels (in that order). Default 'thumbnail'. - * @param bool $text Optional. Link text. Default false. + * @param string|false $text Optional. Link text. Default false. */ function adjacent_image_link( $prev = true, $size = 'thumbnail', $text = false ) { echo get_adjacent_image_link( $prev, $size, $text ); diff --git a/src/wp-includes/meta.php b/src/wp-includes/meta.php index 62d28cdbd9f5c..27bbb6d396353 100644 --- a/src/wp-includes/meta.php +++ b/src/wp-includes/meta.php @@ -266,6 +266,7 @@ function update_metadata( $meta_type, $object_id, $meta_key, $meta_value, $prev_ if ( empty( $meta_ids ) ) { return add_metadata( $meta_type, $object_id, $raw_meta_key, $passed_value ); } + $meta_ids = array_map( 'intval', $meta_ids ); $_meta_value = $meta_value; $meta_value = maybe_serialize( $meta_value ); diff --git a/src/wp-includes/ms-blogs.php b/src/wp-includes/ms-blogs.php index c54563fbbd2b8..f834ceb23a88e 100644 --- a/src/wp-includes/ms-blogs.php +++ b/src/wp-includes/ms-blogs.php @@ -265,7 +265,8 @@ function get_blog_details( $fields = null, $get_all = true ) { * @since MU (3.0.0) * @deprecated 4.7.0 Use {@see 'site_details'} instead. * - * @param WP_Site $details The blog details. + * @param WP_Site|stdClass $details The blog details: a WP_Site from get_blog_details(), or a plain + * object copy of one from WP_Site::get_details(). */ $details = apply_filters_deprecated( 'blog_details', array( $details ), '4.7.0', 'site_details' ); diff --git a/src/wp-includes/taxonomy.php b/src/wp-includes/taxonomy.php index 56f12f7013dcb..db824959645b0 100644 --- a/src/wp-includes/taxonomy.php +++ b/src/wp-includes/taxonomy.php @@ -2110,7 +2110,7 @@ function wp_delete_term( $term, $taxonomy, $args = array() ) { return $ids; } - $tt_id = $ids['term_taxonomy_id']; + $tt_id = (int) $ids['term_taxonomy_id']; $defaults = array(); diff --git a/tests/phpstan/baselines/argument.type.neon b/tests/phpstan/baselines/argument.type.neon index 9a142a3246b37..c214bdddc8fbe 100644 --- a/tests/phpstan/baselines/argument.type.neon +++ b/tests/phpstan/baselines/argument.type.neon @@ -1043,11 +1043,6 @@ parameters: identifier: argument.type count: 1 path: ../../../src/wp-includes/media.php - - - message: '#^Parameter \#5 \$text of function wp_get_attachment_link expects string\|false, bool given\.$#' - identifier: argument.type - count: 1 - path: ../../../src/wp-includes/media.php - message: '#^Parameter \#2 \$callback of function array_walk expects callable\(non\-empty\-string\|null, int\<0, max\>\)\: mixed, ''clean_bookmark_cache'' given\.$#' identifier: argument.type diff --git a/tests/phpstan/baselines/parameter.phpDocType.neon b/tests/phpstan/baselines/parameter.phpDocType.neon index 18eefeafa1126..db8a7f3b32466 100644 --- a/tests/phpstan/baselines/parameter.phpDocType.neon +++ b/tests/phpstan/baselines/parameter.phpDocType.neon @@ -18,63 +18,8 @@ parameters: ignoreErrors: - - - message: '#^@param array\ \$inline_edit_statuses does not accept actual type of parameter\: array\{\-1\?\: string, pending\: string, draft\: string\}\|array\{\-1\?\: string, publish\: string, future\: string, private\?\: string, pending\: string, draft\: string\}\.$#' - identifier: parameter.phpDocType - count: 1 - path: ../../../src/wp-admin/includes/class-wp-posts-list-table.php - - - message: '#^@param bool \$bulk does not accept actual type of parameter\: 0\|1\.$#' - identifier: parameter.phpDocType - count: 3 - path: ../../../src/wp-admin/includes/class-wp-posts-list-table.php - - - message: '#^@param string \$items does not accept actual type of parameter\: 3\.$#' - identifier: parameter.phpDocType - count: 1 - path: ../../../src/wp-admin/includes/dashboard.php - - - message: '#^@param bool \$bool does not accept actual type of parameter\: ''''\.$#' - identifier: parameter.phpDocType - count: 1 - path: ../../../src/wp-admin/includes/media.php - message: '#^PHPDoc tag @param for parameter \$block_type with type array\ is incompatible with native type string\.$#' identifier: parameter.phpDocType count: 1 path: ../../../src/wp-includes/class-wp-block-processor.php - - - message: '#^@param stdClass \$details does not accept actual type of parameter\: WP_Site\.$#' - identifier: parameter.phpDocType - count: 1 - path: ../../../src/wp-includes/class-wp-site.php - - - message: '#^@param int \$dupe_id does not accept actual type of parameter\: non\-empty\-string\|null\.$#' - identifier: parameter.phpDocType - count: 1 - path: ../../../src/wp-includes/comment.php - - - message: '#^@param string \$audio does not accept actual type of parameter\: WP_Post\|null\.$#' - identifier: parameter.phpDocType - count: 1 - path: ../../../src/wp-includes/media.php - - - message: '#^@param string \$text does not accept actual type of parameter\: bool\.$#' - identifier: parameter.phpDocType - count: 1 - path: ../../../src/wp-includes/media.php - - - message: '#^@param string \$video does not accept actual type of parameter\: WP_Post\|null\.$#' - identifier: parameter.phpDocType - count: 1 - path: ../../../src/wp-includes/media.php - - - message: '#^@param int \$meta_id does not accept actual type of parameter\: non\-empty\-string\|null\.$#' - identifier: parameter.phpDocType - count: 4 - path: ../../../src/wp-includes/meta.php - - - message: '#^@param int \$tt_id does not accept actual type of parameter\: numeric\-string\.$#' - identifier: parameter.phpDocType - count: 4 - path: ../../../src/wp-includes/taxonomy.php From 3680fd90858a3d5a61e990b707d1f4fad120ae87 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 8 Sep 2026 09:57:38 +0000 Subject: [PATCH 5/5] Build/Test Tools: Ignore the action-return check of HookCallbackRule inline. `HookCallbackRule` objects to an action callback whose return type is not void. WordPress discards an action callback's return value, and core registers functions that happen to return one on actions as a matter of course, so the objection is not actionable here. Rather than matching the message away in `phpstan.neon.dist`, each of the fifty-two registrations now carries an inline `@phpstan-ignore` saying why, which keeps the decision next to the code it is about and lets a new registration be judged on its own. Co-authored-by: Pascal Birchler Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_013QJ9RphuttTR7cS5G4mU21 --- phpstan.neon.dist | 18 ----------------- src/wp-admin/customize.php | 2 ++ src/wp-admin/includes/admin-filters.php | 9 +++++++++ src/wp-admin/includes/class-wp-upgrader.php | 2 ++ src/wp-admin/includes/ms-admin-filters.php | 4 ++++ src/wp-admin/includes/update.php | 2 ++ .../class-wp-customize-setting.php | 1 + src/wp-includes/class-wp-recovery-mode.php | 1 + src/wp-includes/comment.php | 1 + src/wp-includes/cron.php | 2 ++ src/wp-includes/default-filters.php | 20 +++++++++++++++++++ src/wp-includes/ms-default-filters.php | 8 ++++++++ tests/phpstan/README.md | 6 +++--- 13 files changed, 55 insertions(+), 21 deletions(-) diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 1a3977055857b..cc4579365122b 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -142,21 +142,3 @@ parameters: message: '#^Parameter \#4 \$length of function substr_compare expects int, null given\.$#' path: src/wp-includes/html-api/class-wp-html-tag-processor.php reportUnmatched: false - - # szepeviktor/phpstan-wordpress's HookCallbackRule, registered in tests/phpstan/base.neon, - # also reports an action callback whose return type is not void. That is sound advice for a - # plugin, where a function returning a value is likely a filter callback registered on the - # wrong function, but core registers functions that happen to return something on actions - # as a matter of course: `wp_save_post_revision()` on `post_updated`, `redirect_canonical()` - # on `template_redirect`, `wp_delete_attachment()` on `importer_scheduled_cleanup`. WordPress - # discards an action callback's return value, so none of the 52 reported registrations is a - # defect, and changing each function's return type to satisfy the rule would change public - # API. The rule's other check, that `$accepted_args` agrees with the callback's signature, is - # kept: it is what the rule is registered for. - # - # The rule reports this under PHPStan's own `return.void` identifier rather than one of its - # own, so the message is matched instead of the identifier to leave PHPStan's `return.void` - # errors reported. - - - message: '#^Action callback returns .+ but should not return anything\.$#' - reportUnmatched: false diff --git a/src/wp-admin/customize.php b/src/wp-admin/customize.php index 75c0865b1f8eb..332d7cfc9f13a 100644 --- a/src/wp-admin/customize.php +++ b/src/wp-admin/customize.php @@ -110,8 +110,10 @@ $wp_scripts = new WP_Scripts(); $wp_scripts->registered = $registered; +// @phpstan-ignore return.void (WordPress discards an action callback's return value.) add_action( 'customize_controls_print_scripts', 'print_head_scripts', 20 ); add_action( 'customize_controls_print_footer_scripts', '_wp_footer_scripts' ); +// @phpstan-ignore return.void (WordPress discards an action callback's return value.) add_action( 'customize_controls_print_styles', 'print_admin_styles', 20 ); /** diff --git a/src/wp-admin/includes/admin-filters.php b/src/wp-admin/includes/admin-filters.php index 5337cc02c88c9..3f619d5df2664 100644 --- a/src/wp-admin/includes/admin-filters.php +++ b/src/wp-admin/includes/admin-filters.php @@ -11,6 +11,7 @@ add_action( 'admin_page_access_denied', 'wp_link_manager_disabled_message' ); // Dashboard hooks. +// @phpstan-ignore return.void (WordPress discards an action callback's return value.) add_action( 'activity_box_end', 'wp_dashboard_quota' ); add_action( 'welcome_panel', 'wp_welcome_panel' ); @@ -18,9 +19,13 @@ add_action( 'attachment_submitbox_misc_actions', 'attachment_submitbox_metadata' ); add_filter( 'plupload_init', 'wp_show_heic_upload_error' ); +// @phpstan-ignore return.void (WordPress discards an action callback's return value.) add_action( 'media_upload_image', 'wp_media_upload_handler' ); +// @phpstan-ignore return.void (WordPress discards an action callback's return value.) add_action( 'media_upload_audio', 'wp_media_upload_handler' ); +// @phpstan-ignore return.void (WordPress discards an action callback's return value.) add_action( 'media_upload_video', 'wp_media_upload_handler' ); +// @phpstan-ignore return.void (WordPress discards an action callback's return value.) add_action( 'media_upload_file', 'wp_media_upload_handler' ); add_action( 'post-plupload-upload-ui', 'media_upload_flash_bypass' ); @@ -57,10 +62,12 @@ } add_action( 'admin_print_scripts', 'print_emoji_detection_script' ); +// @phpstan-ignore return.void (WordPress discards an action callback's return value.) add_action( 'admin_print_scripts', 'print_head_scripts', 20 ); add_action( 'admin_print_footer_scripts', '_wp_footer_scripts' ); add_action( 'admin_enqueue_scripts', 'wp_enqueue_emoji_styles' ); add_action( 'admin_print_styles', 'print_emoji_styles' ); // Retained for backwards-compatibility. Unhooked by wp_enqueue_emoji_styles(). +// @phpstan-ignore return.void (WordPress discards an action callback's return value.) add_action( 'admin_print_styles', 'print_admin_styles', 20 ); add_action( 'admin_print_scripts-index.php', 'wp_localize_community_events' ); @@ -130,10 +137,12 @@ add_action( 'load-plugins.php', 'wp_plugin_update_rows', 20 ); // After wp_update_plugins() is called. add_action( 'load-themes.php', 'wp_theme_update_rows', 20 ); // After wp_update_themes() is called. +// @phpstan-ignore return.void (WordPress discards an action callback's return value.) add_action( 'admin_notices', 'update_nag', 3 ); add_action( 'admin_notices', 'deactivated_plugins_notice', 5 ); add_action( 'admin_notices', 'paused_plugins_notice', 5 ); add_action( 'admin_notices', 'paused_themes_notice', 5 ); +// @phpstan-ignore return.void (WordPress discards an action callback's return value.) add_action( 'admin_notices', 'maintenance_nag', 10 ); add_action( 'admin_notices', 'wp_recovery_mode_nag', 1 ); diff --git a/src/wp-admin/includes/class-wp-upgrader.php b/src/wp-admin/includes/class-wp-upgrader.php index ba27113ff73de..2005c1e231dd8 100644 --- a/src/wp-admin/includes/class-wp-upgrader.php +++ b/src/wp-admin/includes/class-wp-upgrader.php @@ -933,6 +933,7 @@ public function run( $options ) { * internally during actions, causing an error because * `WP_Upgrader::restore_temp_backup()` expects an array. */ + // @phpstan-ignore return.void (WordPress discards an action callback's return value.) add_action( 'shutdown', array( $this, 'restore_temp_backup' ), 10, 0 ); } $this->skin->error( $result ); @@ -950,6 +951,7 @@ public function run( $options ) { // Clean up the backup kept in the temporary backup directory. if ( ! empty( $options['hook_extra']['temp_backup'] ) ) { // Delete the backup on `shutdown` to avoid a PHP timeout. + // @phpstan-ignore return.void (WordPress discards an action callback's return value.) add_action( 'shutdown', array( $this, 'delete_temp_backup' ), 100, 0 ); } diff --git a/src/wp-admin/includes/ms-admin-filters.php b/src/wp-admin/includes/ms-admin-filters.php index b1d73825f1f4b..dd894ebfc72ea 100644 --- a/src/wp-admin/includes/ms-admin-filters.php +++ b/src/wp-admin/includes/ms-admin-filters.php @@ -29,11 +29,15 @@ add_filter( 'import_allow_create_users', 'check_import_new_users' ); // Notices hooks. +// @phpstan-ignore return.void (WordPress discards an action callback's return value.) add_action( 'admin_notices', 'site_admin_notice' ); +// @phpstan-ignore return.void (WordPress discards an action callback's return value.) add_action( 'network_admin_notices', 'site_admin_notice' ); // Update hooks. +// @phpstan-ignore return.void (WordPress discards an action callback's return value.) add_action( 'network_admin_notices', 'update_nag', 3 ); +// @phpstan-ignore return.void (WordPress discards an action callback's return value.) add_action( 'network_admin_notices', 'maintenance_nag', 10 ); // Network Admin hooks. diff --git a/src/wp-admin/includes/update.php b/src/wp-admin/includes/update.php index b0e998264fe06..bc74d0595453b 100644 --- a/src/wp-admin/includes/update.php +++ b/src/wp-admin/includes/update.php @@ -432,6 +432,7 @@ function wp_plugin_update_rows() { $plugins = array_keys( $plugins->response ); foreach ( $plugins as $plugin_file ) { + // @phpstan-ignore return.void (WordPress discards an action callback's return value.) add_action( "after_plugin_row_{$plugin_file}", 'wp_plugin_update_row', 10, 2 ); } } @@ -657,6 +658,7 @@ function wp_theme_update_rows() { $themes = array_keys( $themes->response ); foreach ( $themes as $theme ) { + // @phpstan-ignore return.void (WordPress discards an action callback's return value.) add_action( "after_theme_row_{$theme}", 'wp_theme_update_row', 10, 2 ); } } diff --git a/src/wp-includes/class-wp-customize-setting.php b/src/wp-includes/class-wp-customize-setting.php index 68dd8b1433857..e5c9615124da2 100644 --- a/src/wp-includes/class-wp-customize-setting.php +++ b/src/wp-includes/class-wp-customize-setting.php @@ -362,6 +362,7 @@ public function preview() { // If the setting does not need previewing now, defer to when it has a value to preview. if ( ! $needs_preview ) { if ( ! has_action( "customize_post_value_set_{$this->id}", array( $this, 'preview' ) ) ) { + // @phpstan-ignore return.void (WordPress discards an action callback's return value.) add_action( "customize_post_value_set_{$this->id}", array( $this, 'preview' ) ); } return false; diff --git a/src/wp-includes/class-wp-recovery-mode.php b/src/wp-includes/class-wp-recovery-mode.php index 8fa6bf22cbdea..b1cbf6af8442b 100644 --- a/src/wp-includes/class-wp-recovery-mode.php +++ b/src/wp-includes/class-wp-recovery-mode.php @@ -92,6 +92,7 @@ public function __construct() { public function initialize() { $this->is_initialized = true; + // @phpstan-ignore return.void (WordPress discards an action callback's return value.) add_action( 'wp_logout', array( $this, 'exit_recovery_mode' ) ); add_action( 'login_form_' . self::EXIT_ACTION, array( $this, 'handle_exit_recovery_mode' ) ); add_action( 'recovery_mode_clean_expired_keys', array( $this, 'clean_expired_keys' ) ); diff --git a/src/wp-includes/comment.php b/src/wp-includes/comment.php index 2a19d99651688..0c7f060bc935f 100644 --- a/src/wp-includes/comment.php +++ b/src/wp-includes/comment.php @@ -2815,6 +2815,7 @@ function wp_set_comment_status( $comment_id, $comment_status, $wp_error = false case 'approve': case '1': $status = '1'; + // @phpstan-ignore return.void (WordPress discards an action callback's return value.) add_action( 'wp_set_comment_status', 'wp_new_comment_notify_postauthor' ); break; case 'spam': diff --git a/src/wp-includes/cron.php b/src/wp-includes/cron.php index 1070ae4680b91..2f9b88082639e 100644 --- a/src/wp-includes/cron.php +++ b/src/wp-includes/cron.php @@ -1021,11 +1021,13 @@ function wp_cron(): void { if ( did_action( 'wp_loaded' ) ) { _wp_cron(); } else { + // @phpstan-ignore return.void (WordPress discards an action callback's return value.) add_action( 'wp_loaded', '_wp_cron', 20 ); } } elseif ( doing_action( 'shutdown' ) ) { _wp_cron(); } else { + // @phpstan-ignore return.void (WordPress discards an action callback's return value.) add_action( 'shutdown', '_wp_cron' ); } } diff --git a/src/wp-includes/default-filters.php b/src/wp-includes/default-filters.php index 6ba5d9fad90ed..f87fd014aef11 100644 --- a/src/wp-includes/default-filters.php +++ b/src/wp-includes/default-filters.php @@ -118,6 +118,7 @@ add_action( 'admin_init', 'wp_schedule_update_user_counts' ); add_action( 'wp_update_user_counts', 'wp_schedule_update_user_counts', 10, 0 ); foreach ( array( 'user_register', 'deleted_user' ) as $action ) { + // @phpstan-ignore return.void (WordPress discards an action callback's return value.) add_action( $action, 'wp_maybe_update_user_counts', 10, 0 ); } @@ -360,7 +361,9 @@ add_action( 'publish_future_post', 'check_and_publish_future_post', 10, 1 ); add_action( 'wp_head', 'wp_robots', 1 ); add_action( 'wp_head', 'print_emoji_detection_script', 7 ); +// @phpstan-ignore return.void (WordPress discards an action callback's return value.) add_action( 'wp_head', 'wp_print_styles', 8 ); +// @phpstan-ignore return.void (WordPress discards an action callback's return value.) add_action( 'wp_head', 'wp_print_head_scripts', 9 ); add_action( 'wp_head', 'wp_generator' ); add_action( 'wp_head', 'rel_canonical' ); @@ -394,7 +397,9 @@ // Login actions. add_action( 'login_head', 'wp_robots', 1 ); add_action( 'login_head', 'wp_resource_hints', 8 ); +// @phpstan-ignore return.void (WordPress discards an action callback's return value.) add_action( 'login_head', 'wp_print_head_scripts', 9 ); +// @phpstan-ignore return.void (WordPress discards an action callback's return value.) add_action( 'login_head', 'print_admin_styles', 9 ); add_action( 'login_head', 'wp_site_icon', 99 ); add_action( 'login_footer', 'wp_print_footer_scripts', 20 ); @@ -428,6 +433,7 @@ add_action( 'do_all_pings', 'do_all_pingbacks', 10, 0 ); add_action( 'do_all_pings', 'do_all_enclosures', 10, 0 ); add_action( 'do_all_pings', 'do_all_trackbacks', 10, 0 ); +// @phpstan-ignore return.void (WordPress discards an action callback's return value.) add_action( 'do_all_pings', 'generic_ping', 10, 0 ); // Disable pings (pingbacks, trackbacks, and ping service notifications) in non-production environments. @@ -437,6 +443,7 @@ add_action( 'do_robots', 'do_robots' ); add_action( 'do_favicon', 'do_favicon' ); +// @phpstan-ignore return.void (WordPress discards an action callback's return value.) add_action( 'wp_before_include_template', 'wp_start_template_enhancement_output_buffer', 1000 ); // Late priority to let `wp_template_enhancement_output_buffer` filters and `wp_finalized_template_enhancement_output_buffer` actions be registered. add_action( 'set_comment_cookies', 'wp_set_comment_cookies', 10, 3 ); add_action( 'sanitize_comment_cookies', 'sanitize_comment_cookies' ); @@ -446,6 +453,7 @@ add_action( 'shutdown', 'wp_ob_end_flush_all', 1 ); // Create a revision whenever a post is updated. add_action( 'wp_after_insert_post', 'wp_save_post_revision_on_insert', 9, 3 ); +// @phpstan-ignore return.void (WordPress discards an action callback's return value.) add_action( 'post_updated', 'wp_save_post_revision', 10, 1 ); add_action( 'publish_post', '_publish_post_hook', 5, 1 ); add_action( 'transition_post_status', '_transition_post_status', 5, 3 ); @@ -467,7 +475,9 @@ // Cron tasks. add_action( 'wp_scheduled_delete', 'wp_scheduled_delete' ); add_action( 'wp_scheduled_auto_draft_delete', 'wp_delete_auto_drafts' ); +// @phpstan-ignore return.void (WordPress discards an action callback's return value.) add_action( 'importer_scheduled_cleanup', 'wp_delete_attachment' ); +// @phpstan-ignore return.void (WordPress discards an action callback's return value.) add_action( 'upgrader_scheduled_cleanup', 'wp_delete_attachment' ); add_action( 'delete_expired_transients', 'delete_expired_transients' ); @@ -536,7 +546,9 @@ add_action( 'wp_update_comment_type_batch', '_wp_batch_update_comment_type' ); // Email notifications. +// @phpstan-ignore return.void (WordPress discards an action callback's return value.) add_action( 'comment_post', 'wp_new_comment_notify_moderator' ); +// @phpstan-ignore return.void (WordPress discards an action callback's return value.) add_action( 'comment_post', 'wp_new_comment_notify_postauthor' ); add_action( 'rest_insert_comment', 'wp_new_comment_via_rest_notify_postauthor' ); add_action( 'rest_insert_comment', 'wp_notify_note_mentions', 10, 3 ); @@ -560,6 +572,7 @@ add_action( 'init', '_wp_connectors_init', 15 ); // Sitemaps actions. +// @phpstan-ignore return.void (WordPress discards an action callback's return value.) add_action( 'init', 'wp_sitemaps_get_server' ); /** @@ -688,6 +701,7 @@ add_action( 'change_locale', 'create_initial_taxonomies' ); // Canonical. +// @phpstan-ignore return.void (WordPress discards an action callback's return value.) add_action( 'template_redirect', 'redirect_canonical' ); add_action( 'template_redirect', 'wp_redirect_admin_locations', 1000 ); @@ -716,12 +730,16 @@ // Admin Bar. // Don't remove. Wrong way to disable. +// @phpstan-ignore return.void (WordPress discards an action callback's return value.) add_action( 'template_redirect', '_wp_admin_bar_init', 0 ); +// @phpstan-ignore return.void (WordPress discards an action callback's return value.) add_action( 'admin_init', '_wp_admin_bar_init' ); add_action( 'wp_enqueue_scripts', 'wp_enqueue_admin_bar_bump_styles' ); add_action( 'wp_enqueue_scripts', 'wp_enqueue_admin_bar_header_styles' ); add_action( 'admin_enqueue_scripts', 'wp_enqueue_admin_bar_header_styles' ); +// @phpstan-ignore return.void (WordPress discards an action callback's return value.) add_action( 'before_signup_header', '_wp_admin_bar_init' ); +// @phpstan-ignore return.void (WordPress discards an action callback's return value.) add_action( 'activate_header', '_wp_admin_bar_init' ); add_action( 'wp_body_open', 'wp_admin_bar_render', 0 ); add_action( 'wp_footer', 'wp_admin_bar_render', 1000 ); // Back-compat for themes not using `wp_body_open`. @@ -745,7 +763,9 @@ add_action( 'embed_head', 'print_emoji_detection_script' ); add_action( 'embed_head', 'wp_enqueue_embed_styles', 9 ); add_action( 'embed_head', 'print_embed_styles' ); // Retained for backwards-compatibility. Unhooked by wp_enqueue_embed_styles(). +// @phpstan-ignore return.void (WordPress discards an action callback's return value.) add_action( 'embed_head', 'wp_print_head_scripts', 20 ); +// @phpstan-ignore return.void (WordPress discards an action callback's return value.) add_action( 'embed_head', 'wp_print_styles', 20 ); add_action( 'embed_head', 'wp_robots' ); add_action( 'embed_head', 'rel_canonical' ); diff --git a/src/wp-includes/ms-default-filters.php b/src/wp-includes/ms-default-filters.php index 8682d48e18e45..439683a80c89a 100644 --- a/src/wp-includes/ms-default-filters.php +++ b/src/wp-includes/ms-default-filters.php @@ -23,9 +23,12 @@ // Users. add_filter( 'wpmu_validate_user_signup', 'signup_nonce_check' ); add_action( 'init', 'maybe_add_existing_user_to_blog' ); +// @phpstan-ignore return.void (WordPress discards an action callback's return value.) add_action( 'wpmu_new_user', 'newuser_notify_siteadmin' ); add_action( 'wpmu_activate_user', 'add_new_user_to_blog', 10, 3 ); +// @phpstan-ignore return.void (WordPress discards an action callback's return value.) add_action( 'wpmu_activate_user', 'wpmu_welcome_user_notification', 10, 3 ); +// @phpstan-ignore return.void (WordPress discards an action callback's return value.) add_action( 'after_signup_user', 'wpmu_signup_user_notification', 10, 4 ); add_action( 'network_site_new_created_user', 'wp_send_new_user_notifications' ); add_action( 'network_site_users_created_user', 'wp_send_new_user_notifications' ); @@ -38,7 +41,9 @@ // Blogs. add_filter( 'wpmu_validate_blog_signup', 'signup_nonce_check' ); +// @phpstan-ignore return.void (WordPress discards an action callback's return value.) add_action( 'wpmu_activate_blog', 'wpmu_welcome_notification', 10, 5 ); +// @phpstan-ignore return.void (WordPress discards an action callback's return value.) add_action( 'after_signup_site', 'wpmu_signup_blog_notification', 10, 7 ); add_filter( 'wp_normalize_site_data', 'wp_normalize_site_data', 10, 1 ); add_action( 'wp_validate_site_data', 'wp_validate_site_data', 10, 3 ); @@ -48,9 +53,12 @@ add_action( 'wp_insert_site', 'wp_maybe_transition_site_statuses_on_update', 10, 1 ); add_action( 'wp_update_site', 'wp_maybe_transition_site_statuses_on_update', 10, 2 ); add_action( 'wp_update_site', 'wp_maybe_clean_new_site_cache_on_update', 10, 2 ); +// @phpstan-ignore return.void (WordPress discards an action callback's return value.) add_action( 'wp_initialize_site', 'wp_initialize_site', 10, 2 ); add_action( 'wp_initialize_site', 'wpmu_log_new_registrations', 100, 2 ); +// @phpstan-ignore return.void (WordPress discards an action callback's return value.) add_action( 'wp_initialize_site', 'newblog_notify_siteadmin', 100, 1 ); +// @phpstan-ignore return.void (WordPress discards an action callback's return value.) add_action( 'wp_uninitialize_site', 'wp_uninitialize_site', 10, 1 ); add_action( 'update_blog_public', 'wp_update_blog_public_option_on_site_update', 1, 2 ); diff --git a/tests/phpstan/README.md b/tests/phpstan/README.md index 4fab23d06d22c..6dc5935f2c2e8 100644 --- a/tests/phpstan/README.md +++ b/tests/phpstan/README.md @@ -112,9 +112,9 @@ One consequence worth knowing: because a hook's documentation may live in a diff What the package provides falls into four groups, and only the first is loaded: -- **Used, because a docblock cannot express what they do.** `ShortcodeAttsDynamicFunctionReturnTypeExtension` types the result of `shortcode_atts()` from the defaults passed to it, the same merge that `wp_parse_args()` performs. `HookCallbackRule` checks a callback registered with `add_action()` or `add_filter()` against its registration: that `$accepted_args` agrees with the parameters the callback declares, and that a filter callback returns a value. Its third check, that an action callback returns nothing, is switched off in [`phpstan.neon.dist`](../../phpstan.neon.dist), where the reason is recorded. `HookDocsRule` checks that the type each `@param` of a hook docblock documents accepts the value the hook passes. That documented type is what `apply_filters()` is typed from, so a wrong one misleads every caller. It reads only a docblock written at the call, so a hook documented elsewhere through a reference comment is not checked. +- **Used, because a docblock cannot express what they do.** `ShortcodeAttsDynamicFunctionReturnTypeExtension` types the result of `shortcode_atts()` from the defaults passed to it, the same merge that `wp_parse_args()` performs. `HookCallbackRule` checks a callback registered with `add_action()` or `add_filter()` against its registration: that `$accepted_args` agrees with the parameters the callback declares, and that a filter callback returns a value. Its third check, that an action callback returns nothing, does not fit core, which registers functions that happen to return a value on actions as a matter of course and discards the value; each such registration carries an inline `@phpstan-ignore` saying so. `HookDocsRule` checks that the type each `@param` of a hook docblock documents accepts the value the hook passes. That documented type is what `apply_filters()` is typed from, so a wrong one misleads every caller. It reads only a docblock written at the call, so a hook documented elsewhere through a reference comment is not checked. - **Not used, because core's own versions do the same and more.** `HookDocsVisitor`, `HookDocBlock` and `ApplyFiltersDynamicFunctionReturnTypeExtension` are what the [hook documentation](#hook-documentation) extensions were adapted from. Core's resolve the "This filter is documented in" reference comments, which the originals do not, and core's rules already check the `@param` counts that `HookDocsRule` also checks, so only its type check adds anything. -- **Not used, because a docblock does the same.** `EscSql`, `NormalizeWhitespace`, `StripslashesFromStringsOnly`, `SlashitFunctions`, `WpParseUrl` and `WpSlash` each narrow one function's return type from its arguments in a way a conditional `@phpstan-return` expresses. Where core has that docblock already, as `wp_parse_url()`, `wp_slash()` and `stripslashes_from_strings_only()` do, loading the extension changes nothing; where it does not yet, as `esc_sql()` and `trailingslashit()`, the docblock is the fix to make. A docblock in core also types the function for every plugin, since the stubs are generated from core. phpstan-wordpress itself has gone that way: its 2.x branch dropped the extensions it had for `get_post()`, `get_terms()`, `current_time()`, `wp_die()`, `is_wp_error()` and others in favor of types carried by the stubs, and the [function map](https://github.com/php-stubs/wordpress-stubs/blob/master/functionMap.php) those stubs apply on top of core's docblocks is a list of the ones core could adopt. +- **Not used, because a docblock does the same.** `EscSql`, `NormalizeWhitespace`, `StripslashesFromStringsOnly`, `SlashitFunctions`, `WpParseUrl` and `WpSlash` each narrow one function's return type from its arguments in a way a conditional `@phpstan-return` expresses. Where core has that docblock already, as `wp_parse_url()`, `wp_slash()` and `stripslashes_from_strings_only()` do, loading the extension changes nothing; where it does not yet, as `esc_sql()`, the docblock is the fix to make. A docblock in core also types the function for every plugin, since the stubs are generated from core. phpstan-wordpress itself has gone that way: its 2.x branch dropped the extensions it had for `get_post()`, `get_terms()`, `current_time()`, `wp_die()`, `is_wp_error()` and others in favor of types carried by the stubs. The [function map](https://github.com/php-stubs/wordpress-stubs/blob/master/functionMap.php) those stubs applied on top of core's docblocks has since been brought into core itself, as `@phpstan-param` and `@phpstan-return` tags on the functions it named, so the same types now reach core's own analysis, the stubs, and every plugin from one place. - **Not applicable to core.** `WpConstantFetchRule` discourages reading a constant such as `MULTISITE` where a function exists to read it, but core is where those functions read them. `AssertWpErrorTypeSpecifyingExtension` narrows the argument of `assertWPError()` in tests, which are not analyzed; when they are, `@phpstan-assert` on the methods themselves is the way to express it. ### Errors these rules report @@ -135,7 +135,7 @@ The rules loaded from szepeviktor/phpstan-wordpress report under PHPStan's own i | --- | --- | --- | | `arguments.count` | `Callback expects N parameters, $accepted_args is set to M.` | The `$accepted_args` of an `add_action()` or `add_filter()` call does not fit the callback's signature. Fewer than the callback requires is an `ArgumentCountError` when the hook fires; more than it declares is misleading, and usually a leftover from an earlier signature. | | `return.missing` | `Filter callback return statement is missing.` | A filter callback returns nothing, so the value being filtered becomes `null`. | -| `return.void` | `Action callback returns X but should not return anything.` | An action callback returns a value. Ignored for core in `phpstan.neon.dist`; see the note there. | +| `return.void` | `Action callback returns X but should not return anything.` | An action callback returns a value. WordPress discards it, so where core registers such a function deliberately the call carries an inline `@phpstan-ignore` saying so. | | `parameter.phpDocType` | `@param X $name does not accept actual type of parameter: Y.` | The type a hook docblock documents for a parameter does not accept the value the hook passes. Fix the docblock, or the value; the documented type is what callbacks and the `apply_filters()` return type rely on. | | `paramTag.count` | `Expected N @param tags, found M.` | The same mismatch `wordpress.hookParamCountMismatch` reports, for a docblock written at the call. | | `phpDoc.parseError` | `One or more @param tags has an invalid name or invalid syntax.` | A `@param` tag in a hook docblock could not be parsed, or is named `$this`. |