From 29e0353e984a06d70613c4962680a1e7c747a192 Mon Sep 17 00:00:00 2001 From: Pascal Birchler Date: Thu, 1 Oct 2026 08:53:28 +0000 Subject: [PATCH 01/17] Docs: Add PHPStan integer ranges and literal types. Adds integer ranges such as `int<0, max>`, and unions of literal values, where a function only returns or accepts those values. Compared to the function map in php-stubs/wordpress-stubs, `sanitize_title_with_dashes()` also accepts the `'query'` context, `WP_Locale::$word_count_type` can be `null` before `init()` runs, and the link category IDs returned by `get_bookmark_field()` and `sanitize_bookmark_field()` can be `0`, since they come from `absint()`. The map's narrowing of the nonce `$action` and `wp_hash()`'s `$scheme` is left out, as core passes wider types to both. See #65817. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VieGtSQtUoVfHiGwTmkvtc --- src/wp-admin/includes/bookmark.php | 6 ++++++ src/wp-admin/includes/class-custom-image-header.php | 2 ++ src/wp-admin/includes/taxonomy.php | 2 ++ src/wp-admin/includes/template.php | 2 ++ src/wp-includes/bookmark.php | 6 ++++++ src/wp-includes/class-wp-locale.php | 4 ++++ src/wp-includes/class-wpdb.php | 2 ++ src/wp-includes/comment.php | 2 ++ src/wp-includes/formatting.php | 5 +++++ src/wp-includes/functions.php | 12 ++++++++++++ src/wp-includes/l10n.php | 2 ++ src/wp-includes/load.php | 4 ++++ src/wp-includes/pluggable.php | 2 ++ src/wp-includes/plugin.php | 4 ++++ src/wp-includes/post-template.php | 2 ++ src/wp-includes/rest-api.php | 2 ++ src/wp-includes/script-loader.php | 2 ++ src/wp-includes/user.php | 2 ++ tests/phpstan/baselines/nullCoalesce.property.neon | 5 ----- 19 files changed, 63 insertions(+), 5 deletions(-) diff --git a/src/wp-admin/includes/bookmark.php b/src/wp-admin/includes/bookmark.php index aae87b9abc60f..ddb43b3177bf2 100644 --- a/src/wp-admin/includes/bookmark.php +++ b/src/wp-admin/includes/bookmark.php @@ -12,6 +12,8 @@ * @since 2.0.0 * * @return int The link ID on success. The value 0 on failure. + * + * @phpstan-return int<0, max> */ function add_link() { return edit_link(); @@ -24,6 +26,8 @@ function add_link() { * * @param int $link_id Optional. ID of the link to edit. Default 0. * @return int The link ID on success. The value 0 on failure. + * + * @phpstan-return int<0, max> */ function edit_link( $link_id = 0 ) { if ( ! current_user_can( 'manage_links' ) ) { @@ -301,6 +305,8 @@ function wp_set_link_cats( $link_id = 0, $link_categories = array() ) { * * @param array $linkdata Link data to update. See wp_insert_link() for accepted arguments. * @return int The updated link ID on success. The value 0 on failure. + * + * @phpstan-return int<0, max> */ function wp_update_link( $linkdata ) { $link_id = (int) $linkdata['link_id']; diff --git a/src/wp-admin/includes/class-custom-image-header.php b/src/wp-admin/includes/class-custom-image-header.php index 367ba04cdc340..8981ce60f4c6e 100644 --- a/src/wp-admin/includes/class-custom-image-header.php +++ b/src/wp-admin/includes/class-custom-image-header.php @@ -310,6 +310,8 @@ public function process_default_headers() { * * @param string $type The header type. One of 'default' (for the Uploaded Images control) * or 'uploaded' (for the Uploaded Images control). + * + * @phpstan-param 'default'|'uploaded' $type */ public function show_header_selector( $type = 'default' ) { if ( 'default' === $type ) { diff --git a/src/wp-admin/includes/taxonomy.php b/src/wp-admin/includes/taxonomy.php index dbdab1af4fab8..5673fb9635499 100644 --- a/src/wp-admin/includes/taxonomy.php +++ b/src/wp-admin/includes/taxonomy.php @@ -188,6 +188,8 @@ function wp_insert_category( $catarr, $wp_error = false ) { * * @param array $catarr The 'cat_ID' value is required. All other keys are optional. * @return int|false The ID number of the new or updated Category on success. Zero or FALSE on failure. + * + * @phpstan-return int<0, max>|false */ function wp_update_category( $catarr ) { $cat_id = (int) $catarr['cat_ID']; diff --git a/src/wp-admin/includes/template.php b/src/wp-admin/includes/template.php index 93428dcc4ae6d..dbfb3c5fb3919 100644 --- a/src/wp-admin/includes/template.php +++ b/src/wp-admin/includes/template.php @@ -1302,6 +1302,8 @@ function _get_plugin_from_callback( $callback ) { * Often this is the object that's the focus of the current screen, * for example a `WP_Post` or `WP_Comment` object. * @return int Number of meta_boxes. + * + * @phpstan-return int<0, max> */ function do_meta_boxes( $screen, $context, $data_object ) { global $wp_meta_boxes; diff --git a/src/wp-includes/bookmark.php b/src/wp-includes/bookmark.php index e1c8a6e522a00..c1e6e79d280c3 100644 --- a/src/wp-includes/bookmark.php +++ b/src/wp-includes/bookmark.php @@ -81,6 +81,9 @@ function get_bookmark( $bookmark, $output = OBJECT, $filter = 'raw' ) { * @param int $bookmark The bookmark ID to get field. * @param string $context Optional. The context of how the field will be used. Default 'display'. * @return string|WP_Error + * + * @phpstan-param 'link_id'|'link_url'|'link_name'|'link_image'|'link_target'|'link_description'|'link_visible'|'link_owner'|'link_rating'|'link_updated'|'link_rel'|'link_notes'|'link_rss'|'link_category' $field + * @phpstan-return array>|int|string */ function get_bookmark_field( $field, $bookmark, $context = 'display' ) { $bookmark = (int) $bookmark; @@ -403,6 +406,9 @@ function sanitize_bookmark( $bookmark, $context = 'display' ) { * @param string $context How to filter the field value. Accepts 'raw', 'edit', 'db', * 'display', 'attribute', or 'js'. Default 'display'. * @return mixed The filtered value. + * + * @phpstan-param 'link_id'|'link_url'|'link_name'|'link_image'|'link_target'|'link_description'|'link_visible'|'link_owner'|'link_rating'|'link_updated'|'link_rel'|'link_notes'|'link_rss'|'link_category' $field + * @phpstan-return array>|int|string */ function sanitize_bookmark_field( $field, $value, $bookmark_id, $context ) { $int_fields = array( 'link_id', 'link_rating' ); diff --git a/src/wp-includes/class-wp-locale.php b/src/wp-includes/class-wp-locale.php index e18c0c4f1d897..da9a5aeeb6bd5 100644 --- a/src/wp-includes/class-wp-locale.php +++ b/src/wp-includes/class-wp-locale.php @@ -119,6 +119,8 @@ class WP_Locale { * * @since 6.2.0 * @var string + * + * @phpstan-var 'characters_excluding_spaces'|'characters_including_spaces'|'words'|null */ public $word_count_type; @@ -442,6 +444,8 @@ public function get_list_item_separator() { * * @return string Localized word count type. Possible values are `characters_excluding_spaces`, * `characters_including_spaces`, or `words`. Defaults to `words`. + * + * @phpstan-return 'characters_excluding_spaces'|'characters_including_spaces'|'words' */ public function get_word_count_type() { diff --git a/src/wp-includes/class-wpdb.php b/src/wp-includes/class-wpdb.php index 869111efd0797..bbec5752c3d7e 100644 --- a/src/wp-includes/class-wpdb.php +++ b/src/wp-includes/class-wpdb.php @@ -3098,6 +3098,8 @@ public function get_var( $query = null, $x = 0, $y = 0 ) { * ) * : null * ) + * + * @phpstan-param int<0, max> $y */ public function get_row( $query = null, $output = OBJECT, $y = 0 ) { $this->func_call = "\$db->get_row(\"$query\",$output,$y)"; diff --git a/src/wp-includes/comment.php b/src/wp-includes/comment.php index 9ad18180b78e9..b56db0c16477c 100644 --- a/src/wp-includes/comment.php +++ b/src/wp-includes/comment.php @@ -1899,6 +1899,8 @@ function wp_unspam_comment( $comment_id ) { * * @param int|WP_Comment $comment_id Comment ID or WP_Comment object * @return string|false Status might be 'trash', 'approved', 'unapproved', 'spam'. False on failure. + * + * @phpstan-return 'approved'|'spam'|'trash'|'unapproved'|false */ function wp_get_comment_status( $comment_id ) { $comment = get_comment( $comment_id ); diff --git a/src/wp-includes/formatting.php b/src/wp-includes/formatting.php index c39beaf7b1d68..f03132fcf8971 100644 --- a/src/wp-includes/formatting.php +++ b/src/wp-includes/formatting.php @@ -2278,6 +2278,8 @@ function sanitize_title_for_query( $title ) { * When set to 'save', additional entities are converted to hyphens * or stripped entirely. Default 'display'. * @return string The sanitized title. + * + * @phpstan-param 'display'|'save'|'query' $context */ function sanitize_title_with_dashes( $title, $raw_title = '', $context = 'display' ) { $title = strip_tags( $title ); @@ -2821,6 +2823,7 @@ function format_to_edit( $content, $rich_text = false ) { * @param int $threshold Digit places number needs to be to not have zeros added. * @return string Adds leading zeros to number if needed. * + * @phpstan-param int<0, max> $threshold * @phpstan-return ( * $threshold is 0 * ? lowercase-string&non-empty-string&numeric-string @@ -2990,6 +2993,8 @@ function urldecode_deep( $value ) { * @param string $email_address Email address. * @param int $hex_encoding Optional. Set to 1 to enable hex encoding. * @return string Converted email address. + * + * @phpstan-param 0|1 $hex_encoding */ function antispambot( $email_address, $hex_encoding = 0 ) { $obfuscated = ''; diff --git a/src/wp-includes/functions.php b/src/wp-includes/functions.php index 393f50a3aad58..a32c56f802244 100644 --- a/src/wp-includes/functions.php +++ b/src/wp-includes/functions.php @@ -1034,6 +1034,8 @@ function wp_get_http_headers( $url, $deprecated = false ) { * @global string $previousday The day of the previous post in the loop. * * @return int 1 when new day, 0 if not a new day. + * + * @phpstan-return 0|1 */ function is_new_day() { global $currentday, $previousday; @@ -4475,6 +4477,8 @@ function _wp_die_process_input( $message, $title = '', $args = array() ) { * @param int $depth Optional. Maximum depth to walk through $value. Must be * greater than 0. Default 512. * @return string|false The JSON encoded string, or false if it cannot be encoded. + * + * @phpstan-param int<1, max> $depth */ function wp_json_encode( $value, $flags = 0, $depth = 512 ) { $json = json_encode( $value, $flags, $depth ); @@ -6245,6 +6249,8 @@ function _doing_it_wrong( $function_name, $message, $version ) { * before passing to this function to avoid being stripped {@see wp_kses()}. * @param int $error_level Optional. The designated error type for this error. * Only works with E_USER family of constants. Default E_USER_NOTICE. + * + * @phpstan-param \E_USER_ERROR|\E_USER_WARNING|\E_USER_NOTICE|\E_USER_DEPRECATED $error_level */ function wp_trigger_error( $function_name, $message, $error_level = E_USER_NOTICE ) { /** @@ -7192,6 +7198,8 @@ function __return_false() { // phpcs:ignore WordPress.NamingConventions.ValidFun * @since 3.0.0 * * @return int 0. + * + * @phpstan-return 0 */ function __return_zero() { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.FunctionDoubleUnderscore,PHPCompatibility.FunctionNameRestrictions.ReservedFunctionNames.FunctionDoubleUnderscore return 0; @@ -7205,6 +7213,8 @@ function __return_zero() { // phpcs:ignore WordPress.NamingConventions.ValidFunc * @since 3.0.0 * * @return array Empty array. + * + * @phpstan-return array{} */ function __return_empty_array() { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.FunctionDoubleUnderscore,PHPCompatibility.FunctionNameRestrictions.ReservedFunctionNames.FunctionDoubleUnderscore return array(); @@ -7233,6 +7243,8 @@ function __return_null() { // phpcs:ignore WordPress.NamingConventions.ValidFunc * @see __return_null() * * @return string Empty string. + * + * @phpstan-return '' */ function __return_empty_string() { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.FunctionDoubleUnderscore,PHPCompatibility.FunctionNameRestrictions.ReservedFunctionNames.FunctionDoubleUnderscore return ''; diff --git a/src/wp-includes/l10n.php b/src/wp-includes/l10n.php index 7b9d2652d41dd..67b7b6778658d 100644 --- a/src/wp-includes/l10n.php +++ b/src/wp-includes/l10n.php @@ -2066,6 +2066,8 @@ function wp_get_list_item_separator() { * * @return string Locale-specific word count type. Possible values are `characters_excluding_spaces`, * `characters_including_spaces`, or `words`. Defaults to `words`. + * + * @phpstan-return 'characters_excluding_spaces'|'characters_including_spaces'|'words' */ function wp_get_word_count_type() { global $wp_locale; diff --git a/src/wp-includes/load.php b/src/wp-includes/load.php index 1558a44189226..6ac13f24b8268 100644 --- a/src/wp-includes/load.php +++ b/src/wp-includes/load.php @@ -11,6 +11,8 @@ * @since 4.4.0 * * @return string The HTTP protocol. Default: HTTP/1.0. + * + * @phpstan-return 'HTTP/1.0'|'HTTP/1.1'|'HTTP/2'|'HTTP/2.0'|'HTTP/3' */ function wp_get_server_protocol() { $protocol = $_SERVER['SERVER_PROTOCOL'] ?? ''; @@ -1509,6 +1511,8 @@ function absint( $maybeint ): int { * @global int $blog_id * * @return int Site ID. + * + * @phpstan-return int<0, max> */ function get_current_blog_id() { global $blog_id; diff --git a/src/wp-includes/pluggable.php b/src/wp-includes/pluggable.php index 4126f25d7c0fc..5361d8dacb3a8 100644 --- a/src/wp-includes/pluggable.php +++ b/src/wp-includes/pluggable.php @@ -2477,6 +2477,8 @@ function wp_nonce_tick( $action = -1 ) { * @return int|false 1 if the nonce is valid and generated between 0-12 hours ago, * 2 if the nonce is valid and generated between 12-24 hours ago. * False if the nonce is invalid. + * + * @phpstan-return 1|2|false */ function wp_verify_nonce( $nonce, $action = -1 ) { $nonce = (string) $nonce; diff --git a/src/wp-includes/plugin.php b/src/wp-includes/plugin.php index a3e9afacdb8dc..138ae24e3279a 100644 --- a/src/wp-includes/plugin.php +++ b/src/wp-includes/plugin.php @@ -423,6 +423,8 @@ function doing_filter( $hook_name = null ) { * * @param string $hook_name The name of the filter hook. * @return int The number of times the filter hook has been applied. + * + * @phpstan-return int<0, max> */ function did_filter( $hook_name ) { global $wp_filters; @@ -702,6 +704,8 @@ function doing_action( $hook_name = null ) { * * @param string $hook_name The name of the action hook. * @return int The number of times the action hook has been fired. + * + * @phpstan-return int<0, max> */ function did_action( $hook_name ) { global $wp_actions; diff --git a/src/wp-includes/post-template.php b/src/wp-includes/post-template.php index b817653aaf0ec..7933818aba5ff 100644 --- a/src/wp-includes/post-template.php +++ b/src/wp-includes/post-template.php @@ -2054,6 +2054,8 @@ function wp_post_revision_title_expanded( $revision, $link = true ) { * * @param int|WP_Post $post Optional. Post ID or WP_Post object. Default is global $post. * @param string $type 'all' (default), 'revision' or 'autosave' + * + * @phpstan-param 'all'|'revision'|'autosave' $type */ function wp_list_post_revisions( $post = 0, $type = 'all' ) { $post = get_post( $post ); diff --git a/src/wp-includes/rest-api.php b/src/wp-includes/rest-api.php index 36b5b23de9f00..01cc25a798e32 100644 --- a/src/wp-includes/rest-api.php +++ b/src/wp-includes/rest-api.php @@ -1436,6 +1436,8 @@ function rest_get_date_with_gmt( $date, $is_utc = false ) { * @since 4.7.0 * * @return int 401 if the user is not logged in, 403 if the user is logged in. + * + * @phpstan-return 401|403 */ function rest_authorization_required_code() { return is_user_logged_in() ? 403 : 401; diff --git a/src/wp-includes/script-loader.php b/src/wp-includes/script-loader.php index 6b01efa1718ce..cfa3525fe6ba9 100644 --- a/src/wp-includes/script-loader.php +++ b/src/wp-includes/script-loader.php @@ -685,6 +685,8 @@ function wp_default_packages( $scripts ) { * * @param string $type The type of suffix to retrieve. * @return string The script suffix. + * + * @phpstan-return ''|'.min' */ function wp_scripts_get_suffix( $type = '' ) { static $suffixes; diff --git a/src/wp-includes/user.php b/src/wp-includes/user.php index 92d091165525b..4f0c433aa9f3c 100644 --- a/src/wp-includes/user.php +++ b/src/wp-includes/user.php @@ -722,6 +722,8 @@ function count_many_users_posts( $users, $post_type = 'post', $public_only = fal * @since MU (3.0.0) * * @return int The current user's ID, or 0 if no user is logged in. + * + * @phpstan-return int<0, max> */ function get_current_user_id() { if ( ! function_exists( 'wp_get_current_user' ) ) { diff --git a/tests/phpstan/baselines/nullCoalesce.property.neon b/tests/phpstan/baselines/nullCoalesce.property.neon index 431dd45d9d3c9..e4090e3bf9b3f 100644 --- a/tests/phpstan/baselines/nullCoalesce.property.neon +++ b/tests/phpstan/baselines/nullCoalesce.property.neon @@ -18,11 +18,6 @@ parameters: ignoreErrors: - - - message: '#^Property WP_Locale\:\:\$word_count_type \(string\) on left side of \?\? is not nullable\.$#' - identifier: nullCoalesce.property - count: 1 - path: ../../../src/wp-includes/class-wp-locale.php - message: '#^Property WP_Query\:\:\$max_num_pages \(int\) on left side of \?\? is not nullable\.$#' identifier: nullCoalesce.property From e729b55dad2e04b72a3467f31e0b098d3f82c939 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 14:40:58 +0000 Subject: [PATCH 02/17] Docs: Address review feedback on integer range and literal types. Keep the return type of sanitize_bookmark_field() and get_bookmark_field() mixed for fields other than link_category, link_id and link_rating, since the raw context and the field filters can return any value. Update the canonical @return and @var tags to match the PHPStan types, and use non-negative-int and positive-int, the named forms core already uses most, instead of int<0, max> and int<1, max>. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015BuoHZQvJMvPEYp4Sfjgvd --- src/wp-admin/includes/bookmark.php | 6 +++--- src/wp-admin/includes/taxonomy.php | 2 +- src/wp-admin/includes/template.php | 2 +- src/wp-includes/bookmark.php | 6 +++--- src/wp-includes/class-wp-locale.php | 2 +- src/wp-includes/class-wpdb.php | 2 +- src/wp-includes/formatting.php | 2 +- src/wp-includes/functions.php | 2 +- src/wp-includes/load.php | 2 +- src/wp-includes/plugin.php | 4 ++-- src/wp-includes/user.php | 2 +- 11 files changed, 16 insertions(+), 16 deletions(-) diff --git a/src/wp-admin/includes/bookmark.php b/src/wp-admin/includes/bookmark.php index ddb43b3177bf2..a892a3a836a18 100644 --- a/src/wp-admin/includes/bookmark.php +++ b/src/wp-admin/includes/bookmark.php @@ -13,7 +13,7 @@ * * @return int The link ID on success. The value 0 on failure. * - * @phpstan-return int<0, max> + * @phpstan-return non-negative-int */ function add_link() { return edit_link(); @@ -27,7 +27,7 @@ function add_link() { * @param int $link_id Optional. ID of the link to edit. Default 0. * @return int The link ID on success. The value 0 on failure. * - * @phpstan-return int<0, max> + * @phpstan-return non-negative-int */ function edit_link( $link_id = 0 ) { if ( ! current_user_can( 'manage_links' ) ) { @@ -306,7 +306,7 @@ function wp_set_link_cats( $link_id = 0, $link_categories = array() ) { * @param array $linkdata Link data to update. See wp_insert_link() for accepted arguments. * @return int The updated link ID on success. The value 0 on failure. * - * @phpstan-return int<0, max> + * @phpstan-return non-negative-int */ function wp_update_link( $linkdata ) { $link_id = (int) $linkdata['link_id']; diff --git a/src/wp-admin/includes/taxonomy.php b/src/wp-admin/includes/taxonomy.php index 5673fb9635499..1333416a7b79e 100644 --- a/src/wp-admin/includes/taxonomy.php +++ b/src/wp-admin/includes/taxonomy.php @@ -189,7 +189,7 @@ function wp_insert_category( $catarr, $wp_error = false ) { * @param array $catarr The 'cat_ID' value is required. All other keys are optional. * @return int|false The ID number of the new or updated Category on success. Zero or FALSE on failure. * - * @phpstan-return int<0, max>|false + * @phpstan-return non-negative-int|false */ function wp_update_category( $catarr ) { $cat_id = (int) $catarr['cat_ID']; diff --git a/src/wp-admin/includes/template.php b/src/wp-admin/includes/template.php index dbfb3c5fb3919..d949e8422417f 100644 --- a/src/wp-admin/includes/template.php +++ b/src/wp-admin/includes/template.php @@ -1303,7 +1303,7 @@ function _get_plugin_from_callback( $callback ) { * for example a `WP_Post` or `WP_Comment` object. * @return int Number of meta_boxes. * - * @phpstan-return int<0, max> + * @phpstan-return non-negative-int */ function do_meta_boxes( $screen, $context, $data_object ) { global $wp_meta_boxes; diff --git a/src/wp-includes/bookmark.php b/src/wp-includes/bookmark.php index c1e6e79d280c3..f4d5a379f732e 100644 --- a/src/wp-includes/bookmark.php +++ b/src/wp-includes/bookmark.php @@ -80,10 +80,10 @@ function get_bookmark( $bookmark, $output = OBJECT, $filter = 'raw' ) { * @param string $field The name of the data field to return. * @param int $bookmark The bookmark ID to get field. * @param string $context Optional. The context of how the field will be used. Default 'display'. - * @return string|WP_Error + * @return mixed The sanitized field value, or an empty string if the bookmark or field does not exist. * * @phpstan-param 'link_id'|'link_url'|'link_name'|'link_image'|'link_target'|'link_description'|'link_visible'|'link_owner'|'link_rating'|'link_updated'|'link_rel'|'link_notes'|'link_rss'|'link_category' $field - * @phpstan-return array>|int|string + * @phpstan-return ($field is 'link_category' ? array|'' : ($field is 'link_id'|'link_rating' ? int|'' : mixed)) */ function get_bookmark_field( $field, $bookmark, $context = 'display' ) { $bookmark = (int) $bookmark; @@ -408,7 +408,7 @@ function sanitize_bookmark( $bookmark, $context = 'display' ) { * @return mixed The filtered value. * * @phpstan-param 'link_id'|'link_url'|'link_name'|'link_image'|'link_target'|'link_description'|'link_visible'|'link_owner'|'link_rating'|'link_updated'|'link_rel'|'link_notes'|'link_rss'|'link_category' $field - * @phpstan-return array>|int|string + * @phpstan-return ($field is 'link_category' ? array : ($field is 'link_id'|'link_rating' ? int : mixed)) */ function sanitize_bookmark_field( $field, $value, $bookmark_id, $context ) { $int_fields = array( 'link_id', 'link_rating' ); diff --git a/src/wp-includes/class-wp-locale.php b/src/wp-includes/class-wp-locale.php index da9a5aeeb6bd5..9b7a35e71225e 100644 --- a/src/wp-includes/class-wp-locale.php +++ b/src/wp-includes/class-wp-locale.php @@ -118,7 +118,7 @@ class WP_Locale { * Default is 'words'. * * @since 6.2.0 - * @var string + * @var string|null * * @phpstan-var 'characters_excluding_spaces'|'characters_including_spaces'|'words'|null */ diff --git a/src/wp-includes/class-wpdb.php b/src/wp-includes/class-wpdb.php index bbec5752c3d7e..716c130b81a0f 100644 --- a/src/wp-includes/class-wpdb.php +++ b/src/wp-includes/class-wpdb.php @@ -3099,7 +3099,7 @@ public function get_var( $query = null, $x = 0, $y = 0 ) { * : null * ) * - * @phpstan-param int<0, max> $y + * @phpstan-param non-negative-int $y */ public function get_row( $query = null, $output = OBJECT, $y = 0 ) { $this->func_call = "\$db->get_row(\"$query\",$output,$y)"; diff --git a/src/wp-includes/formatting.php b/src/wp-includes/formatting.php index f03132fcf8971..db1e48e971583 100644 --- a/src/wp-includes/formatting.php +++ b/src/wp-includes/formatting.php @@ -2823,7 +2823,7 @@ function format_to_edit( $content, $rich_text = false ) { * @param int $threshold Digit places number needs to be to not have zeros added. * @return string Adds leading zeros to number if needed. * - * @phpstan-param int<0, max> $threshold + * @phpstan-param non-negative-int $threshold * @phpstan-return ( * $threshold is 0 * ? lowercase-string&non-empty-string&numeric-string diff --git a/src/wp-includes/functions.php b/src/wp-includes/functions.php index a32c56f802244..1ce7600eb9fce 100644 --- a/src/wp-includes/functions.php +++ b/src/wp-includes/functions.php @@ -4478,7 +4478,7 @@ function _wp_die_process_input( $message, $title = '', $args = array() ) { * greater than 0. Default 512. * @return string|false The JSON encoded string, or false if it cannot be encoded. * - * @phpstan-param int<1, max> $depth + * @phpstan-param positive-int $depth */ function wp_json_encode( $value, $flags = 0, $depth = 512 ) { $json = json_encode( $value, $flags, $depth ); diff --git a/src/wp-includes/load.php b/src/wp-includes/load.php index 6ac13f24b8268..7bc3801e3fea1 100644 --- a/src/wp-includes/load.php +++ b/src/wp-includes/load.php @@ -1512,7 +1512,7 @@ function absint( $maybeint ): int { * * @return int Site ID. * - * @phpstan-return int<0, max> + * @phpstan-return non-negative-int */ function get_current_blog_id() { global $blog_id; diff --git a/src/wp-includes/plugin.php b/src/wp-includes/plugin.php index 138ae24e3279a..f9e3922ac2561 100644 --- a/src/wp-includes/plugin.php +++ b/src/wp-includes/plugin.php @@ -424,7 +424,7 @@ function doing_filter( $hook_name = null ) { * @param string $hook_name The name of the filter hook. * @return int The number of times the filter hook has been applied. * - * @phpstan-return int<0, max> + * @phpstan-return non-negative-int */ function did_filter( $hook_name ) { global $wp_filters; @@ -705,7 +705,7 @@ function doing_action( $hook_name = null ) { * @param string $hook_name The name of the action hook. * @return int The number of times the action hook has been fired. * - * @phpstan-return int<0, max> + * @phpstan-return non-negative-int */ function did_action( $hook_name ) { global $wp_actions; diff --git a/src/wp-includes/user.php b/src/wp-includes/user.php index 4f0c433aa9f3c..11b8f11e14952 100644 --- a/src/wp-includes/user.php +++ b/src/wp-includes/user.php @@ -723,7 +723,7 @@ function count_many_users_posts( $users, $post_type = 'post', $public_only = fal * * @return int The current user's ID, or 0 if no user is logged in. * - * @phpstan-return int<0, max> + * @phpstan-return non-negative-int */ function get_current_user_id() { if ( ! function_exists( 'wp_get_current_user' ) ) { From 419da78a547805c36f8b4e8ea3cbd8cf0981c2c5 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Wed, 7 Oct 2026 15:37:23 -0700 Subject: [PATCH 03/17] Type `$wp_actions` and `$wp_filters` globals as positive-int counts The hook counters were documented as `int[]` (or a bare `array` in the test helpers), and since PHPStan reads `@global` tags as inline `@var` annotations, `did_action()` and `did_filter()` could not satisfy their new `non-negative-int` return types. Each count starts at 1 and is only ever incremented, so `array` is the accurate type. Co-Authored-By: Claude Opus 5.5 --- src/wp-admin/includes/class-wp-importer.php | 4 +-- src/wp-includes/plugin.php | 32 ++++++++++---------- tests/phpunit/includes/abstract-testcase.php | 16 +++++----- tests/phpunit/includes/utils.php | 2 +- 4 files changed, 27 insertions(+), 27 deletions(-) diff --git a/src/wp-admin/includes/class-wp-importer.php b/src/wp-admin/includes/class-wp-importer.php index a1b76e388f04e..5205393458551 100644 --- a/src/wp-admin/includes/class-wp-importer.php +++ b/src/wp-admin/includes/class-wp-importer.php @@ -280,8 +280,8 @@ public function min_whitespace( $text ) { * * @since 3.0.0 * - * @global wpdb $wpdb WordPress database abstraction object. - * @global int[] $wp_actions Stores the number of times each action was triggered. + * @global wpdb $wpdb WordPress database abstraction object. + * @global array $wp_actions Stores the number of times each action was triggered. */ public function stop_the_insanity() { global $wpdb, $wp_actions; diff --git a/src/wp-includes/plugin.php b/src/wp-includes/plugin.php index f9e3922ac2561..b9988567ffb05 100644 --- a/src/wp-includes/plugin.php +++ b/src/wp-includes/plugin.php @@ -28,10 +28,10 @@ /** @var WP_Hook[] $wp_filter */ global $wp_filter; -/** @var int[] $wp_actions */ +/** @var array $wp_actions */ global $wp_actions; -/** @var int[] $wp_filters */ +/** @var array $wp_filters */ global $wp_filters; /** @var string[] $wp_current_filter */ @@ -162,9 +162,9 @@ function add_filter( $hook_name, $callback, $priority = 10, $accepted_args = 1 ) * @since 6.0.0 Formalized the existing and already documented `...$args` parameter * by adding it to the function signature. * - * @global WP_Hook[] $wp_filter Stores all of the filters and actions. - * @global int[] $wp_filters Stores the number of times each filter was triggered. - * @global string[] $wp_current_filter Stores the list of current filters with the current one last. + * @global WP_Hook[] $wp_filter Stores all of the filters and actions. + * @global array $wp_filters Stores the number of times each filter was triggered. + * @global string[] $wp_current_filter Stores the list of current filters with the current one last. * * @param string $hook_name The name of the filter hook. * @param mixed $value The value to filter. @@ -219,9 +219,9 @@ function apply_filters( $hook_name, $value, ...$args ) { * @see apply_filters() This function is identical, but the arguments passed to the * functions hooked to `$hook_name` are supplied using an array. * - * @global WP_Hook[] $wp_filter Stores all of the filters and actions. - * @global int[] $wp_filters Stores the number of times each filter was triggered. - * @global string[] $wp_current_filter Stores the list of current filters with the current one last. + * @global WP_Hook[] $wp_filter Stores all of the filters and actions. + * @global array $wp_filters Stores the number of times each filter was triggered. + * @global string[] $wp_current_filter Stores the list of current filters with the current one last. * * @param string $hook_name The name of the filter hook. * @param non-empty-list $args The arguments supplied to the functions hooked to `$hook_name`. @@ -419,7 +419,7 @@ function doing_filter( $hook_name = null ) { * * @since 6.1.0 * - * @global int[] $wp_filters Stores the number of times each filter was triggered. + * @global array $wp_filters Stores the number of times each filter was triggered. * * @param string $hook_name The name of the filter hook. * @return int The number of times the filter hook has been applied. @@ -489,9 +489,9 @@ function add_action( $hook_name, $callback, $priority = 10, $accepted_args = 1 ) * @since 5.3.0 Formalized the existing and already documented `...$arg` parameter * by adding it to the function signature. * - * @global WP_Hook[] $wp_filter Stores all of the filters and actions. - * @global int[] $wp_actions Stores the number of times each action was triggered. - * @global string[] $wp_current_filter Stores the list of current filters with the current one last. + * @global WP_Hook[] $wp_filter Stores all of the filters and actions. + * @global array $wp_actions Stores the number of times each action was triggered. + * @global string[] $wp_current_filter Stores the list of current filters with the current one last. * * @param string $hook_name The name of the action to be executed. * @param mixed ...$arg Optional. Additional arguments which are passed on to the @@ -546,9 +546,9 @@ function do_action( $hook_name, ...$arg ) { * @see do_action() This function is identical, but the arguments passed to the * functions hooked to `$hook_name` are supplied using an array. * - * @global WP_Hook[] $wp_filter Stores all of the filters and actions. - * @global int[] $wp_actions Stores the number of times each action was triggered. - * @global string[] $wp_current_filter Stores the list of current filters with the current one last. + * @global WP_Hook[] $wp_filter Stores all of the filters and actions. + * @global array $wp_actions Stores the number of times each action was triggered. + * @global string[] $wp_current_filter Stores the list of current filters with the current one last. * * @param string $hook_name The name of the action to be executed. * @param list $args The arguments supplied to the functions hooked to `$hook_name`. @@ -700,7 +700,7 @@ function doing_action( $hook_name = null ) { * * @since 2.1.0 * - * @global int[] $wp_actions Stores the number of times each action was triggered. + * @global array $wp_actions Stores the number of times each action was triggered. * * @param string $hook_name The name of the action hook. * @return int The number of times the action hook has been fired. diff --git a/tests/phpunit/includes/abstract-testcase.php b/tests/phpunit/includes/abstract-testcase.php index aaf0c8aa57b5e..9579b2a663f21 100644 --- a/tests/phpunit/includes/abstract-testcase.php +++ b/tests/phpunit/includes/abstract-testcase.php @@ -422,10 +422,10 @@ protected function reset__SERVER() { * Stores $wp_filter, $wp_actions, $wp_filters, and $wp_current_filter * on a class variable so they can be restored on tear_down() using _restore_hooks(). * - * @global array $wp_filter All of the filters and actions. - * @global array $wp_actions The number of times each action was triggered. - * @global array $wp_filters The number of times each filter was triggered. - * @global array $wp_current_filter The list of current filters with the current one last. + * @global array $wp_filter All of the filters and actions. + * @global array $wp_actions The number of times each action was triggered. + * @global array $wp_filters The number of times each filter was triggered. + * @global array $wp_current_filter The list of current filters with the current one last. */ protected function _backup_hooks() { self::$hooks_saved['wp_filter'] = array(); @@ -445,10 +445,10 @@ protected function _backup_hooks() { * Restores the hook-related globals to their state at set_up() * so that future tests aren't affected by hooks set during this last test. * - * @global array $wp_filter All of the filters and actions. - * @global array $wp_actions The number of times each action was triggered. - * @global array $wp_filters The number of times each filter was triggered. - * @global array $wp_current_filter The list of current filters with the current one last. + * @global array $wp_filter All of the filters and actions. + * @global array $wp_actions The number of times each action was triggered. + * @global array $wp_filters The number of times each filter was triggered. + * @global array $wp_current_filter The list of current filters with the current one last. */ protected function _restore_hooks() { if ( isset( self::$hooks_saved['wp_filter'] ) ) { diff --git a/tests/phpunit/includes/utils.php b/tests/phpunit/includes/utils.php index a99e640ced4b9..272a1e55050e3 100644 --- a/tests/phpunit/includes/utils.php +++ b/tests/phpunit/includes/utils.php @@ -101,7 +101,7 @@ public function reset(): void { /** * @since UT (3.7.0) * - * @global array $wp_actions + * @global array $wp_actions * @return string|false */ public function current_filter() { From 1862d068e5f68f08add390c55b8ad84a33aa29ab Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Wed, 7 Oct 2026 15:49:28 -0700 Subject: [PATCH 04/17] Narrow `wp_insert_link()` return to `non-negative-int` `edit_link()` and `wp_update_link()` are annotated as returning a `non-negative-int`, but `wp_insert_link()` only promised `int`, so PHPStan flagged both wrappers on their unchanged `return` lines. The `int` was accurate: on the update path, `$link_id` was taken as-is from the sanitized (and filterable) input, so passing a negative `link_id` made the update match no rows and the negative ID was returned. The ID is now passed through `absint()`, so an invalid negative ID is treated as 0 (an insert) rather than an update of a link that cannot exist. On the insert path, `$link_id` comes from `wpdb::$insert_id`, which is now annotated as `non-negative-int`, since an AUTO_INCREMENT ID is never negative. Co-Authored-By: Claude Opus 5.5 --- src/wp-admin/includes/bookmark.php | 4 ++-- src/wp-includes/class-wpdb.php | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/wp-admin/includes/bookmark.php b/src/wp-admin/includes/bookmark.php index a892a3a836a18..042f93a0d2bf2 100644 --- a/src/wp-admin/includes/bookmark.php +++ b/src/wp-admin/includes/bookmark.php @@ -178,7 +178,7 @@ function get_link_to_edit( $link ) { * @param bool $wp_error Optional. Whether to return a WP_Error object on failure. Default false. * @return int|WP_Error The link ID on success. The value 0 or WP_Error on failure. * - * @phpstan-return ( $wp_error is false ? int : int|WP_Error ) + * @phpstan-return ( $wp_error is false ? non-negative-int : non-negative-int|WP_Error ) */ function wp_insert_link( $linkdata, $wp_error = false ) { global $wpdb; @@ -193,7 +193,7 @@ function wp_insert_link( $linkdata, $wp_error = false ) { $parsed_args = wp_parse_args( $linkdata, $defaults ); $parsed_args = wp_unslash( sanitize_bookmark( $parsed_args, 'db' ) ); - $link_id = $parsed_args['link_id']; + $link_id = absint( $parsed_args['link_id'] ); $link_name = $parsed_args['link_name']; $link_url = $parsed_args['link_url']; diff --git a/src/wp-includes/class-wpdb.php b/src/wp-includes/class-wpdb.php index 716c130b81a0f..57e4ba4259ace 100644 --- a/src/wp-includes/class-wpdb.php +++ b/src/wp-includes/class-wpdb.php @@ -116,6 +116,7 @@ class wpdb { * @since 0.71 * * @var int + * @phpstan-var non-negative-int */ public $insert_id = 0; From 1cc1ec55304da4adf163f88d3e157d83f6df6f43 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Wed, 7 Oct 2026 15:52:33 -0700 Subject: [PATCH 05/17] Narrow `wp_insert_category()` and term `term_id` returns to `non-negative-int` `wp_update_category()` is annotated as returning `non-negative-int|false`, but it returns `wp_insert_category()`, which only promised `int`, so PHPStan flagged its unchanged `return` line. `wp_insert_category()` returns the `term_id` from `wp_insert_term()` or `wp_update_term()`, so their array shapes are narrowed to `term_id: non-negative-int` as well. To make that true rather than merely asserted, the IDs those functions return are now passed through `absint()`: - The value returned by the `term_id_filter` filter, in both functions. Previously a filter could leak a negative number or a numeric string into the returned array. - The ID of an existing duplicate term, which is read from a database row and so was only cast with `(int)`. The remaining path in `wp_insert_term()` already uses `wpdb::$insert_id`, which is now `non-negative-int`. Co-Authored-By: Claude Opus 5.5 --- src/wp-admin/includes/taxonomy.php | 2 +- src/wp-includes/taxonomy.php | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/wp-admin/includes/taxonomy.php b/src/wp-admin/includes/taxonomy.php index 1333416a7b79e..73677c9d7617d 100644 --- a/src/wp-admin/includes/taxonomy.php +++ b/src/wp-admin/includes/taxonomy.php @@ -119,7 +119,7 @@ function wp_create_categories( $categories, $post_id = 0 ) { * depending on param `$wp_error`. * * @phpstan-return ( - * $wp_error is false ? int : int|WP_Error + * $wp_error is false ? non-negative-int : non-negative-int|WP_Error * ) */ function wp_insert_category( $catarr, $wp_error = false ) { diff --git a/src/wp-includes/taxonomy.php b/src/wp-includes/taxonomy.php index ea13e99271cf0..efeffbb0b880d 100644 --- a/src/wp-includes/taxonomy.php +++ b/src/wp-includes/taxonomy.php @@ -2567,7 +2567,7 @@ function wp_get_object_terms( $object_ids, $taxonomies, $args = array() ) { * ... * } $args * @phpstan-return array{ - * term_id: int, + * term_id: non-negative-int, * term_taxonomy_id: int|numeric-string, * }|WP_Error */ @@ -2801,7 +2801,7 @@ function wp_insert_term( $term, $taxonomy, $args = array() ) { $wpdb->delete( $wpdb->terms, array( 'term_id' => $term_id ) ); $wpdb->delete( $wpdb->term_taxonomy, array( 'term_taxonomy_id' => $tt_id ) ); - $term_id = (int) $duplicate_term->term_id; + $term_id = absint( $duplicate_term->term_id ); $tt_id = (int) $duplicate_term->term_taxonomy_id; clean_term_cache( $term_id, $taxonomy ); @@ -2857,7 +2857,7 @@ function wp_insert_term( $term, $taxonomy, $args = array() ) { * @param int $tt_id Term taxonomy ID. * @param array $args Arguments passed to wp_insert_term(). */ - $term_id = apply_filters( 'term_id_filter', $term_id, $tt_id, $args ); + $term_id = absint( apply_filters( 'term_id_filter', $term_id, $tt_id, $args ) ); clean_term_cache( $term_id, $taxonomy ); @@ -3379,7 +3379,7 @@ function wp_unique_term_slug( $slug, $term ) { * ... * } $args * @phpstan-return array{ - * term_id: int, + * term_id: non-negative-int, * term_taxonomy_id: int, * }|WP_Error */ @@ -3618,7 +3618,7 @@ function wp_update_term( $term_id, $taxonomy, $args = array() ) { do_action( "edit_{$taxonomy}", $term_id, $tt_id, $args ); /** This filter is documented in wp-includes/taxonomy.php */ - $term_id = apply_filters( 'term_id_filter', $term_id, $tt_id, $args ); + $term_id = absint( apply_filters( 'term_id_filter', $term_id, $tt_id, $args ) ); clean_term_cache( $term_id, $taxonomy ); From 945a1bb7586d1997682f4bfdab7f62d04ff942a1 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Wed, 7 Oct 2026 15:54:48 -0700 Subject: [PATCH 06/17] Update baseline for narrowed `$term_id` passed to `sanitize_title()` Now that `wpdb::$insert_id` is a `non-negative-int`, the existing baselined error for passing `$term_id` as the `$fallback_title` of `sanitize_title()` in `wp_insert_term()` reports `int<0, max>` instead of `int`. Split the baseline entry so each message matches its single occurrence, rather than leaving one error unignored and the other entry reporting an unmet count. Co-Authored-By: Claude Opus 5.5 --- tests/phpstan/baselines/argument.type.neon | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/phpstan/baselines/argument.type.neon b/tests/phpstan/baselines/argument.type.neon index 193d2aa64f6f3..72a5f22fc3b21 100644 --- a/tests/phpstan/baselines/argument.type.neon +++ b/tests/phpstan/baselines/argument.type.neon @@ -1326,7 +1326,12 @@ parameters: - message: '#^Parameter \#2 \$fallback_title of function sanitize_title expects string, int given\.$#' identifier: argument.type - count: 2 + count: 1 + path: ../../../src/wp-includes/taxonomy.php + - + message: '#^Parameter \#2 \$fallback_title of function sanitize_title expects string, int\<0, max\> given\.$#' + identifier: argument.type + count: 1 path: ../../../src/wp-includes/taxonomy.php - message: '#^Parameter \#2 \$meta_id of function delete_metadata_by_mid expects int, string\|null given\.$#' From b40136e3a8499f66ace0b99a341532d422ce863e Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Wed, 7 Oct 2026 16:05:51 -0700 Subject: [PATCH 07/17] Narrow `WP_User::$ID` to `non-negative-int` `get_current_user_id()` is annotated as returning a `non-negative-int`, but it returns `(int) $user->ID`, and `WP_User::$ID` was only typed as `int`, so PHPStan flagged its unchanged `return` line. Although `wp_get_current_user()` is pluggable, it is documented to return a `WP_User`, and the `WP_User` class itself is not pluggable, so the narrowed property type holds for whatever object a replacement returns. This matches `WP_Post::$ID`, which is already annotated as `non-negative-int`. To make the type true rather than merely asserted, the two places in `WP_User` that set the ID, `init()` and the deprecated `id` handling in `__set()`, now use `absint()` instead of an `(int)` cast. A negative ID was never valid, and is now treated as 0, the ID of a user who is not logged in. Co-Authored-By: Claude Opus 5.5 --- src/wp-includes/class-wp-user.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/wp-includes/class-wp-user.php b/src/wp-includes/class-wp-user.php index b50a6790a2dc7..46f9a13bc3209 100644 --- a/src/wp-includes/class-wp-user.php +++ b/src/wp-includes/class-wp-user.php @@ -58,6 +58,7 @@ class WP_User { * * @since 2.1.0 * @var int + * @phpstan-var non-negative-int */ public $ID = 0; @@ -185,7 +186,7 @@ public function init( $data, $site_id = 0 ) { $data->ID = 0; } $this->data = $data; - $this->ID = (int) $data->ID; + $this->ID = absint( $data->ID ); $this->for_site( $site_id ); } @@ -364,7 +365,7 @@ public function __set( $key, $value ) { 'WP_User->ID' ) ); - $this->ID = (int) $value; + $this->ID = absint( $value ); return; } From 04a36913d36ea75a848eca2d4e0ec1d4952ef71b Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Wed, 7 Oct 2026 16:08:52 -0700 Subject: [PATCH 08/17] Update baselines for narrowed `WP_User::$ID` Now that `WP_User::$ID` is a `non-negative-int`, two baselined errors report `int<0, max>` instead of `int`, so their patterns no longer matched: - The `post_author` user ID in the array passed to `wp_insert_post()` by `wp_xmlrpc_server::blogger_newPost()`. - The IDs passed as the `$fallback` class name to `sanitize_html_class()` in `get_body_class()`: the author's user ID, and the `ID` of the queried object. The latter was already narrowed for `WP_Post`, but `get_queried_object()` may also return a `WP_User`, so it was only `int` until now. Co-Authored-By: Claude Opus 5.5 --- tests/phpstan/baselines/argument.type.neon | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/phpstan/baselines/argument.type.neon b/tests/phpstan/baselines/argument.type.neon index 72a5f22fc3b21..dd4bb6b2f6e9b 100644 --- a/tests/phpstan/baselines/argument.type.neon +++ b/tests/phpstan/baselines/argument.type.neon @@ -829,7 +829,7 @@ parameters: count: 1 path: ../../../src/wp-includes/class-wp-widget.php - - message: '#^Parameter \#1 \$postarr of function wp_insert_post expects array\{ID\?\: int, post_author\?\: int, post_date\?\: string, post_date_gmt\?\: string, post_content\?\: string, post_content_filtered\?\: string, post_title\?\: string, post_excerpt\?\: string, \.\.\., \.\.\.\}, array\{post_author\: int, post_date\: string, post_date_gmt\: string, post_content\: string, post_title\: string, post_category\: array\\|string, post_status\: ''draft''\|''publish''\} given\.$#' + message: '#^Parameter \#1 \$postarr of function wp_insert_post expects array\{ID\?\: int, post_author\?\: int, post_date\?\: string, post_date_gmt\?\: string, post_content\?\: string, post_content_filtered\?\: string, post_title\?\: string, post_excerpt\?\: string, \.\.\., \.\.\.\}, array\{post_author\: int\<0, max\>, post_date\: string, post_date_gmt\: string, post_content\: string, post_title\: string, post_category\: array\\|string, post_status\: ''draft''\|''publish''\} given\.$#' identifier: argument.type count: 1 path: ../../../src/wp-includes/class-wp-xmlrpc-server.php @@ -1119,7 +1119,7 @@ parameters: count: 1 path: ../../../src/wp-includes/post-template.php - - message: '#^Parameter \#2 \$fallback of function sanitize_html_class expects string, int given\.$#' + message: '#^Parameter \#2 \$fallback of function sanitize_html_class expects string, int\<0, max\> given\.$#' identifier: argument.type count: 2 path: ../../../src/wp-includes/post-template.php From 2c3333a3c961a40884d2456d5249910df1874158 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Wed, 7 Oct 2026 16:39:29 -0700 Subject: [PATCH 09/17] =?UTF-8?q?Clamp=20IDs=20with=20`max(=200,=20(int)?= =?UTF-8?q?=20=E2=80=A6=20)`=20instead=20of=20`absint()`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The earlier commits narrowing the IDs from `wp_insert_link()`, `wp_insert_term()`, `wp_update_term()` and `WP_User` to `non-negative-int` used `absint()` to make the types hold, and their messages described this as turning a negative ID into 0. That was wrong: `absint()` is `abs( intval() )`, so it flips the sign, and an invalid ID such as -5 became the valid ID 5, which could refer to an existing user, term or link. Each of these now casts with `(int)` and then clamps with `max( 0, … )`, so a negative ID becomes 0 and never resolves to a real object. PHPStan infers `int<0, max>` from this, so the narrowed return types still hold. The restored `(int)` casts in `WP_User::init()`, `WP_User::__set()` and the duplicate-term check in `wp_insert_term()` report `cast.int` for casting a `mixed` value, as they did on trunk. The cast in `wp_insert_link()` is new and reports the same error, because `wp_unslash()` erases the array shape returned by `sanitize_bookmark()`. `wp_update_link()` already has this error for its own cast of `link_id`. It is left as is, since the link manager is disabled by default. Co-Authored-By: Claude Opus 5.5 --- src/wp-admin/includes/bookmark.php | 2 +- src/wp-includes/class-wp-user.php | 4 ++-- src/wp-includes/taxonomy.php | 6 +++--- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/wp-admin/includes/bookmark.php b/src/wp-admin/includes/bookmark.php index 042f93a0d2bf2..2aebb37773fc5 100644 --- a/src/wp-admin/includes/bookmark.php +++ b/src/wp-admin/includes/bookmark.php @@ -193,7 +193,7 @@ function wp_insert_link( $linkdata, $wp_error = false ) { $parsed_args = wp_parse_args( $linkdata, $defaults ); $parsed_args = wp_unslash( sanitize_bookmark( $parsed_args, 'db' ) ); - $link_id = absint( $parsed_args['link_id'] ); + $link_id = max( 0, (int) $parsed_args['link_id'] ); $link_name = $parsed_args['link_name']; $link_url = $parsed_args['link_url']; diff --git a/src/wp-includes/class-wp-user.php b/src/wp-includes/class-wp-user.php index 46f9a13bc3209..7b67c93761b31 100644 --- a/src/wp-includes/class-wp-user.php +++ b/src/wp-includes/class-wp-user.php @@ -186,7 +186,7 @@ public function init( $data, $site_id = 0 ) { $data->ID = 0; } $this->data = $data; - $this->ID = absint( $data->ID ); + $this->ID = max( 0, (int) $data->ID ); $this->for_site( $site_id ); } @@ -365,7 +365,7 @@ public function __set( $key, $value ) { 'WP_User->ID' ) ); - $this->ID = absint( $value ); + $this->ID = max( 0, (int) $value ); return; } diff --git a/src/wp-includes/taxonomy.php b/src/wp-includes/taxonomy.php index efeffbb0b880d..369766e54c1d6 100644 --- a/src/wp-includes/taxonomy.php +++ b/src/wp-includes/taxonomy.php @@ -2801,7 +2801,7 @@ function wp_insert_term( $term, $taxonomy, $args = array() ) { $wpdb->delete( $wpdb->terms, array( 'term_id' => $term_id ) ); $wpdb->delete( $wpdb->term_taxonomy, array( 'term_taxonomy_id' => $tt_id ) ); - $term_id = absint( $duplicate_term->term_id ); + $term_id = max( 0, (int) $duplicate_term->term_id ); $tt_id = (int) $duplicate_term->term_taxonomy_id; clean_term_cache( $term_id, $taxonomy ); @@ -2857,7 +2857,7 @@ function wp_insert_term( $term, $taxonomy, $args = array() ) { * @param int $tt_id Term taxonomy ID. * @param array $args Arguments passed to wp_insert_term(). */ - $term_id = absint( apply_filters( 'term_id_filter', $term_id, $tt_id, $args ) ); + $term_id = max( 0, (int) apply_filters( 'term_id_filter', $term_id, $tt_id, $args ) ); clean_term_cache( $term_id, $taxonomy ); @@ -3618,7 +3618,7 @@ function wp_update_term( $term_id, $taxonomy, $args = array() ) { do_action( "edit_{$taxonomy}", $term_id, $tt_id, $args ); /** This filter is documented in wp-includes/taxonomy.php */ - $term_id = absint( apply_filters( 'term_id_filter', $term_id, $tt_id, $args ) ); + $term_id = max( 0, (int) apply_filters( 'term_id_filter', $term_id, $tt_id, $args ) ); clean_term_cache( $term_id, $taxonomy ); From 5437060f41a2fbfd7955490da2d14c8ca1b35d8b Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Wed, 7 Oct 2026 16:44:14 -0700 Subject: [PATCH 10/17] Type the static suffix cache in `wp_scripts_get_suffix()` `wp_scripts_get_suffix()` is annotated as returning `''|'.min'`, but its `static $suffixes` cache was untyped, so PHPStan saw each return as `mixed` and could not verify the annotation; it only reworded the existing `return.type` errors. An inline `@var` gives the cache its shape, so both returns now check out. Co-Authored-By: Claude Opus 5.5 --- src/wp-includes/script-loader.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/wp-includes/script-loader.php b/src/wp-includes/script-loader.php index d0492acc036a8..96cfe4482a68e 100644 --- a/src/wp-includes/script-loader.php +++ b/src/wp-includes/script-loader.php @@ -689,6 +689,7 @@ function wp_default_packages( $scripts ) { * @phpstan-return ''|'.min' */ function wp_scripts_get_suffix( $type = '' ) { + /** @var array{ suffix: ''|'.min', dev_suffix: ''|'.min' }|null $suffixes */ static $suffixes; if ( null === $suffixes ) { From 88a9359447edd37a03648b4e2fce9fd9544bc330 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Wed, 7 Oct 2026 16:46:44 -0700 Subject: [PATCH 11/17] Narrow referer check returns to the nonce tick literals `wp_verify_nonce()` is annotated as returning `1|2|false`, but `check_admin_referer()` and `check_ajax_referer()`, which return its result directly, still declared `int`, so callers lost the literal type one call away. Their conditional returns now use `1|2`, keeping `false` only for the cases where they return rather than exit on an invalid nonce: `$action` of -1 for `check_admin_referer()`, and `$stop` of false for `check_ajax_referer()`. Co-Authored-By: Claude Opus 5.5 --- src/wp-includes/pluggable.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/wp-includes/pluggable.php b/src/wp-includes/pluggable.php index 5c1844386270e..d749b89f26732 100644 --- a/src/wp-includes/pluggable.php +++ b/src/wp-includes/pluggable.php @@ -1375,7 +1375,7 @@ function auth_redirect() { * 2 if the nonce is valid and generated between 12-24 hours ago. * False if the nonce is invalid. Only possible when `$action` is -1, * as the function otherwise exits rather than returning false. - * @phpstan-return ( $action is -1 ? int|false : int ) + * @phpstan-return ( $action is -1 ? 1|2|false : 1|2 ) */ function check_admin_referer( $action = -1, $query_arg = '_wpnonce' ) { if ( -1 === $action ) { @@ -1422,7 +1422,7 @@ function check_admin_referer( $action = -1, $query_arg = '_wpnonce' ) { * 2 if the nonce is valid and generated between 12-24 hours ago. * False if the nonce is invalid. Only possible when `$stop` is false, * as the function otherwise exits rather than returning false. - * @phpstan-return ( $stop is true ? int : int|false ) + * @phpstan-return ( $stop is true ? 1|2 : 1|2|false ) */ function check_ajax_referer( $action = -1, $query_arg = false, $stop = true ) { if ( -1 === $action ) { From af89d43e7ae02d26149c6570f0ef956f7c899bc4 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Wed, 7 Oct 2026 16:50:54 -0700 Subject: [PATCH 12/17] Narrow network ID returns to `non-negative-int` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `get_current_blog_id()` is annotated as returning a `non-negative-int`, but its sibling `get_current_network_id()` still returned plain `int`. `get_current_network_id()` returns 1, `absint()` of the current network's ID, or `get_main_network_id()`. The last of these returned the `(int)` cast of the `get_main_network_id` filter, which could be negative, so `get_main_network_id()` now clamps that value with `max( 0, … )` and is narrowed as well. A negative network ID was never valid, and is now treated as 0, which never matches a real network. Co-Authored-By: Claude Opus 5.5 --- src/wp-includes/functions.php | 4 +++- src/wp-includes/load.php | 2 ++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/wp-includes/functions.php b/src/wp-includes/functions.php index bc714edf769ff..170d9e4822618 100644 --- a/src/wp-includes/functions.php +++ b/src/wp-includes/functions.php @@ -6697,6 +6697,8 @@ function is_main_network( $network_id = null ) { * @since 4.3.0 * * @return int The ID of the main network. + * + * @phpstan-return non-negative-int */ function get_main_network_id() { if ( ! is_multisite() ) { @@ -6727,7 +6729,7 @@ function get_main_network_id() { * * @param int $main_network_id The ID of the main network. */ - return (int) apply_filters( 'get_main_network_id', $main_network_id ); + return max( 0, (int) apply_filters( 'get_main_network_id', $main_network_id ) ); } /** diff --git a/src/wp-includes/load.php b/src/wp-includes/load.php index 7bc3801e3fea1..e3964cc5bffbd 100644 --- a/src/wp-includes/load.php +++ b/src/wp-includes/load.php @@ -1526,6 +1526,8 @@ function get_current_blog_id() { * @since 4.6.0 * * @return int The ID of the current network. + * + * @phpstan-return non-negative-int */ function get_current_network_id() { if ( ! is_multisite() ) { From 40b88ca6017571424cdd6d16618ba464d016cd55 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Wed, 7 Oct 2026 17:07:52 -0700 Subject: [PATCH 13/17] Reject a negative link ID in `wp_insert_link()` instead of clamping it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clamping the ID with `max( 0, … )` turned a negative `link_id` into 0, which `wp_insert_link()` treats as a request to insert, so a call meant to update a (nonexistent) link created a new one and fired `add_link`. Before the clamp, a negative ID took the update path, which matched no rows and wrote nothing. A negative ID is now rejected up front by returning 0, the same failure value the function already returns for a missing name or URL. Nothing is written, and `$link_id` is still narrowed to `non-negative-int` for the return type. Co-Authored-By: Claude Opus 5.5 --- src/wp-admin/includes/bookmark.php | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/wp-admin/includes/bookmark.php b/src/wp-admin/includes/bookmark.php index 2aebb37773fc5..b7295237ddbde 100644 --- a/src/wp-admin/includes/bookmark.php +++ b/src/wp-admin/includes/bookmark.php @@ -193,10 +193,14 @@ function wp_insert_link( $linkdata, $wp_error = false ) { $parsed_args = wp_parse_args( $linkdata, $defaults ); $parsed_args = wp_unslash( sanitize_bookmark( $parsed_args, 'db' ) ); - $link_id = max( 0, (int) $parsed_args['link_id'] ); + $link_id = (int) $parsed_args['link_id']; $link_name = $parsed_args['link_name']; $link_url = $parsed_args['link_url']; + if ( $link_id < 0 ) { + return 0; + } + $update = false; if ( ! empty( $link_id ) ) { $update = true; From 51beb14f6e6df60cc2892ea4ead40344a4cd7ecf Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Wed, 7 Oct 2026 17:10:26 -0700 Subject: [PATCH 14/17] Remove the literal `$context` type from `sanitize_title_with_dashes()` `sanitize_title_with_dashes()` runs as a `sanitize_title` filter callback, so it receives whatever context is passed to `sanitize_title()`. Core itself passes `'old-save'` in `wp_check_for_changed_slugs()`, and plugins may pass any string. The function only distinguishes `'save'` from everything else, so the `'display'|'save'|'query'` union was narrower than the values it actually handles, and the documented `string` is the accurate type. Co-Authored-By: Claude Opus 5.5 --- src/wp-includes/formatting.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/wp-includes/formatting.php b/src/wp-includes/formatting.php index fca237a4e575b..a31d2c8851ef8 100644 --- a/src/wp-includes/formatting.php +++ b/src/wp-includes/formatting.php @@ -2282,7 +2282,6 @@ function sanitize_title_for_query( $title ) { * or stripped entirely. Default 'display'. * @return string The sanitized title. * - * @phpstan-param 'display'|'save'|'query' $context * @phpstan-return lowercase-string */ function sanitize_title_with_dashes( $title, $raw_title = '', $context = 'display' ) { From 2352b609370aa0f9359cce8f04024f2f328de413 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Wed, 7 Oct 2026 17:46:57 -0700 Subject: [PATCH 15/17] Keep `WP_User::$ID` and `$data->ID` in agreement for invalid IDs `WP_User::init()` clamped a negative ID to 0 for the `ID` property, but stored the data object untouched, so `$user->data->ID` (and so `$user->get( 'ID' )`) still held the negative value while `$user->ID` was 0. The ID is now validated before the data is stored: when it is missing or negative, both the property and the data are set to 0. A valid ID is left as it is in the data, so a database row keeps its numeric-string `ID`. A test covers a negative ID, a missing ID and a database row. Co-Authored-By: Claude Opus 5.5 --- src/wp-includes/class-wp-user.php | 6 ++++-- tests/phpunit/tests/user.php | 20 ++++++++++++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/src/wp-includes/class-wp-user.php b/src/wp-includes/class-wp-user.php index 7b67c93761b31..557845f756f43 100644 --- a/src/wp-includes/class-wp-user.php +++ b/src/wp-includes/class-wp-user.php @@ -182,11 +182,13 @@ public function __construct( $id = 0, $name = '', $site_id = 0 ) { * @param int $site_id Optional. The site ID to initialize for. */ public function init( $data, $site_id = 0 ) { - if ( ! isset( $data->ID ) ) { + $id = isset( $data->ID ) ? (int) $data->ID : 0; + if ( ! isset( $data->ID ) || $id < 0 ) { + $id = 0; $data->ID = 0; } $this->data = $data; - $this->ID = max( 0, (int) $data->ID ); + $this->ID = $id; $this->for_site( $site_id ); } diff --git a/tests/phpunit/tests/user.php b/tests/phpunit/tests/user.php index 2d4ed48f73c35..b48bee1e27cc4 100644 --- a/tests/phpunit/tests/user.php +++ b/tests/phpunit/tests/user.php @@ -378,6 +378,26 @@ public function test_construction() { $this->assertSame( $user->user_login, $user7->user_login ); } + /** + * Tests that {@see WP_User::init()} keeps the ID property and the ID in the data in agreement. + * + * @covers WP_User::init + */ + public function test_init_normalizes_invalid_id_in_data(): void { + $negative = new WP_User( (object) array( 'ID' => -1 ) ); + $this->assertSame( 0, $negative->ID, 'Expected a negative ID to be replaced with 0.' ); + $this->assertSame( 0, $negative->data->ID, 'Expected the data to agree with the ID property.' ); + $this->assertFalse( $negative->exists(), 'Expected a user with a negative ID not to exist.' ); + + $missing = new WP_User( (object) array( 'user_login' => 'nobody' ) ); + $this->assertSame( 0, $missing->ID, 'Expected a missing ID to be 0.' ); + $this->assertSame( 0, $missing->data->ID, 'Expected the data to be given an ID of 0.' ); + + $user_id = self::factory()->user->create(); + $author = new WP_User( $user_id ); + $this->assertSame( (string) $user_id, $author->data->ID, 'Expected the numeric-string ID of a database row to be preserved.' ); + } + public function test_get() { $user = new WP_User( self::$author_id ); $this->assertSame( 'author_login', $user->get( 'user_login' ) ); From 63ec1b7426a5d4f940b7ca7c3d936cd37b4b248a Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Thu, 8 Oct 2026 09:43:50 -0700 Subject: [PATCH 16/17] Remove dead `WP_Error` check from `get_bookmark_field()` `get_bookmark()` returns an object, an array or null, never a `WP_Error`, so the `is_wp_error()` check after it could never match. Co-Authored-By: Claude Opus 5.5 --- src/wp-includes/bookmark.php | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/wp-includes/bookmark.php b/src/wp-includes/bookmark.php index f4d5a379f732e..99f9e922cfd51 100644 --- a/src/wp-includes/bookmark.php +++ b/src/wp-includes/bookmark.php @@ -89,10 +89,6 @@ function get_bookmark_field( $field, $bookmark, $context = 'display' ) { $bookmark = (int) $bookmark; $bookmark = get_bookmark( $bookmark ); - if ( is_wp_error( $bookmark ) ) { - return $bookmark; - } - if ( ! is_object( $bookmark ) ) { return ''; } From ee5dad5672b7c70b0f6086822afbfca82dc9de5b Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Fri, 9 Oct 2026 13:10:25 -0700 Subject: [PATCH 17/17] Space it out --- src/wp-includes/bookmark.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/wp-includes/bookmark.php b/src/wp-includes/bookmark.php index 99f9e922cfd51..f8314af645f49 100644 --- a/src/wp-includes/bookmark.php +++ b/src/wp-includes/bookmark.php @@ -83,7 +83,7 @@ function get_bookmark( $bookmark, $output = OBJECT, $filter = 'raw' ) { * @return mixed The sanitized field value, or an empty string if the bookmark or field does not exist. * * @phpstan-param 'link_id'|'link_url'|'link_name'|'link_image'|'link_target'|'link_description'|'link_visible'|'link_owner'|'link_rating'|'link_updated'|'link_rel'|'link_notes'|'link_rss'|'link_category' $field - * @phpstan-return ($field is 'link_category' ? array|'' : ($field is 'link_id'|'link_rating' ? int|'' : mixed)) + * @phpstan-return ( $field is 'link_category' ? array|'' : ( $field is 'link_id'|'link_rating' ? int|'' : mixed ) ) */ function get_bookmark_field( $field, $bookmark, $context = 'display' ) { $bookmark = (int) $bookmark;