From 7f76eeac0c0fd974ccd7fc73afd432cf8f73cd1f Mon Sep 17 00:00:00 2001 From: Jason Adams Date: Tue, 11 Nov 2025 14:46:35 -0700 Subject: [PATCH 1/8] Abilities: Add _deprecated_ability function --- src/wp-includes/abilities-api.php | 113 ++++++++++++++++++++++++++++++ 1 file changed, 113 insertions(+) diff --git a/src/wp-includes/abilities-api.php b/src/wp-includes/abilities-api.php index 393e40b56ed8c..ff63a4dd059d7 100644 --- a/src/wp-includes/abilities-api.php +++ b/src/wp-includes/abilities-api.php @@ -801,3 +801,116 @@ function wp_get_ability_categories(): array { return $registry->get_all_registered(); } + +/** + * Marks an ability as deprecated and informs when it has been used. + * + * This function should be called from within a deprecated ability's execute callback + * to notify developers that they are using an obsolete ability. The function logs + * the deprecation and triggers a notice when WP_DEBUG is enabled. + * + * Example: + * + * function my_plugin_old_ability_callback( $input ) { + * _deprecated_ability( 'my-plugin/old-ability', '1.5.0', 'my-plugin/new-ability' ); + * // Legacy implementation... + * } + * + * @since 7.0.0 + * + * @param string $ability_name The name of the ability that is deprecated. + * @param string $version Optional. The version in which the ability was deprecated. + * Default empty string. + * @param string $replacement Optional. The name of the ability that should be used instead. + * Default empty string. + */ +function _deprecated_ability( string $ability_name, string $version = '', string $replacement = '' ): void { + + /** + * Fires when a deprecated ability is called. + * + * @since 7.0.0 + * + * @param string $ability_name The ability that was called. + * @param string $replacement The ability that should have been called. + * @param string $version The version in which the ability was deprecated. + */ + do_action( 'deprecated_ability_run', $ability_name, $replacement, $version ); + + /** + * Filters whether to trigger an error for deprecated abilities. + * + * @since 7.0.0 + * + * @param bool $trigger Whether to trigger the error for deprecated abilities. Default true. + */ + if ( WP_DEBUG && apply_filters( 'deprecated_ability_trigger_error', true ) ) { + if ( function_exists( '__' ) ) { + if ( $replacement ) { + if ( $version ) { + $message = sprintf( + /* translators: 1: Ability name, 2: Version number, 3: Alternative ability name. */ + __( 'Ability %1$s is deprecated since version %2$s! Use %3$s instead.' ), + $ability_name, + $version, + $replacement + ); + } else { + $message = sprintf( + /* translators: 1: Ability name, 2: Alternative ability name. */ + __( 'Ability %1$s is deprecated! Use %2$s instead.' ), + $ability_name, + $replacement + ); + } + } else { + if ( $version ) { + $message = sprintf( + /* translators: 1: Ability name, 2: Version number. */ + __( 'Ability %1$s is deprecated since version %2$s with no alternative available.' ), + $ability_name, + $version + ); + } else { + $message = sprintf( + /* translators: %s: Ability name. */ + __( 'Ability %s is deprecated with no alternative available.' ), + $ability_name + ); + } + } + } else { + if ( $replacement ) { + if ( $version ) { + $message = sprintf( + 'Ability %1$s is deprecated since version %2$s! Use %3$s instead.', + $ability_name, + $version, + $replacement + ); + } else { + $message = sprintf( + 'Ability %1$s is deprecated! Use %2$s instead.', + $ability_name, + $replacement + ); + } + } else { + if ( $version ) { + $message = sprintf( + 'Ability %1$s is deprecated since version %2$s with no alternative available.', + $ability_name, + $version + ); + } else { + $message = sprintf( + 'Ability %s is deprecated with no alternative available.', + $ability_name + ); + } + } + } + + wp_trigger_error( '', $message, E_USER_DEPRECATED ); + } +} From 4d118f9c1af6cbd50bcfdaa021002a735784968c Mon Sep 17 00:00:00 2001 From: Jason Adams Date: Tue, 11 Nov 2025 14:47:17 -0700 Subject: [PATCH 2/8] Abilities: Support ability deprecation --- src/wp-includes/abilities-api/class-wp-ability.php | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/wp-includes/abilities-api/class-wp-ability.php b/src/wp-includes/abilities-api/class-wp-ability.php index 88853f6496a31..e4fc4ecc586c0 100644 --- a/src/wp-includes/abilities-api/class-wp-ability.php +++ b/src/wp-includes/abilities-api/class-wp-ability.php @@ -356,6 +356,7 @@ protected function prepare_properties( array $args ): array { $args['meta'] ?? array(), array( 'annotations' => static::$default_annotations, + 'deprecated' => false, ) ); @@ -680,6 +681,13 @@ protected function do_execute( $input = null ) { sprintf( __( 'Ability "%s" does not have a valid execute callback.' ), $this->name ) ); } else { + // Trigger deprecation notice if the ability is deprecated. + if ( $this->get_meta_item( 'deprecated', false ) === true ) { + $version = $this->get_meta_item( 'deprecated_version', '' ); + $replacement = $this->get_meta_item( 'deprecated_replacement', '' ); + _deprecated_ability( $this->name, $version, $replacement ); + } + $result = $this->invoke_callback( $this->execute_callback, $input ); } From 9607ae3bda9e54405fa2d7447fb3b752474e25db Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Fri, 14 Aug 2026 15:49:03 +0200 Subject: [PATCH 3/8] Abilities: Complete support for ability deprecation --- src/wp-includes/abilities-api.php | 220 +++++++++--------- .../class-wp-abilities-registry.php | 18 ++ .../abilities-api/class-wp-ability.php | 106 +++++++-- ...s-wp-rest-abilities-v1-list-controller.php | 34 +++ tests/phpunit/includes/abstract-testcase.php | 37 +++ .../phpunit/tests/abilities-api/wpAbility.php | 128 ++++++++++ .../tests/abilities-api/wpGetAbilities.php | 80 +++++++ .../wpRestAbilitiesV1ListController.php | 85 +++++++ .../wpRestAbilitiesV1RunController.php | 32 +++ tests/qunit/fixtures/wp-api-generated.js | 19 ++ 10 files changed, 625 insertions(+), 134 deletions(-) diff --git a/src/wp-includes/abilities-api.php b/src/wp-includes/abilities-api.php index ff63a4dd059d7..086bf0bf766c1 100644 --- a/src/wp-includes/abilities-api.php +++ b/src/wp-includes/abilities-api.php @@ -231,6 +231,17 @@ * 'show_in_rest' => false, * ), * + * Mark an ability as deprecated while keeping exact-name retrieval and execution + * available for backward compatibility: + * + * 'meta' => array( + * 'deprecated' => array( + * 'since' => '2.0.0', + * 'replacement' => 'my-plugin/new-ability', + * 'message' => __( 'The replacement supports the new data format.', 'my-plugin' ), + * ), + * ), + * * @since 6.9.0 * @since 7.1.0 Added the `public` meta argument. * @since 7.2.0 The `category` argument is now optional and defaults to `uncategorized`. @@ -282,6 +293,15 @@ * clients such as the REST API, MCP, or AI agents. Seeds * the default for per-channel flags like `$show_in_rest`. * Defaults to false. + * @type false|array $deprecated { + * Optional. Deprecation details. Set to an array to mark the ability as deprecated. At least one + * supported detail must be provided. Deprecated abilities remain available by exact name and can + * be explicitly included or excluded from discovery through meta filtering. Default false. + * + * @type string $since Optional. Version of the ability provider that deprecated the ability. + * @type string $replacement Optional. Namespaced ability to use instead. + * @type string $message Optional. Additional migration guidance. + * } * @type bool $show_in_rest Optional. Whether to expose this ability in the REST API. * When true, the ability can be invoked via HTTP requests. * Default is the value of `$public` when set, false otherwise. @@ -413,6 +433,83 @@ function wp_get_ability( string $name ): ?WP_Ability { return $registry->get_registered( $name ); } +/** + * Marks an ability as deprecated and informs when it has been used. + * + * There is a {@see 'deprecated_ability_run'} hook that will be called that can be used + * to get the backtrace up to what code executed the deprecated ability. + * + * The current behavior is to trigger a user error if `WP_DEBUG` is true. + * + * @since 7.1.0 + * + * @param string $ability_name The ability that was executed. + * @param string $version Optional. The version of the ability provider that deprecated the ability. + * Default empty string. + * @param string $replacement Optional. The ability that should be used instead. Default empty string. + * @param string $message Optional. Additional migration guidance. Default empty string. + */ +function _deprecated_ability( string $ability_name, string $version = '', string $replacement = '', string $message = '' ): void { + /** + * Fires when a deprecated ability is executed. + * + * @since 7.1.0 + * + * @param string $ability_name The ability that was executed. + * @param string $replacement The ability that should be used as a replacement. + * @param string $version The version of the ability provider that deprecated the ability. + * @param string $message Additional migration guidance. + */ + do_action( 'deprecated_ability_run', $ability_name, $replacement, $version, $message ); + + /** + * Filters whether to trigger an error for deprecated abilities. + * + * @since 7.1.0 + * + * @param bool $trigger Whether to trigger the error for deprecated abilities. Default true. + */ + if ( WP_DEBUG && apply_filters( 'deprecated_ability_trigger_error', true ) ) { + if ( $version ) { + if ( $replacement ) { + $notice = sprintf( + /* translators: 1: Ability name, 2: Version number, 3: Alternative ability name. */ + __( 'Ability %1$s is deprecated since version %2$s! Use %3$s instead.' ), + $ability_name, + $version, + $replacement + ); + } else { + $notice = sprintf( + /* translators: 1: Ability name, 2: Version number. */ + __( 'Ability %1$s is deprecated since version %2$s with no alternative available.' ), + $ability_name, + $version + ); + } + } elseif ( $replacement ) { + $notice = sprintf( + /* translators: 1: Ability name, 2: Alternative ability name. */ + __( 'Ability %1$s is deprecated! Use %2$s instead.' ), + $ability_name, + $replacement + ); + } else { + $notice = sprintf( + /* translators: %s: Ability name. */ + __( 'Ability %s is deprecated with no alternative available.' ), + $ability_name + ); + } + + if ( $message ) { + $notice .= ' ' . $message; + } + + wp_trigger_error( '', $notice, E_USER_DEPRECATED ); + } +} + /** * Retrieves registered abilities, optionally filtered by the given arguments. * @@ -436,6 +533,16 @@ function wp_get_ability( string $name ): ?WP_Ability { * // All abilities (unchanged behaviour). * $abilities = wp_get_abilities(); * + * // Exclude deprecated abilities explicitly. + * $abilities = wp_get_abilities( array( + * 'meta' => array( 'deprecated' => false ), + * ) ); + * + * // Return only deprecated abilities. + * $abilities = wp_get_abilities( array( + * 'meta' => array( 'deprecated' => array() ), + * ) ); + * * // Filter by category. * $abilities = wp_get_abilities( array( 'category' => 'content' ) ); * @@ -801,116 +908,3 @@ function wp_get_ability_categories(): array { return $registry->get_all_registered(); } - -/** - * Marks an ability as deprecated and informs when it has been used. - * - * This function should be called from within a deprecated ability's execute callback - * to notify developers that they are using an obsolete ability. The function logs - * the deprecation and triggers a notice when WP_DEBUG is enabled. - * - * Example: - * - * function my_plugin_old_ability_callback( $input ) { - * _deprecated_ability( 'my-plugin/old-ability', '1.5.0', 'my-plugin/new-ability' ); - * // Legacy implementation... - * } - * - * @since 7.0.0 - * - * @param string $ability_name The name of the ability that is deprecated. - * @param string $version Optional. The version in which the ability was deprecated. - * Default empty string. - * @param string $replacement Optional. The name of the ability that should be used instead. - * Default empty string. - */ -function _deprecated_ability( string $ability_name, string $version = '', string $replacement = '' ): void { - - /** - * Fires when a deprecated ability is called. - * - * @since 7.0.0 - * - * @param string $ability_name The ability that was called. - * @param string $replacement The ability that should have been called. - * @param string $version The version in which the ability was deprecated. - */ - do_action( 'deprecated_ability_run', $ability_name, $replacement, $version ); - - /** - * Filters whether to trigger an error for deprecated abilities. - * - * @since 7.0.0 - * - * @param bool $trigger Whether to trigger the error for deprecated abilities. Default true. - */ - if ( WP_DEBUG && apply_filters( 'deprecated_ability_trigger_error', true ) ) { - if ( function_exists( '__' ) ) { - if ( $replacement ) { - if ( $version ) { - $message = sprintf( - /* translators: 1: Ability name, 2: Version number, 3: Alternative ability name. */ - __( 'Ability %1$s is deprecated since version %2$s! Use %3$s instead.' ), - $ability_name, - $version, - $replacement - ); - } else { - $message = sprintf( - /* translators: 1: Ability name, 2: Alternative ability name. */ - __( 'Ability %1$s is deprecated! Use %2$s instead.' ), - $ability_name, - $replacement - ); - } - } else { - if ( $version ) { - $message = sprintf( - /* translators: 1: Ability name, 2: Version number. */ - __( 'Ability %1$s is deprecated since version %2$s with no alternative available.' ), - $ability_name, - $version - ); - } else { - $message = sprintf( - /* translators: %s: Ability name. */ - __( 'Ability %s is deprecated with no alternative available.' ), - $ability_name - ); - } - } - } else { - if ( $replacement ) { - if ( $version ) { - $message = sprintf( - 'Ability %1$s is deprecated since version %2$s! Use %3$s instead.', - $ability_name, - $version, - $replacement - ); - } else { - $message = sprintf( - 'Ability %1$s is deprecated! Use %2$s instead.', - $ability_name, - $replacement - ); - } - } else { - if ( $version ) { - $message = sprintf( - 'Ability %1$s is deprecated since version %2$s with no alternative available.', - $ability_name, - $version - ); - } else { - $message = sprintf( - 'Ability %s is deprecated with no alternative available.', - $ability_name - ); - } - } - } - - wp_trigger_error( '', $message, E_USER_DEPRECATED ); - } -} diff --git a/src/wp-includes/abilities-api/class-wp-abilities-registry.php b/src/wp-includes/abilities-api/class-wp-abilities-registry.php index 813c3dd49f7be..64afb20a0a377 100644 --- a/src/wp-includes/abilities-api/class-wp-abilities-registry.php +++ b/src/wp-includes/abilities-api/class-wp-abilities-registry.php @@ -78,6 +78,15 @@ final class WP_Abilities_Registry { * to clients such as the REST API, MCP, or AI agents. * Seeds the default for per-channel flags like * `$show_in_rest`. Defaults to false. + * @type false|array $deprecated { + * Optional. Deprecation details. Set to an array to mark the ability as deprecated. At least one + * supported detail must be provided. Deprecated abilities remain available by exact name and can + * be explicitly included or excluded from discovery through meta filtering. Default false. + * + * @type string $since Optional. Version of the ability provider that deprecated the ability. + * @type string $replacement Optional. Namespaced ability to use instead. + * @type string $message Optional. Additional migration guidance. + * } * @type bool $show_in_rest Optional. Whether to expose this ability in the REST API. * Default is the value of `$public` when set, false otherwise. * } @@ -135,6 +144,15 @@ public function register( string $name, array $args ): ?WP_Ability { * available to clients such as the REST API, MCP, or AI * agents. Seeds the default for per-channel flags like * `$show_in_rest`. Defaults to false. + * @type false|array $deprecated { + * Optional. Deprecation details. Set to an array to mark the ability as deprecated. At least one + * supported detail must be provided. Deprecated abilities remain available by exact name and can + * be explicitly included or excluded from discovery through meta filtering. Default false. + * + * @type string $since Optional. Version of the ability provider that deprecated the ability. + * @type string $replacement Optional. Namespaced ability to use instead. + * @type string $message Optional. Additional migration guidance. + * } * @type bool $show_in_rest Optional. Whether to expose this ability in the REST API. * Default is the value of `$public` when set, false otherwise. * } diff --git a/src/wp-includes/abilities-api/class-wp-ability.php b/src/wp-includes/abilities-api/class-wp-ability.php index e4fc4ecc586c0..ce05657b343c1 100644 --- a/src/wp-includes/abilities-api/class-wp-ability.php +++ b/src/wp-includes/abilities-api/class-wp-ability.php @@ -169,11 +169,20 @@ class WP_Ability { * @type bool|null $idempotent Optional. If true, calling the ability repeatedly with the same arguments * will have no additional effect on its environment. * } - * @type bool $public Optional. Whether the ability is meant to be available - * to clients such as the REST API, MCP, or AI agents. - * Seeds the default for per-channel flags like - * `$show_in_rest`. Defaults to false. - * @type bool $show_in_rest Optional. Whether to expose this ability in the REST API. + * @type bool $public Optional. Whether the ability is meant to be available + * to clients such as the REST API, MCP, or AI agents. + * Seeds the default for per-channel flags like + * `$show_in_rest`. Defaults to false. + * @type false|array $deprecated { + * Optional. Deprecation details. Set to an array to mark the ability as deprecated. At least one + * supported detail must be provided. Deprecated abilities remain available by exact name and can + * be explicitly included or excluded from discovery through meta filtering. Default false. + * + * @type string $since Optional. Version of the ability provider that deprecated the ability. + * @type string $replacement Optional. Namespaced ability to use instead. + * @type string $message Optional. Additional migration guidance. + * } + * @type bool $show_in_rest Optional. Whether to expose this ability in the REST API. * Default is the value of `$public` when set, false otherwise. * } * } @@ -239,11 +248,20 @@ public function __construct( string $name, array $args ) { * @type bool|null $idempotent Optional. If true, calling the ability repeatedly with the same arguments * will have no additional effect on its environment. * } - * @type bool $public Optional. Whether the ability is meant to be available - * to clients such as the REST API, MCP, or AI agents. - * Seeds the default for per-channel flags like - * `$show_in_rest`. Defaults to false. - * @type bool $show_in_rest Optional. Whether to expose this ability in the REST API. + * @type bool $public Optional. Whether the ability is meant to be available + * to clients such as the REST API, MCP, or AI agents. + * Seeds the default for per-channel flags like + * `$show_in_rest`. Defaults to false. + * @type false|array $deprecated { + * Optional. Deprecation details. Set to an array to mark the ability as deprecated. At least one + * supported detail must be provided. Deprecated abilities remain available by exact name and can + * be explicitly included or excluded from discovery through meta filtering. Default false. + * + * @type string $since Optional. Version of the ability provider that deprecated the ability. + * @type string $replacement Optional. Namespaced ability to use instead. + * @type string $message Optional. Additional migration guidance. + * } + * @type bool $show_in_rest Optional. Whether to expose this ability in the REST API. * Default is the value of `$public` when set, false otherwise. * } * } @@ -272,10 +290,17 @@ public function __construct( string $name, array $args ) { * @type bool|null $idempotent If true, calling the ability repeatedly with the same arguments * will have no additional effect on its environment. * } - * @type bool $public Whether the ability is meant to be available to clients - * such as the REST API, MCP, or AI agents. Defaults to - * false. - * @type bool $show_in_rest Whether to expose this ability in the REST API. + * @type bool $public Whether the ability is meant to be available to clients + * such as the REST API, MCP, or AI agents. Defaults to + * false. + * @type false|array $deprecated { + * Deprecation details, or false when the ability is not deprecated. + * + * @type string $since Optional. Version of the ability provider that deprecated the ability. + * @type string $replacement Optional. Namespaced ability to use instead. + * @type string $message Optional. Additional migration guidance. + * } + * @type bool $show_in_rest Whether to expose this ability in the REST API. * } * } * @throws InvalidArgumentException if an argument is invalid. @@ -351,6 +376,39 @@ protected function prepare_properties( array $args ): array { ); } + if ( isset( $args['meta']['deprecated'] ) && false !== $args['meta']['deprecated'] ) { + if ( ! is_array( $args['meta']['deprecated'] ) ) { + throw new InvalidArgumentException( + __( 'The ability meta should provide `deprecated` as false or an array of deprecation details.' ) + ); + } + + $has_deprecation_details = false; + foreach ( array( 'since', 'replacement', 'message' ) as $key ) { + if ( ! array_key_exists( $key, $args['meta']['deprecated'] ) ) { + continue; + } + + if ( ! is_string( $args['meta']['deprecated'][ $key ] ) || '' === $args['meta']['deprecated'][ $key ] ) { + throw new InvalidArgumentException( + sprintf( + /* translators: %s: Deprecation metadata key. */ + __( 'The ability deprecation `%s` value should be a non-empty string.' ), + $key + ) + ); + } + + $has_deprecation_details = true; + } + + if ( ! $has_deprecation_details ) { + throw new InvalidArgumentException( + __( 'The ability deprecation details should provide at least one of `since`, `replacement`, or `message`.' ) + ); + } + } + // Set defaults for optional meta. $args['meta'] = wp_parse_args( $args['meta'] ?? array(), @@ -360,6 +418,9 @@ protected function prepare_properties( array $args ): array { ) ); + // Treat a null `deprecated` value as unset. + $args['meta']['deprecated'] = $args['meta']['deprecated'] ?? false; + $args['meta']['annotations'] = wp_parse_args( $args['meta']['annotations'], static::$default_annotations @@ -681,13 +742,6 @@ protected function do_execute( $input = null ) { sprintf( __( 'Ability "%s" does not have a valid execute callback.' ), $this->name ) ); } else { - // Trigger deprecation notice if the ability is deprecated. - if ( $this->get_meta_item( 'deprecated', false ) === true ) { - $version = $this->get_meta_item( 'deprecated_version', '' ); - $replacement = $this->get_meta_item( 'deprecated_replacement', '' ); - _deprecated_ability( $this->name, $version, $replacement ); - } - $result = $this->invoke_callback( $this->execute_callback, $input ); } @@ -795,6 +849,16 @@ public function execute( $input = null ) { */ do_action( 'wp_ability_invoked', $this->name, $input, $this ); + $deprecated = $this->get_meta_item( 'deprecated', false ); + if ( is_array( $deprecated ) ) { + _deprecated_ability( + $this->name, + $deprecated['since'] ?? '', + $deprecated['replacement'] ?? '', + $deprecated['message'] ?? '' + ); + } + $pre_execute_sentinel = new WP_Filter_Sentinel(); /** diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-list-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-list-controller.php index 5547e7e002f02..dd39317c7e4e2 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-list-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-list-controller.php @@ -317,6 +317,24 @@ public function get_item_schema(): array { 'description' => __( 'Whether the ability is meant to be available to clients such as the REST API, MCP, or AI agents. Defaults to false, but individual channel settings such as show_in_rest can override it.' ), 'type' => 'boolean', ), + 'deprecated' => array( + 'description' => __( 'Deprecation details for the ability, or false when the ability is not deprecated.' ), + 'type' => array( 'boolean', 'object' ), + 'properties' => array( + 'since' => array( + 'description' => __( 'Version of the ability provider that deprecated the ability.' ), + 'type' => 'string', + ), + 'replacement' => array( + 'description' => __( 'Namespaced ability to use instead.' ), + 'type' => 'string', + ), + 'message' => array( + 'description' => __( 'Additional migration guidance.' ), + 'type' => 'string', + ), + ), + ), ), 'context' => array( 'view', 'edit' ), 'readonly' => true, @@ -387,6 +405,22 @@ public function get_collection_params(): array { ), 'additionalProperties' => true, ), + 'deprecated' => array( + 'description' => __( 'Limit results by deprecation status or details.' ), + 'type' => array( 'boolean', 'object' ), + 'properties' => array( + 'since' => array( + 'type' => 'string', + ), + 'replacement' => array( + 'type' => 'string', + ), + 'message' => array( + 'type' => 'string', + ), + ), + 'additionalProperties' => true, + ), ), 'additionalProperties' => true, ), diff --git a/tests/phpunit/includes/abstract-testcase.php b/tests/phpunit/includes/abstract-testcase.php index 2dde7e8b5787c..92b2ce3bbad1b 100644 --- a/tests/phpunit/includes/abstract-testcase.php +++ b/tests/phpunit/includes/abstract-testcase.php @@ -629,6 +629,7 @@ public function expectDeprecated() { add_action( 'deprecated_class_run', array( $this, 'deprecated_function_run' ), 10, 3 ); add_action( 'deprecated_file_included', array( $this, 'deprecated_function_run' ), 10, 4 ); add_action( 'deprecated_hook_run', array( $this, 'deprecated_function_run' ), 10, 4 ); + add_action( 'deprecated_ability_run', array( $this, 'deprecated_function_run' ), 10, 4 ); add_action( 'doing_it_wrong_run', array( $this, 'doing_it_wrong_run' ), 10, 3 ); add_action( 'deprecated_function_trigger_error', '__return_false' ); @@ -636,6 +637,7 @@ public function expectDeprecated() { add_action( 'deprecated_class_trigger_error', '__return_false' ); add_action( 'deprecated_file_trigger_error', '__return_false' ); add_action( 'deprecated_hook_trigger_error', '__return_false' ); + add_action( 'deprecated_ability_trigger_error', '__return_false' ); add_action( 'doing_it_wrong_trigger_error', '__return_false' ); } @@ -807,6 +809,8 @@ public function setExpectedException( $exception, $message = '', $code = null ) */ public function deprecated_function_run( $function_name, $replacement, $version, $message = '' ) { if ( ! isset( $this->caught_deprecated[ $function_name ] ) ) { + $additional_message = $message; + switch ( current_action() ) { case 'deprecated_function_run': if ( $replacement ) { @@ -892,6 +896,39 @@ public function deprecated_function_run( $function_name, $replacement, $version, ) . ' ' . $message; } break; + + case 'deprecated_ability_run': + if ( $version ) { + if ( $replacement ) { + $message = sprintf( + 'Ability %1$s is deprecated since version %2$s! Use %3$s instead.', + $function_name, + $version, + $replacement + ); + } else { + $message = sprintf( + 'Ability %1$s is deprecated since version %2$s with no alternative available.', + $function_name, + $version + ); + } + } elseif ( $replacement ) { + $message = sprintf( + 'Ability %1$s is deprecated! Use %2$s instead.', + $function_name, + $replacement + ); + } else { + $message = sprintf( + 'Ability %s is deprecated with no alternative available.', + $function_name + ); + } + if ( $additional_message ) { + $message .= ' ' . $additional_message; + } + break; } $this->caught_deprecated[ $function_name ] = $message; diff --git a/tests/phpunit/tests/abilities-api/wpAbility.php b/tests/phpunit/tests/abilities-api/wpAbility.php index f613736211f77..88d307ee4deee 100644 --- a/tests/phpunit/tests/abilities-api/wpAbility.php +++ b/tests/phpunit/tests/abilities-api/wpAbility.php @@ -381,6 +381,134 @@ public function test_meta_public_throws_exception_for_non_boolean() { new WP_Ability( self::$test_ability_name, $args ); } + /** + * Tests that `deprecated` metadata defaults to false when not provided. + * + * @ticket 64209 + */ + public function test_meta_deprecated_defaults_to_false(): void { + $ability = new WP_Ability( self::$test_ability_name, self::$test_ability_properties ); + + $this->assertFalse( $ability->get_meta_item( 'deprecated' ) ); + } + + /** + * Tests that a null `deprecated` value is treated as unset. + * + * @ticket 64209 + */ + public function test_meta_deprecated_null_is_treated_as_unset(): void { + $args = self::$test_ability_properties; + $args['meta']['deprecated'] = null; + + $ability = new WP_Ability( self::$test_ability_name, $args ); + + $this->assertFalse( $ability->get_meta_item( 'deprecated' ) ); + } + + /** + * Tests that structured `deprecated` metadata is retained. + * + * @ticket 64209 + */ + public function test_meta_deprecated_accepts_structured_details(): void { + $deprecated = array( + 'since' => '2.0.0', + 'replacement' => 'test/new-calculator', + 'message' => 'Use the new input format.', + ); + + $args = self::$test_ability_properties; + $args['meta']['deprecated'] = $deprecated; + + $ability = new WP_Ability( self::$test_ability_name, $args ); + + $this->assertSame( $deprecated, $ability->get_meta_item( 'deprecated' ) ); + } + + /** + * Tests that `deprecated` rejects values other than false or an array. + * + * @ticket 64209 + */ + public function test_meta_deprecated_rejects_invalid_type(): void { + $args = self::$test_ability_properties; + $args['meta']['deprecated'] = true; + + $this->expectException( InvalidArgumentException::class ); + $this->expectExceptionMessage( 'The ability meta should provide `deprecated` as false or an array of deprecation details.' ); + + new WP_Ability( self::$test_ability_name, $args ); + } + + /** + * Tests that `deprecated` requires at least one supported detail. + * + * @ticket 64209 + */ + public function test_meta_deprecated_rejects_empty_details(): void { + $args = self::$test_ability_properties; + $args['meta']['deprecated'] = array(); + + $this->expectException( InvalidArgumentException::class ); + $this->expectExceptionMessage( 'The ability deprecation details should provide at least one of `since`, `replacement`, or `message`.' ); + + new WP_Ability( self::$test_ability_name, $args ); + } + + /** + * Tests that supported `deprecated` details must be non-empty strings. + * + * @ticket 64209 + */ + public function test_meta_deprecated_rejects_invalid_detail(): void { + $args = self::$test_ability_properties; + $args['meta']['deprecated'] = array( 'since' => 2 ); + + $this->expectException( InvalidArgumentException::class ); + $this->expectExceptionMessage( 'The ability deprecation `since` value should be a non-empty string.' ); + + new WP_Ability( self::$test_ability_name, $args ); + } + + /** + * Tests that executing a deprecated ability emits its structured details. + * + * @ticket 64209 + */ + public function test_execute_deprecated_ability_emits_deprecation(): void { + $args = self::$test_ability_properties; + $args['meta']['deprecated'] = array( + 'since' => '2.0.0', + 'replacement' => 'test/new-calculator', + 'message' => 'Use the new input format.', + ); + + $received = null; + $listener = static function ( $name, $replacement, $version, $message ) use ( &$received ): void { + $received = compact( 'name', 'replacement', 'version', 'message' ); + }; + + $this->setExpectedDeprecated( self::$test_ability_name ); + add_action( 'deprecated_ability_run', $listener, 10, 4 ); + + $ability = new WP_Ability( self::$test_ability_name, $args ); + $result = $ability->execute(); + + remove_action( 'deprecated_ability_run', $listener, 10 ); + + $this->assertSame( 0, $result ); + $this->assertSame( + array( + 'name' => self::$test_ability_name, + 'replacement' => 'test/new-calculator', + 'version' => '2.0.0', + 'message' => 'Use the new input format.', + ), + $received + ); + } + /** * Data provider for testing the execution of the ability. * diff --git a/tests/phpunit/tests/abilities-api/wpGetAbilities.php b/tests/phpunit/tests/abilities-api/wpGetAbilities.php index 475ef341d869f..124eb6ee9f87e 100644 --- a/tests/phpunit/tests/abilities-api/wpGetAbilities.php +++ b/tests/phpunit/tests/abilities-api/wpGetAbilities.php @@ -115,6 +115,86 @@ private function register_test_ability( string $name, array $overrides = array() return $ability; } + // ------------------------------------------------------------------------- + // Deprecated abilities + // ------------------------------------------------------------------------- + + /** + * Tests that deprecated abilities remain in discovery by default. + * + * @ticket 64209 + */ + public function test_deprecated_abilities_are_included_by_default(): void { + $this->simulate_wp_abilities_init(); + + $this->register_test_ability( 'test/active-ability' ); + $this->register_test_ability( + 'test/deprecated-ability', + array( + 'meta' => array( + 'deprecated' => array( 'replacement' => 'test/active-ability' ), + ), + ) + ); + + $result = wp_get_abilities(); + + $this->assertArrayHasKey( 'test/active-ability', $result ); + $this->assertArrayHasKey( 'test/deprecated-ability', $result ); + } + + /** + * Tests that deprecated abilities can be explicitly excluded from discovery. + * + * @ticket 64209 + */ + public function test_deprecated_abilities_can_be_excluded(): void { + $this->simulate_wp_abilities_init(); + + $this->register_test_ability( 'test/active-ability' ); + $this->register_test_ability( + 'test/deprecated-ability', + array( + 'meta' => array( + 'deprecated' => array( 'replacement' => 'test/active-ability' ), + ), + ) + ); + + $result = wp_get_abilities( array( 'meta' => array( 'deprecated' => false ) ) ); + + $this->assertArrayHasKey( 'test/active-ability', $result ); + $this->assertArrayNotHasKey( 'test/deprecated-ability', $result ); + } + + /** + * Tests that the meta filter can return only deprecated abilities. + * + * @ticket 64209 + */ + public function test_deprecated_abilities_can_be_filtered_exclusively(): void { + $this->simulate_wp_abilities_init(); + + $this->register_test_ability( 'test/active-ability' ); + $this->register_test_ability( + 'test/deprecated-ability', + array( + 'meta' => array( + 'deprecated' => array( 'replacement' => 'test/active-ability' ), + ), + ) + ); + + $result = wp_get_abilities( + array( + 'meta' => array( 'deprecated' => array() ), + ) + ); + + $this->assertArrayNotHasKey( 'test/active-ability', $result ); + $this->assertArrayHasKey( 'test/deprecated-ability', $result ); + } + // ------------------------------------------------------------------------- // Category filter // ------------------------------------------------------------------------- diff --git a/tests/phpunit/tests/rest-api/wpRestAbilitiesV1ListController.php b/tests/phpunit/tests/rest-api/wpRestAbilitiesV1ListController.php index 3f31831a825a5..ee30e1dd85d48 100644 --- a/tests/phpunit/tests/rest-api/wpRestAbilitiesV1ListController.php +++ b/tests/phpunit/tests/rest-api/wpRestAbilitiesV1ListController.php @@ -166,6 +166,29 @@ private function register_featured_ability(): void { ); } + /** + * Helper to register a deprecated ability exposed through REST. + */ + private function register_deprecated_ability(): void { + $this->register_test_ability( + 'test/deprecated-calculator', + array( + 'label' => 'Deprecated Calculator', + 'description' => 'A deprecated calculator ability.', + 'category' => 'math', + 'execute_callback' => '__return_true', + 'permission_callback' => '__return_true', + 'meta' => array( + 'show_in_rest' => true, + 'deprecated' => array( + 'since' => '2.0.0', + 'replacement' => 'test/calculator', + ), + ), + ) + ); + } + /** * Register test abilities for testing. */ @@ -318,6 +341,49 @@ public function test_get_items(): void { $this->assertNotContains( 'test/not-show-in-rest', $ability_names ); } + /** + * Tests that collection discovery includes deprecated abilities by default. + * + * @ticket 64209 + */ + public function test_get_items_includes_deprecated_abilities_by_default(): void { + $this->register_deprecated_ability(); + + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities' ); + $request->set_param( 'per_page', 100 ); + $response = $this->server->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + + $ability_meta = wp_list_pluck( $response->get_data(), 'meta', 'name' ); + + $this->assertArrayHasKey( 'test/deprecated-calculator', $ability_meta ); + $this->assertSame( + array( + 'since' => '2.0.0', + 'replacement' => 'test/calculator', + ), + $ability_meta['test/deprecated-calculator']['deprecated'] + ); + } + + /** + * Tests that collection discovery can explicitly exclude deprecated abilities. + * + * @ticket 64209 + */ + public function test_get_items_can_exclude_deprecated_abilities(): void { + $this->register_deprecated_ability(); + + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities' ); + $request->set_param( 'per_page', 100 ); + $request->set_param( 'meta', array( 'deprecated' => 'false' ) ); + $response = $this->server->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + $this->assertNotContains( 'test/deprecated-calculator', wp_list_pluck( $response->get_data(), 'name' ) ); + } + /** * Test getting a specific ability. * @@ -696,6 +762,25 @@ public function test_get_schema_meta_declares_public(): void { $this->assertSame( 'boolean', $meta_properties['public']['type'] ); } + /** + * Tests that the item schema declares structured deprecation metadata. + * + * @ticket 64209 + */ + public function test_get_schema_meta_declares_deprecated(): void { + $request = new WP_REST_Request( 'OPTIONS', '/wp-abilities/v1/abilities' ); + $response = $this->server->dispatch( $request ); + $data = $response->get_data(); + + $deprecated = $data['schema']['properties']['meta']['properties']['deprecated']; + + $this->assertSame( array( 'boolean', 'object' ), $deprecated['type'] ); + $this->assertSame( + array( 'since', 'replacement', 'message' ), + array_keys( $deprecated['properties'] ) + ); + } + /** * Test ability name with valid special characters. * diff --git a/tests/phpunit/tests/rest-api/wpRestAbilitiesV1RunController.php b/tests/phpunit/tests/rest-api/wpRestAbilitiesV1RunController.php index e546ce05fd29e..dd5caca729a72 100644 --- a/tests/phpunit/tests/rest-api/wpRestAbilitiesV1RunController.php +++ b/tests/phpunit/tests/rest-api/wpRestAbilitiesV1RunController.php @@ -432,6 +432,38 @@ public function test_execute_regular_ability_post(): void { $this->assertEquals( 8, $response->get_data() ); } + /** + * Tests that deprecated abilities remain executable through their exact REST route. + * + * @ticket 64209 + */ + public function test_execute_deprecated_ability_by_exact_name(): void { + $this->register_test_ability( + 'test/deprecated-ability', + array( + 'label' => 'Deprecated Ability', + 'description' => 'A deprecated test ability.', + 'category' => 'general', + 'execute_callback' => static function (): string { + return 'executed'; + }, + 'permission_callback' => '__return_true', + 'meta' => array( + 'show_in_rest' => true, + 'deprecated' => array( 'replacement' => 'test/calculator' ), + ), + ) + ); + + $this->setExpectedDeprecated( 'test/deprecated-ability' ); + + $request = new WP_REST_Request( 'POST', '/wp-abilities/v1/abilities/test/deprecated-ability/run' ); + $response = $this->server->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + $this->assertSame( 'executed', $response->get_data() ); + } + /** * Test executing a read-only ability with GET. * diff --git a/tests/qunit/fixtures/wp-api-generated.js b/tests/qunit/fixtures/wp-api-generated.js index 5d209f1c79820..12e703ac3b50b 100644 --- a/tests/qunit/fixtures/wp-api-generated.js +++ b/tests/qunit/fixtures/wp-api-generated.js @@ -12747,6 +12747,25 @@ mockedApiResponse.Schema = { } }, "additionalProperties": true + }, + "deprecated": { + "description": "Limit results by deprecation status or details.", + "type": [ + "boolean", + "object" + ], + "properties": { + "since": { + "type": "string" + }, + "replacement": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "additionalProperties": true } }, "additionalProperties": true, From 910cf501e2f5513d30effd32ce23124e368e0266 Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Tue, 18 Aug 2026 15:03:30 +0200 Subject: [PATCH 4/8] Abilities: Update deprecation API since annotations --- src/wp-includes/abilities-api.php | 8 +++++--- .../abilities-api/class-wp-abilities-registry.php | 2 ++ src/wp-includes/abilities-api/class-wp-ability.php | 3 +++ .../class-wp-rest-abilities-v1-list-controller.php | 3 +++ 4 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/wp-includes/abilities-api.php b/src/wp-includes/abilities-api.php index 086bf0bf766c1..53e68d6898912 100644 --- a/src/wp-includes/abilities-api.php +++ b/src/wp-includes/abilities-api.php @@ -245,6 +245,7 @@ * @since 6.9.0 * @since 7.1.0 Added the `public` meta argument. * @since 7.2.0 The `category` argument is now optional and defaults to `uncategorized`. + * @since 7.2.0 Added the `deprecated` meta property. * * @see WP_Abilities_Registry::register() * @see wp_register_ability_category() @@ -441,7 +442,7 @@ function wp_get_ability( string $name ): ?WP_Ability { * * The current behavior is to trigger a user error if `WP_DEBUG` is true. * - * @since 7.1.0 + * @since 7.2.0 * * @param string $ability_name The ability that was executed. * @param string $version Optional. The version of the ability provider that deprecated the ability. @@ -453,7 +454,7 @@ function _deprecated_ability( string $ability_name, string $version = '', string /** * Fires when a deprecated ability is executed. * - * @since 7.1.0 + * @since 7.2.0 * * @param string $ability_name The ability that was executed. * @param string $replacement The ability that should be used as a replacement. @@ -465,7 +466,7 @@ function _deprecated_ability( string $ability_name, string $version = '', string /** * Filters whether to trigger an error for deprecated abilities. * - * @since 7.1.0 + * @since 7.2.0 * * @param bool $trigger Whether to trigger the error for deprecated abilities. Default true. */ @@ -582,6 +583,7 @@ function _deprecated_ability( string $ability_name, string $version = '', string * * @since 6.9.0 * @since 7.1.0 Added the `$args` parameter for filtering support. + * @since 7.2.0 Added support for filtering by the `deprecated` meta property. * * @see WP_Abilities_Registry::get_all_registered() * diff --git a/src/wp-includes/abilities-api/class-wp-abilities-registry.php b/src/wp-includes/abilities-api/class-wp-abilities-registry.php index 64afb20a0a377..e472f38f4224c 100644 --- a/src/wp-includes/abilities-api/class-wp-abilities-registry.php +++ b/src/wp-includes/abilities-api/class-wp-abilities-registry.php @@ -42,6 +42,7 @@ final class WP_Abilities_Registry { * @since 6.9.0 * @since 7.1.0 Added the `public` meta argument. * @since 7.2.0 The `category` argument is now optional and defaults to `uncategorized`. + * @since 7.2.0 Added the `deprecated` meta property. * * @see wp_register_ability() * @@ -122,6 +123,7 @@ public function register( string $name, array $args ): ?WP_Ability { * @since 6.9.0 * @since 7.1.0 Added the `public` meta argument. * @since 7.2.0 The `category` argument is now optional and defaults to `uncategorized`. + * @since 7.2.0 Added the `deprecated` meta property. * * @param array $args { * An associative array of arguments for the ability. diff --git a/src/wp-includes/abilities-api/class-wp-ability.php b/src/wp-includes/abilities-api/class-wp-ability.php index ce05657b343c1..4b4fe3060e71e 100644 --- a/src/wp-includes/abilities-api/class-wp-ability.php +++ b/src/wp-includes/abilities-api/class-wp-ability.php @@ -140,6 +140,7 @@ class WP_Ability { * * @since 6.9.0 * @since 7.1.0 Added the `public` meta argument. + * @since 7.2.0 Added the `deprecated` meta property. * * @see wp_register_ability() * @@ -220,6 +221,7 @@ public function __construct( string $name, array $args ) { * * @since 6.9.0 * @since 7.1.0 Added the `public` meta argument. + * @since 7.2.0 Added support for the `deprecated` meta property. * * @see WP_Abilities_Registry::register() * @@ -829,6 +831,7 @@ protected function validate_output( $output ) { * @since 6.9.0 * @since 7.1.0 Added the `wp_ability_invoked` action. * @since 7.1.0 Added the `wp_pre_execute_ability` filter. + * @since 7.2.0 Added deprecation notices for abilities with the `deprecated` meta property. * * @param mixed $input Optional. The input data for the ability. Default `null`. * @return mixed|WP_Error The result of the ability execution, or WP_Error on failure. diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-list-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-list-controller.php index dd39317c7e4e2..06165da7cb4c2 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-list-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-list-controller.php @@ -245,6 +245,7 @@ public function prepare_item_for_response( $ability, $request ) { * * @since 6.9.0 * @since 7.1.0 Added the `meta.public` property. + * @since 7.2.0 Added the `deprecated` meta property to the ability schema. * * @return array Item schema data. */ @@ -350,6 +351,7 @@ public function get_item_schema(): array { * * @since 6.9.0 * @since 7.1.0 Added the `namespace` and `meta` parameters and the `rest_abilities_collection_params` filter. + * @since 7.2.0 Added support for filtering by the `deprecated` meta property. * * @return array Collection parameters. */ @@ -434,6 +436,7 @@ public function get_collection_params(): array { * boolean, before the meta filter matches it. * * @since 7.1.0 + * @since 7.2.0 Added the `deprecated` meta collection parameter. * * @param array $query_params JSON Schema-formatted collection parameters. */ From 763010d6f97d9fca109f76f0f29ec2753df80cbb Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Tue, 18 Aug 2026 15:49:55 +0200 Subject: [PATCH 5/8] Abilities: Update registration test for deprecation metadata --- tests/phpunit/tests/abilities-api/wpRegisterAbility.php | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/phpunit/tests/abilities-api/wpRegisterAbility.php b/tests/phpunit/tests/abilities-api/wpRegisterAbility.php index cfa81924a0de2..073c914c3a25e 100644 --- a/tests/phpunit/tests/abilities-api/wpRegisterAbility.php +++ b/tests/phpunit/tests/abilities-api/wpRegisterAbility.php @@ -196,6 +196,7 @@ public function test_register_valid_ability(): void { 'annotations' => $expected_annotations, 'show_in_rest' => true, 'public' => false, + 'deprecated' => false, ) ); From 712d0fd9c6d9b922001f4faef84387112f79cafe Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Thu, 20 Aug 2026 13:51:59 +0200 Subject: [PATCH 6/8] Abilities: Address review feedback for ability deprecation - Support `true` as a shorthand for the `deprecated` meta filter in `wp_get_abilities()`, so REST API and PHP callers can request only deprecated abilities. - Validate that the deprecation `replacement` detail is a namespaced ability name. - Handle the `deprecated_ability_run` action while serving REST API requests. The notice becomes an `X-WP-DeprecatedAbility` header instead of corrupting the JSON response when `WP_DEBUG` is enabled. - Document the `deprecated` collection parameter details in the REST API schema. Co-Authored-By: Claude Fable 5 --- src/wp-includes/abilities-api.php | 14 ++++++- .../abilities-api/class-wp-ability.php | 6 +++ src/wp-includes/rest-api.php | 38 +++++++++++++++++++ ...s-wp-rest-abilities-v1-list-controller.php | 11 ++++-- tests/phpunit/includes/abstract-testcase.php | 4 +- .../phpunit/tests/abilities-api/wpAbility.php | 15 ++++++++ .../tests/abilities-api/wpGetAbilities.php | 24 ++++++++++++ .../wpRestAbilitiesV1ListController.php | 21 ++++++++++ tests/qunit/fixtures/wp-api-generated.js | 5 ++- 9 files changed, 129 insertions(+), 9 deletions(-) diff --git a/src/wp-includes/abilities-api.php b/src/wp-includes/abilities-api.php index 53e68d6898912..cf46b5d61f897 100644 --- a/src/wp-includes/abilities-api.php +++ b/src/wp-includes/abilities-api.php @@ -539,9 +539,10 @@ function _deprecated_ability( string $ability_name, string $version = '', string * 'meta' => array( 'deprecated' => false ), * ) ); * - * // Return only deprecated abilities. + * // Return only deprecated abilities. Passing `true` matches any deprecated + * // ability. An array of details narrows the results further. * $abilities = wp_get_abilities( array( - * 'meta' => array( 'deprecated' => array() ), + * 'meta' => array( 'deprecated' => true ), * ) ); * * // Filter by category. @@ -624,6 +625,15 @@ function wp_get_abilities( array $args = array() ): array { $item_include_callback = isset( $args['item_include_callback'] ) && is_callable( $args['item_include_callback'] ) ? $args['item_include_callback'] : null; $result_callback = isset( $args['result_callback'] ) && is_callable( $args['result_callback'] ) ? $args['result_callback'] : null; + /* + * Normalize the `deprecated` meta filter shorthand. Stored values are `false` + * or an array of details, so `true` becomes an empty set of conditions that + * matches any deprecated ability. + */ + if ( isset( $meta['deprecated'] ) && true === $meta['deprecated'] ) { + $meta['deprecated'] = array(); + } + $matched = array(); foreach ( $abilities as $name => $ability ) { diff --git a/src/wp-includes/abilities-api/class-wp-ability.php b/src/wp-includes/abilities-api/class-wp-ability.php index 4b4fe3060e71e..b5ed418447813 100644 --- a/src/wp-includes/abilities-api/class-wp-ability.php +++ b/src/wp-includes/abilities-api/class-wp-ability.php @@ -401,6 +401,12 @@ protected function prepare_properties( array $args ): array { ); } + if ( 'replacement' === $key && ! preg_match( '/^[a-z0-9-]+\/[a-z0-9-]+$/', $args['meta']['deprecated'][ $key ] ) ) { + throw new InvalidArgumentException( + __( 'The ability deprecation `replacement` value should be a namespaced ability name, i.e. "my-plugin/my-ability". It can only contain lowercase alphanumeric characters, dashes and the forward slash.' ) + ); + } + $has_deprecation_details = true; } diff --git a/src/wp-includes/rest-api.php b/src/wp-includes/rest-api.php index 36b5b23de9f00..551dbe8023402 100644 --- a/src/wp-includes/rest-api.php +++ b/src/wp-includes/rest-api.php @@ -244,6 +244,8 @@ function rest_api_default_filters() { add_filter( 'deprecated_function_trigger_error', '__return_false' ); add_action( 'deprecated_argument_run', 'rest_handle_deprecated_argument', 10, 3 ); add_filter( 'deprecated_argument_trigger_error', '__return_false' ); + add_action( 'deprecated_ability_run', 'rest_handle_deprecated_ability', 10, 4 ); + add_filter( 'deprecated_ability_trigger_error', '__return_false' ); add_action( 'doing_it_wrong_run', 'rest_handle_doing_it_wrong', 10, 3 ); add_filter( 'doing_it_wrong_trigger_error', '__return_false' ); } @@ -769,6 +771,42 @@ function rest_handle_deprecated_argument( $function_name, $message, $version ) { header( sprintf( 'X-WP-DeprecatedParam: %s', $string ) ); } +/** + * Handles _deprecated_ability() errors. + * + * @since 7.2.0 + * + * @param string $ability_name The ability that was executed. + * @param string $replacement The ability that should be used as a replacement. + * @param string $version The version of the ability provider that deprecated the ability. + * @param string $message Additional migration guidance. + */ +function rest_handle_deprecated_ability( $ability_name, $replacement, $version, $message ) { + if ( ! WP_DEBUG || headers_sent() ) { + return; + } + + if ( $version && $replacement ) { + /* translators: 1: Ability name, 2: Version number, 3: Alternative ability name. */ + $string = sprintf( __( '%1$s (since %2$s; use %3$s instead)' ), $ability_name, $version, $replacement ); + } elseif ( $version ) { + /* translators: 1: Ability name, 2: Version number. */ + $string = sprintf( __( '%1$s (since %2$s; no alternative available)' ), $ability_name, $version ); + } elseif ( $replacement ) { + /* translators: 1: Ability name, 2: Alternative ability name. */ + $string = sprintf( __( '%1$s (use %2$s instead)' ), $ability_name, $replacement ); + } else { + /* translators: %s: Ability name. */ + $string = sprintf( __( '%s (no alternative available)' ), $ability_name ); + } + + if ( $message ) { + $string .= ' ' . $message; + } + + header( sprintf( 'X-WP-DeprecatedAbility: %s', $string ) ); +} + /** * Handles _doing_it_wrong errors. * diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-list-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-list-controller.php index 06165da7cb4c2..cb808395e57c7 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-list-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-list-controller.php @@ -408,17 +408,20 @@ public function get_collection_params(): array { 'additionalProperties' => true, ), 'deprecated' => array( - 'description' => __( 'Limit results by deprecation status or details.' ), + 'description' => __( 'Limit results by deprecation status or details. Use true to return only deprecated abilities, false to return only active abilities, or an object to match specific deprecation details.' ), 'type' => array( 'boolean', 'object' ), 'properties' => array( 'since' => array( - 'type' => 'string', + 'description' => __( 'Version of the ability provider that deprecated the ability.' ), + 'type' => 'string', ), 'replacement' => array( - 'type' => 'string', + 'description' => __( 'Namespaced ability to use instead.' ), + 'type' => 'string', ), 'message' => array( - 'type' => 'string', + 'description' => __( 'Additional migration guidance.' ), + 'type' => 'string', ), ), 'additionalProperties' => true, diff --git a/tests/phpunit/includes/abstract-testcase.php b/tests/phpunit/includes/abstract-testcase.php index 92b2ce3bbad1b..639d3ffcef12b 100644 --- a/tests/phpunit/includes/abstract-testcase.php +++ b/tests/phpunit/includes/abstract-testcase.php @@ -809,8 +809,6 @@ public function setExpectedException( $exception, $message = '', $code = null ) */ public function deprecated_function_run( $function_name, $replacement, $version, $message = '' ) { if ( ! isset( $this->caught_deprecated[ $function_name ] ) ) { - $additional_message = $message; - switch ( current_action() ) { case 'deprecated_function_run': if ( $replacement ) { @@ -898,6 +896,8 @@ public function deprecated_function_run( $function_name, $replacement, $version, break; case 'deprecated_ability_run': + $additional_message = $message; + if ( $version ) { if ( $replacement ) { $message = sprintf( diff --git a/tests/phpunit/tests/abilities-api/wpAbility.php b/tests/phpunit/tests/abilities-api/wpAbility.php index 88d307ee4deee..d0b4192503c66 100644 --- a/tests/phpunit/tests/abilities-api/wpAbility.php +++ b/tests/phpunit/tests/abilities-api/wpAbility.php @@ -471,6 +471,21 @@ public function test_meta_deprecated_rejects_invalid_detail(): void { new WP_Ability( self::$test_ability_name, $args ); } + /** + * Tests that the `replacement` detail must be a namespaced ability name. + * + * @ticket 64209 + */ + public function test_meta_deprecated_rejects_invalid_replacement(): void { + $args = self::$test_ability_properties; + $args['meta']['deprecated'] = array( 'replacement' => 'Not An Ability' ); + + $this->expectException( InvalidArgumentException::class ); + $this->expectExceptionMessage( 'The ability deprecation `replacement` value should be a namespaced ability name' ); + + new WP_Ability( self::$test_ability_name, $args ); + } + /** * Tests that executing a deprecated ability emits its structured details. * diff --git a/tests/phpunit/tests/abilities-api/wpGetAbilities.php b/tests/phpunit/tests/abilities-api/wpGetAbilities.php index 124eb6ee9f87e..e2570900c2c66 100644 --- a/tests/phpunit/tests/abilities-api/wpGetAbilities.php +++ b/tests/phpunit/tests/abilities-api/wpGetAbilities.php @@ -195,6 +195,30 @@ public function test_deprecated_abilities_can_be_filtered_exclusively(): void { $this->assertArrayHasKey( 'test/deprecated-ability', $result ); } + /** + * Tests that `true` works as a shorthand for any deprecated ability. + * + * @ticket 64209 + */ + public function test_deprecated_abilities_can_be_filtered_with_true_shorthand(): void { + $this->simulate_wp_abilities_init(); + + $this->register_test_ability( 'test/active-ability' ); + $this->register_test_ability( + 'test/deprecated-ability', + array( + 'meta' => array( + 'deprecated' => array( 'replacement' => 'test/active-ability' ), + ), + ) + ); + + $result = wp_get_abilities( array( 'meta' => array( 'deprecated' => true ) ) ); + + $this->assertArrayNotHasKey( 'test/active-ability', $result ); + $this->assertArrayHasKey( 'test/deprecated-ability', $result ); + } + // ------------------------------------------------------------------------- // Category filter // ------------------------------------------------------------------------- diff --git a/tests/phpunit/tests/rest-api/wpRestAbilitiesV1ListController.php b/tests/phpunit/tests/rest-api/wpRestAbilitiesV1ListController.php index ee30e1dd85d48..fec59711b9c90 100644 --- a/tests/phpunit/tests/rest-api/wpRestAbilitiesV1ListController.php +++ b/tests/phpunit/tests/rest-api/wpRestAbilitiesV1ListController.php @@ -384,6 +384,27 @@ public function test_get_items_can_exclude_deprecated_abilities(): void { $this->assertNotContains( 'test/deprecated-calculator', wp_list_pluck( $response->get_data(), 'name' ) ); } + /** + * Tests that collection discovery can return only deprecated abilities. + * + * @ticket 64209 + */ + public function test_get_items_can_filter_only_deprecated_abilities(): void { + $this->register_deprecated_ability(); + + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities' ); + $request->set_param( 'per_page', 100 ); + $request->set_param( 'meta', array( 'deprecated' => 'true' ) ); + $response = $this->server->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + + $ability_names = wp_list_pluck( $response->get_data(), 'name' ); + + $this->assertContains( 'test/deprecated-calculator', $ability_names ); + $this->assertNotContains( 'test/calculator', $ability_names ); + } + /** * Test getting a specific ability. * diff --git a/tests/qunit/fixtures/wp-api-generated.js b/tests/qunit/fixtures/wp-api-generated.js index 12e703ac3b50b..6cbeff3d195a5 100644 --- a/tests/qunit/fixtures/wp-api-generated.js +++ b/tests/qunit/fixtures/wp-api-generated.js @@ -12749,19 +12749,22 @@ mockedApiResponse.Schema = { "additionalProperties": true }, "deprecated": { - "description": "Limit results by deprecation status or details.", + "description": "Limit results by deprecation status or details. Use true to return only deprecated abilities, false to return only active abilities, or an object to match specific deprecation details.", "type": [ "boolean", "object" ], "properties": { "since": { + "description": "Version of the ability provider that deprecated the ability.", "type": "string" }, "replacement": { + "description": "Namespaced ability to use instead.", "type": "string" }, "message": { + "description": "Additional migration guidance.", "type": "string" } }, From f878eee09266f3246cf71e9e79d0f03b3315439e Mon Sep 17 00:00:00 2001 From: Dovid Levine Date: Wed, 30 Sep 2026 15:29:51 +0300 Subject: [PATCH 7/8] dev: address PR feedback - `meta.deprecated` now takes `null|array` instead of `false|array` - `wp_get_abilities()` hide deprecated abilities by default, a new `allow_deprecated` (or explicit meta filtering) includes them. --- src/wp-includes/abilities-api.php | 136 ++++----------- .../class-wp-abilities-registry.php | 29 ++-- .../abilities-api/class-wp-ability.php | 164 ++++++++++++------ src/wp-includes/rest-api.php | 18 +- ...s-wp-rest-abilities-v1-list-controller.php | 49 +++--- tests/phpunit/includes/abstract-testcase.php | 25 +-- .../abilities-api/wpAbilitiesRegistry.php | 44 +++++ .../phpunit/tests/abilities-api/wpAbility.php | 151 +++++++--------- .../tests/abilities-api/wpGetAbilities.php | 90 ++++++++-- .../tests/abilities-api/wpRegisterAbility.php | 2 +- .../wpRestAbilitiesV1ListController.php | 81 +++++---- .../wpRestAbilitiesV1RunController.php | 7 +- tests/qunit/fixtures/wp-api-generated.js | 27 +-- 13 files changed, 423 insertions(+), 400 deletions(-) diff --git a/src/wp-includes/abilities-api.php b/src/wp-includes/abilities-api.php index cf46b5d61f897..fd34c11ecdd98 100644 --- a/src/wp-includes/abilities-api.php +++ b/src/wp-includes/abilities-api.php @@ -294,12 +294,12 @@ * clients such as the REST API, MCP, or AI agents. Seeds * the default for per-channel flags like `$show_in_rest`. * Defaults to false. - * @type false|array $deprecated { - * Optional. Deprecation details. Set to an array to mark the ability as deprecated. At least one - * supported detail must be provided. Deprecated abilities remain available by exact name and can - * be explicitly included or excluded from discovery through meta filtering. Default false. + * @type null|array $deprecated { + * Optional. Deprecation details. If set, mark the ability as deprecated. Deprecated abilities are hidden + * from discovery by default, but can be retrieved by exact name or explicitly included in discovery + * with the `include_deprecated` argument. Default null. * - * @type string $since Optional. Version of the ability provider that deprecated the ability. + * @type string $since Version of the ability provider that deprecated the ability. * @type string $replacement Optional. Namespaced ability to use instead. * @type string $message Optional. Additional migration guidance. * } @@ -434,93 +434,16 @@ function wp_get_ability( string $name ): ?WP_Ability { return $registry->get_registered( $name ); } -/** - * Marks an ability as deprecated and informs when it has been used. - * - * There is a {@see 'deprecated_ability_run'} hook that will be called that can be used - * to get the backtrace up to what code executed the deprecated ability. - * - * The current behavior is to trigger a user error if `WP_DEBUG` is true. - * - * @since 7.2.0 - * - * @param string $ability_name The ability that was executed. - * @param string $version Optional. The version of the ability provider that deprecated the ability. - * Default empty string. - * @param string $replacement Optional. The ability that should be used instead. Default empty string. - * @param string $message Optional. Additional migration guidance. Default empty string. - */ -function _deprecated_ability( string $ability_name, string $version = '', string $replacement = '', string $message = '' ): void { - /** - * Fires when a deprecated ability is executed. - * - * @since 7.2.0 - * - * @param string $ability_name The ability that was executed. - * @param string $replacement The ability that should be used as a replacement. - * @param string $version The version of the ability provider that deprecated the ability. - * @param string $message Additional migration guidance. - */ - do_action( 'deprecated_ability_run', $ability_name, $replacement, $version, $message ); - - /** - * Filters whether to trigger an error for deprecated abilities. - * - * @since 7.2.0 - * - * @param bool $trigger Whether to trigger the error for deprecated abilities. Default true. - */ - if ( WP_DEBUG && apply_filters( 'deprecated_ability_trigger_error', true ) ) { - if ( $version ) { - if ( $replacement ) { - $notice = sprintf( - /* translators: 1: Ability name, 2: Version number, 3: Alternative ability name. */ - __( 'Ability %1$s is deprecated since version %2$s! Use %3$s instead.' ), - $ability_name, - $version, - $replacement - ); - } else { - $notice = sprintf( - /* translators: 1: Ability name, 2: Version number. */ - __( 'Ability %1$s is deprecated since version %2$s with no alternative available.' ), - $ability_name, - $version - ); - } - } elseif ( $replacement ) { - $notice = sprintf( - /* translators: 1: Ability name, 2: Alternative ability name. */ - __( 'Ability %1$s is deprecated! Use %2$s instead.' ), - $ability_name, - $replacement - ); - } else { - $notice = sprintf( - /* translators: %s: Ability name. */ - __( 'Ability %s is deprecated with no alternative available.' ), - $ability_name - ); - } - - if ( $message ) { - $notice .= ' ' . $message; - } - - wp_trigger_error( '', $notice, E_USER_DEPRECATED ); - } -} - /** * Retrieves registered abilities, optionally filtered by the given arguments. * - * When called without arguments, returns all registered abilities. When called - * with an $args array, returns only abilities that match every specified condition. + * When called without arguments, returns all registered abilities that are not deprecated. When called with an $args + * array, returns only abilities that match every specified condition. * * Filtering pipeline (executed in order): * - * 1. Declarative filters (`category`, `namespace`, `meta`) — per-item, AND logic between - * arg types. + * 1. Declarative filters ( `category`, `include_deprecated`, `namespace`, `meta`) - per-item, AND logic between arg + * types. * 2. `item_include_callback` — per-item, caller-scoped. Return true to include, false to exclude. * 3. `wp_get_abilities_item_include` filter — per-item, ecosystem-scoped. Plugins can enforce * universal inclusion rules regardless of what the caller passed. @@ -531,18 +454,17 @@ function _deprecated_ability( string $ability_name, string $version = '', string * * Examples: * - * // All abilities (unchanged behaviour). + * // All (non-deprecated) abilities. * $abilities = wp_get_abilities(); * - * // Exclude deprecated abilities explicitly. + * // All abilities, including deprecated ones. * $abilities = wp_get_abilities( array( - * 'meta' => array( 'deprecated' => false ), + * 'include_deprecated' => true, * ) ); * - * // Return only deprecated abilities. Passing `true` matches any deprecated - * // ability. An array of details narrows the results further. + * // Only deprecated abilities. An empty array matches any deprecation details. * $abilities = wp_get_abilities( array( - * 'meta' => array( 'deprecated' => true ), + * 'meta' => array( 'deprecated' => array() ), * ) ); * * // Filter by category. @@ -584,21 +506,26 @@ function _deprecated_ability( string $ability_name, string $version = '', string * * @since 6.9.0 * @since 7.1.0 Added the `$args` parameter for filtering support. - * @since 7.2.0 Added support for filtering by the `deprecated` meta property. + * @since 7.2.0 Deprecated abilities are excluded by default. Added the `include_deprecated` argument. * * @see WP_Abilities_Registry::get_all_registered() * * @param array $args { - * Optional. Arguments to filter the returned abilities. Default empty array (returns all). + * Optional. Arguments to filter the returned abilities. Default empty array (returns all abilities + * that are not deprecated). * * @type string $category Filter by category slug. Only abilities whose category * exactly matches the given slug are included. + * @type bool $include_deprecated Whether to include deprecated abilities. Deprecated abilities + * retrieved this way do not trigger deprecation notices. + * Default false. * @type string $namespace Filter by ability namespace prefix. Pass the namespace * without a trailing slash, e.g. `'woocommerce'` matches * `'woocommerce/create-order'`. * @type array $meta Filter by meta key/value pairs. All conditions must * match (AND logic). Supports nested arrays for structured * meta, e.g. `array( 'mcp' => array( 'public' => true ) )`. + * A `deprecated` condition implies `include_deprecated`. * @type callable $item_include_callback Optional. A callback invoked per ability after declarative * filters. Receives a WP_Ability instance, returns bool. * Return true to include, false to exclude. @@ -622,32 +549,29 @@ function wp_get_abilities( array $args = array() ): array { $category = isset( $args['category'] ) && is_string( $args['category'] ) ? $args['category'] : ''; $namespace = isset( $args['namespace'] ) && is_string( $args['namespace'] ) ? rtrim( $args['namespace'], '/' ) . '/' : ''; $meta = isset( $args['meta'] ) && is_array( $args['meta'] ) ? $args['meta'] : array(); + $include_deprecated = ! empty( $args['include_deprecated'] ) || array_key_exists( 'deprecated', $meta ); $item_include_callback = isset( $args['item_include_callback'] ) && is_callable( $args['item_include_callback'] ) ? $args['item_include_callback'] : null; $result_callback = isset( $args['result_callback'] ) && is_callable( $args['result_callback'] ) ? $args['result_callback'] : null; - /* - * Normalize the `deprecated` meta filter shorthand. Stored values are `false` - * or an array of details, so `true` becomes an empty set of conditions that - * matches any deprecated ability. - */ - if ( isset( $meta['deprecated'] ) && true === $meta['deprecated'] ) { - $meta['deprecated'] = array(); - } - $matched = array(); foreach ( $abilities as $name => $ability ) { - // Step 1a: Filter by category. + // Step 1a: Exclude deprecated abilities unless requested via `include_deprecated` or a `deprecated` meta condition. + if ( ! $include_deprecated && null !== $ability->get_meta_item( 'deprecated' ) ) { + continue; + } + + // Step 1b: Filter by category. if ( '' !== $category && $ability->get_category() !== $category ) { continue; } - // Step 1b: Filter by namespace prefix. + // Step 1c: Filter by namespace prefix. if ( '' !== $namespace && ! str_starts_with( $ability->get_name(), $namespace ) ) { continue; } - // Step 1c: Filter by meta key/value pairs (AND logic, supports nested arrays). + // Step 1d: Filter by meta key/value pairs (AND logic, supports nested arrays). if ( ! empty( $meta ) && ! _wp_get_abilities_match_meta( $ability->get_meta(), $meta ) ) { continue; } diff --git a/src/wp-includes/abilities-api/class-wp-abilities-registry.php b/src/wp-includes/abilities-api/class-wp-abilities-registry.php index e472f38f4224c..01761fa8900c1 100644 --- a/src/wp-includes/abilities-api/class-wp-abilities-registry.php +++ b/src/wp-includes/abilities-api/class-wp-abilities-registry.php @@ -79,12 +79,12 @@ final class WP_Abilities_Registry { * to clients such as the REST API, MCP, or AI agents. * Seeds the default for per-channel flags like * `$show_in_rest`. Defaults to false. - * @type false|array $deprecated { - * Optional. Deprecation details. Set to an array to mark the ability as deprecated. At least one - * supported detail must be provided. Deprecated abilities remain available by exact name and can - * be explicitly included or excluded from discovery through meta filtering. Default false. + * @type null|array $deprecated { + * Optional. Deprecation details. If set, mark the ability as deprecated. Deprecated abilities are hidden + * from discovery by default, but can be retrieved by exact name or explicitly included in discovery + * with the `include_deprecated` argument. Default null. * - * @type string $since Optional. Version of the ability provider that deprecated the ability. + * @type string $since Version of the ability provider that deprecated the ability. * @type string $replacement Optional. Namespaced ability to use instead. * @type string $message Optional. Additional migration guidance. * } @@ -146,12 +146,12 @@ public function register( string $name, array $args ): ?WP_Ability { * available to clients such as the REST API, MCP, or AI * agents. Seeds the default for per-channel flags like * `$show_in_rest`. Defaults to false. - * @type false|array $deprecated { - * Optional. Deprecation details. Set to an array to mark the ability as deprecated. At least one - * supported detail must be provided. Deprecated abilities remain available by exact name and can - * be explicitly included or excluded from discovery through meta filtering. Default false. + * @type null|array $deprecated { + * Optional. Deprecation details. If set, mark the ability as deprecated. Deprecated abilities are hidden + * from discovery by default, but can be retrieved by exact name or explicitly included in discovery + * with the `include_deprecated` argument. Default null. * - * @type string $since Optional. Version of the ability provider that deprecated the ability. + * @type string $since Version of the ability provider that deprecated the ability. * @type string $replacement Optional. Namespaced ability to use instead. * @type string $message Optional. Additional migration guidance. * } @@ -287,6 +287,7 @@ public function is_registered( string $name ): bool { * Do not use this method directly. Instead, use the `wp_get_ability()` function. * * @since 6.9.0 + * @since 7.2.0 Added deprecation notices for abilities with the `deprecated` meta property. * * @see wp_get_ability() * @@ -303,7 +304,13 @@ public function get_registered( string $name ): ?WP_Ability { ); return null; } - return $this->registered_abilities[ $name ]; + + $ability = $this->registered_abilities[ $name ]; + + // Check if the ability is deprecated and handle it accordingly. + $ability->_handle_ability_deprecation(); + + return $ability; } /** diff --git a/src/wp-includes/abilities-api/class-wp-ability.php b/src/wp-includes/abilities-api/class-wp-ability.php index b5ed418447813..0cad435410d40 100644 --- a/src/wp-includes/abilities-api/class-wp-ability.php +++ b/src/wp-includes/abilities-api/class-wp-ability.php @@ -170,21 +170,21 @@ class WP_Ability { * @type bool|null $idempotent Optional. If true, calling the ability repeatedly with the same arguments * will have no additional effect on its environment. * } - * @type bool $public Optional. Whether the ability is meant to be available - * to clients such as the REST API, MCP, or AI agents. - * Seeds the default for per-channel flags like - * `$show_in_rest`. Defaults to false. - * @type false|array $deprecated { - * Optional. Deprecation details. Set to an array to mark the ability as deprecated. At least one - * supported detail must be provided. Deprecated abilities remain available by exact name and can - * be explicitly included or excluded from discovery through meta filtering. Default false. + * @type null|array $deprecated { + * Optional. Deprecation details. If set, mark the ability as deprecated. Deprecated abilities are hidden + * from discovery by default, but can be retrieved by exact name or explicitly included in discovery + * with the `include_deprecated` argument. Default null. * - * @type string $since Optional. Version of the ability provider that deprecated the ability. + * @type string $since Version of the ability provider that deprecated the ability. * @type string $replacement Optional. Namespaced ability to use instead. * @type string $message Optional. Additional migration guidance. * } + * @type bool $public Optional. Whether the ability is meant to be available + * to clients such as the REST API, MCP, or AI agents. + * Seeds the default for per-channel flags like + * `$show_in_rest`. Defaults to false. * @type bool $show_in_rest Optional. Whether to expose this ability in the REST API. - * Default is the value of `$public` when set, false otherwise. + * Default is the value of `$public` when set, false otherwise. * } * } */ @@ -250,21 +250,21 @@ public function __construct( string $name, array $args ) { * @type bool|null $idempotent Optional. If true, calling the ability repeatedly with the same arguments * will have no additional effect on its environment. * } - * @type bool $public Optional. Whether the ability is meant to be available - * to clients such as the REST API, MCP, or AI agents. - * Seeds the default for per-channel flags like - * `$show_in_rest`. Defaults to false. - * @type false|array $deprecated { - * Optional. Deprecation details. Set to an array to mark the ability as deprecated. At least one - * supported detail must be provided. Deprecated abilities remain available by exact name and can - * be explicitly included or excluded from discovery through meta filtering. Default false. + * @type null|array $deprecated { + * Optional. Deprecation details. If set, mark the ability as deprecated. Deprecated abilities are hidden + * from discovery by default, but can be retrieved by exact name or explicitly included in discovery + * with the `include_deprecated` argument. Default null. * - * @type string $since Optional. Version of the ability provider that deprecated the ability. + * @type string $since Version of the ability provider that deprecated the ability. * @type string $replacement Optional. Namespaced ability to use instead. * @type string $message Optional. Additional migration guidance. * } + * @type bool $public Optional. Whether the ability is meant to be available + * to clients such as the REST API, MCP, or AI agents. + * Seeds the default for per-channel flags like + * `$show_in_rest`. Defaults to false. * @type bool $show_in_rest Optional. Whether to expose this ability in the REST API. - * Default is the value of `$public` when set, false otherwise. + * Default is the value of `$public` when set, false otherwise. * } * } * @return array { @@ -295,10 +295,10 @@ public function __construct( string $name, array $args ) { * @type bool $public Whether the ability is meant to be available to clients * such as the REST API, MCP, or AI agents. Defaults to * false. - * @type false|array $deprecated { - * Deprecation details, or false when the ability is not deprecated. + * @type null|array $deprecated { + * Deprecation details, or null when the ability is not deprecated. * - * @type string $since Optional. Version of the ability provider that deprecated the ability. + * @type string $since Version of the ability provider that deprecated the ability. * @type string $replacement Optional. Namespaced ability to use instead. * @type string $message Optional. Additional migration guidance. * } @@ -378,16 +378,20 @@ protected function prepare_properties( array $args ): array { ); } - if ( isset( $args['meta']['deprecated'] ) && false !== $args['meta']['deprecated'] ) { + if ( isset( $args['meta']['deprecated'] ) ) { if ( ! is_array( $args['meta']['deprecated'] ) ) { throw new InvalidArgumentException( - __( 'The ability meta should provide `deprecated` as false or an array of deprecation details.' ) + __( 'The ability meta should provide `deprecated` as null or an array of deprecation details.' ) ); } - $has_deprecation_details = false; foreach ( array( 'since', 'replacement', 'message' ) as $key ) { if ( ! array_key_exists( $key, $args['meta']['deprecated'] ) ) { + if ( 'since' === $key ) { + throw new InvalidArgumentException( + __( 'The ability deprecation details must include a `since` version.' ) + ); + } continue; } @@ -400,20 +404,6 @@ protected function prepare_properties( array $args ): array { ) ); } - - if ( 'replacement' === $key && ! preg_match( '/^[a-z0-9-]+\/[a-z0-9-]+$/', $args['meta']['deprecated'][ $key ] ) ) { - throw new InvalidArgumentException( - __( 'The ability deprecation `replacement` value should be a namespaced ability name, i.e. "my-plugin/my-ability". It can only contain lowercase alphanumeric characters, dashes and the forward slash.' ) - ); - } - - $has_deprecation_details = true; - } - - if ( ! $has_deprecation_details ) { - throw new InvalidArgumentException( - __( 'The ability deprecation details should provide at least one of `since`, `replacement`, or `message`.' ) - ); } } @@ -422,13 +412,9 @@ protected function prepare_properties( array $args ): array { $args['meta'] ?? array(), array( 'annotations' => static::$default_annotations, - 'deprecated' => false, ) ); - // Treat a null `deprecated` value as unset. - $args['meta']['deprecated'] = $args['meta']['deprecated'] ?? false; - $args['meta']['annotations'] = wp_parse_args( $args['meta']['annotations'], static::$default_annotations @@ -441,6 +427,7 @@ protected function prepare_properties( array $args ): array { */ $args['meta']['show_in_rest'] ??= $args['meta']['public'] ?? self::DEFAULT_SHOW_IN_REST; $args['meta']['public'] ??= self::DEFAULT_PUBLIC; + $args['meta']['deprecated'] ??= null; return $args; } @@ -837,7 +824,6 @@ protected function validate_output( $output ) { * @since 6.9.0 * @since 7.1.0 Added the `wp_ability_invoked` action. * @since 7.1.0 Added the `wp_pre_execute_ability` filter. - * @since 7.2.0 Added deprecation notices for abilities with the `deprecated` meta property. * * @param mixed $input Optional. The input data for the ability. Default `null`. * @return mixed|WP_Error The result of the ability execution, or WP_Error on failure. @@ -858,16 +844,6 @@ public function execute( $input = null ) { */ do_action( 'wp_ability_invoked', $this->name, $input, $this ); - $deprecated = $this->get_meta_item( 'deprecated', false ); - if ( is_array( $deprecated ) ) { - _deprecated_ability( - $this->name, - $deprecated['since'] ?? '', - $deprecated['replacement'] ?? '', - $deprecated['message'] ?? '' - ); - } - $pre_execute_sentinel = new WP_Filter_Sentinel(); /** @@ -963,6 +939,84 @@ public function execute( $input = null ) { return $result; } + /** + * Informs when a deprecated ability has been retrieved. + * + * There is a {@see 'deprecated_ability_run'} hook that will be called that can be used + * to get the backtrace up to what file and function retrieved the deprecated ability. + * + * The current behavior is to trigger a user error if `WP_DEBUG` is true. + * + * @since 7.2.0 + * @internal Triggered when a deprecated ability is retrieved. It should never be called directly in user + * code. There is no guarantee of backward compatibility. + */ + public function _handle_ability_deprecation(): void { + $name = $this->get_name(); + $deprecation_meta = $this->get_meta_item( 'deprecated' ); + if ( ! is_array( $deprecation_meta ) || ! isset( $deprecation_meta['since'] ) ) { + return; + } + + $version = $deprecation_meta['since']; + $replacement = $deprecation_meta['replacement'] ?? ''; + $message = $deprecation_meta['message'] ?? ''; + + /** + * Fires every time a deprecated ability is accessed. + * + * @since 7.2.0 + * + * @param string $ability_name The ability that was retrieved. + * @param string $replacement The ability that should be used as a replacement. + * @param string $version The version of the ability provider (plugin, WordPress core, etc.) that deprecated the ability. + * @param string $message Additional migration guidance. + */ + do_action( 'deprecated_ability_run', $name, $replacement, $version, $message ); + + // A local cache of deprecated abilities is used to prevent repeated warnings. + static $deprecated_abilities = array(); + + $already_triggered = isset( $deprecated_abilities[ $name ] ); + + /** + * Filters whether to trigger an error for deprecated abilities. + * + * @since 7.2.0 + * + * @param bool $trigger Whether to trigger the error for deprecated abilities. + * Default true if the warning has not already been triggered for this ability, false on + * subsequent calls. + */ + if ( WP_DEBUG && apply_filters( 'deprecated_ability_trigger_error', ! $already_triggered ) ) { + $message = empty( $message ) ? '' : ' ' . $message; + + if ( $replacement ) { + $notice = sprintf( + /* translators: 1: Ability name, 2: Version number, 3: Alternative ability name, 4: Additional message. */ + __( 'Ability %1$s is deprecated since version %2$s. Use %3$s instead.%4$s' ), + $name, + $version, + $replacement, + $message + ); + } else { + $notice = sprintf( + /* translators: 1: Ability name, 2: Version number, 3: Additional message. */ + __( 'Ability %1$s is deprecated since version %2$s with no alternative available.%3$s' ), + $name, + $version, + $message + ); + } + + wp_trigger_error( '', $notice, E_USER_DEPRECATED ); + + // Store the deprecated ability so we don't spam the same warning multiple times. + $deprecated_abilities[ $name ] = true; + } + } + /** * Wakeup magic method. * diff --git a/src/wp-includes/rest-api.php b/src/wp-includes/rest-api.php index 551dbe8023402..b0db22bcd3fac 100644 --- a/src/wp-includes/rest-api.php +++ b/src/wp-includes/rest-api.php @@ -772,11 +772,11 @@ function rest_handle_deprecated_argument( $function_name, $message, $version ) { } /** - * Handles _deprecated_ability() errors. + * Handles deprecated ability notices. * * @since 7.2.0 * - * @param string $ability_name The ability that was executed. + * @param string $ability_name The deprecated ability that was retrieved. * @param string $replacement The ability that should be used as a replacement. * @param string $version The version of the ability provider that deprecated the ability. * @param string $message Additional migration guidance. @@ -786,18 +786,12 @@ function rest_handle_deprecated_ability( $ability_name, $replacement, $version, return; } - if ( $version && $replacement ) { - /* translators: 1: Ability name, 2: Version number, 3: Alternative ability name. */ + if ( $replacement ) { + /* translators: Developer debugging message. 1: Ability name, 2: Version number, 3: Alternative ability name. */ $string = sprintf( __( '%1$s (since %2$s; use %3$s instead)' ), $ability_name, $version, $replacement ); - } elseif ( $version ) { - /* translators: 1: Ability name, 2: Version number. */ - $string = sprintf( __( '%1$s (since %2$s; no alternative available)' ), $ability_name, $version ); - } elseif ( $replacement ) { - /* translators: 1: Ability name, 2: Alternative ability name. */ - $string = sprintf( __( '%1$s (use %2$s instead)' ), $ability_name, $replacement ); } else { - /* translators: %s: Ability name. */ - $string = sprintf( __( '%s (no alternative available)' ), $ability_name ); + /* translators: Developer debugging message. 1: Ability name, 2: Version number. */ + $string = sprintf( __( '%1$s (since %2$s; no alternative available)' ), $ability_name, $version ); } if ( $message ) { diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-list-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-list-controller.php index cb808395e57c7..86aed35419258 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-list-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-list-controller.php @@ -81,6 +81,7 @@ public function register_routes(): void { * Retrieves all abilities. * * @since 6.9.0 + * @since 7.2.0 Added support for the `include_deprecated` parameter. * * @param WP_REST_Request $request Full details about the request. * @return WP_REST_Response Response object on success. @@ -98,6 +99,10 @@ public function get_items( $request ) { $query_args['namespace'] = $request['namespace']; } + if ( ! empty( $request['include_deprecated'] ) ) { + $query_args['include_deprecated'] = true; + } + if ( ! empty( $request['meta'] ) ) { // Merge caller meta first so the forced show_in_rest filter wins. This keeps a caller from using meta to reveal abilities hidden from REST. $query_args['meta'] = array_merge( $request['meta'], $query_args['meta'] ); @@ -319,8 +324,8 @@ public function get_item_schema(): array { 'type' => 'boolean', ), 'deprecated' => array( - 'description' => __( 'Deprecation details for the ability, or false when the ability is not deprecated.' ), - 'type' => array( 'boolean', 'object' ), + 'description' => __( 'Deprecation details for the ability, or null when the ability is not deprecated.' ), + 'type' => array( 'null', 'object' ), 'properties' => array( 'since' => array( 'description' => __( 'Version of the ability provider that deprecated the ability.' ), @@ -351,39 +356,45 @@ public function get_item_schema(): array { * * @since 6.9.0 * @since 7.1.0 Added the `namespace` and `meta` parameters and the `rest_abilities_collection_params` filter. - * @since 7.2.0 Added support for filtering by the `deprecated` meta property. + * @since 7.2.0 Added the `include_deprecated` parameter. * * @return array Collection parameters. */ public function get_collection_params(): array { $query_params = array( - 'context' => $this->get_context_param( array( 'default' => 'view' ) ), - 'page' => array( + 'context' => $this->get_context_param( array( 'default' => 'view' ) ), + 'page' => array( 'description' => __( 'Current page of the collection.' ), 'type' => 'integer', 'default' => 1, 'minimum' => 1, ), - 'per_page' => array( + 'per_page' => array( 'description' => __( 'Maximum number of items to be returned in result set.' ), 'type' => 'integer', 'default' => 50, 'minimum' => 1, 'maximum' => 100, ), - 'category' => array( + 'category' => array( 'description' => __( 'Limit results to abilities in specific ability category.' ), 'type' => 'string', 'sanitize_callback' => 'sanitize_key', 'validate_callback' => 'rest_validate_request_arg', ), - 'namespace' => array( + 'include_deprecated' => array( + 'description' => __( 'Whether to include deprecated abilities in the results.' ), + 'type' => 'boolean', + 'sanitize_callback' => 'rest_sanitize_boolean', + 'validate_callback' => 'rest_validate_request_arg', + ), + 'namespace' => array( 'description' => __( 'Limit results to abilities in a specific namespace.' ), 'type' => 'string', 'sanitize_callback' => 'sanitize_key', 'validate_callback' => 'rest_validate_request_arg', ), - 'meta' => array( + 'meta' => array( 'description' => __( 'Limit results to abilities matching all of the given meta fields.' ), 'type' => 'object', 'properties' => array( @@ -407,25 +418,6 @@ public function get_collection_params(): array { ), 'additionalProperties' => true, ), - 'deprecated' => array( - 'description' => __( 'Limit results by deprecation status or details. Use true to return only deprecated abilities, false to return only active abilities, or an object to match specific deprecation details.' ), - 'type' => array( 'boolean', 'object' ), - 'properties' => array( - 'since' => array( - 'description' => __( 'Version of the ability provider that deprecated the ability.' ), - 'type' => 'string', - ), - 'replacement' => array( - 'description' => __( 'Namespaced ability to use instead.' ), - 'type' => 'string', - ), - 'message' => array( - 'description' => __( 'Additional migration guidance.' ), - 'type' => 'string', - ), - ), - 'additionalProperties' => true, - ), ), 'additionalProperties' => true, ), @@ -439,7 +431,6 @@ public function get_collection_params(): array { * boolean, before the meta filter matches it. * * @since 7.1.0 - * @since 7.2.0 Added the `deprecated` meta collection parameter. * * @param array $query_params JSON Schema-formatted collection parameters. */ diff --git a/tests/phpunit/includes/abstract-testcase.php b/tests/phpunit/includes/abstract-testcase.php index 639d3ffcef12b..4817a5300b901 100644 --- a/tests/phpunit/includes/abstract-testcase.php +++ b/tests/phpunit/includes/abstract-testcase.php @@ -898,31 +898,18 @@ public function deprecated_function_run( $function_name, $replacement, $version, case 'deprecated_ability_run': $additional_message = $message; - if ( $version ) { - if ( $replacement ) { - $message = sprintf( - 'Ability %1$s is deprecated since version %2$s! Use %3$s instead.', - $function_name, - $version, - $replacement - ); - } else { - $message = sprintf( - 'Ability %1$s is deprecated since version %2$s with no alternative available.', - $function_name, - $version - ); - } - } elseif ( $replacement ) { + if ( $replacement ) { $message = sprintf( - 'Ability %1$s is deprecated! Use %2$s instead.', + 'Ability %1$s is deprecated since version %2$s. Use %3$s instead.', $function_name, + $version, $replacement ); } else { $message = sprintf( - 'Ability %s is deprecated with no alternative available.', - $function_name + 'Ability %1$s is deprecated since version %2$s with no alternative available.', + $function_name, + $version ); } if ( $additional_message ) { diff --git a/tests/phpunit/tests/abilities-api/wpAbilitiesRegistry.php b/tests/phpunit/tests/abilities-api/wpAbilitiesRegistry.php index 613fb7afb0474..2d9d8b8f9cda4 100644 --- a/tests/phpunit/tests/abilities-api/wpAbilitiesRegistry.php +++ b/tests/phpunit/tests/abilities-api/wpAbilitiesRegistry.php @@ -581,6 +581,50 @@ public function test_get_registered_for_known_ability() { $this->assertSame( 'test/two', $result->get_name() ); } + /** + * Should trigger the deprecation notice when retrieving a deprecated ability by name. + * + * @ticket 64209 + * + * @covers WP_Abilities_Registry::register + * @covers WP_Abilities_Registry::get_registered + * @covers WP_Ability::_handle_ability_deprecation + */ + public function test_get_registered_triggers_deprecation_for_deprecated_ability() { + $deprecated_args = self::$test_ability_args; + $deprecated_args['meta']['deprecated'] = array( + 'since' => '2.0.0', + 'replacement' => 'test/one', + 'message' => 'Use the new input format.', + ); + $this->registry->register( 'test/one', self::$test_ability_args ); + $this->registry->register( 'test/deprecated', $deprecated_args ); + + $received = null; + $listener = static function ( $name, $replacement, $version, $message ) use ( &$received ): void { + $received = compact( 'name', 'replacement', 'version', 'message' ); + }; + + $this->setExpectedDeprecated( 'test/deprecated' ); + add_action( 'deprecated_ability_run', $listener, 10, 4 ); + + $result = $this->registry->get_registered( 'test/deprecated' ); + + remove_action( 'deprecated_ability_run', $listener, 10 ); + + $this->assertSame( 'test/deprecated', $result->get_name() ); + $this->assertSame( + array( + 'name' => 'test/deprecated', + 'replacement' => 'test/one', + 'version' => '2.0.0', + 'message' => 'Use the new input format.', + ), + $received, + 'Retrieving a deprecated ability by exact name should emit its structured deprecation details.' + ); + } + /** * Unregistering should fail if an ability is not registered. * diff --git a/tests/phpunit/tests/abilities-api/wpAbility.php b/tests/phpunit/tests/abilities-api/wpAbility.php index d0b4192503c66..f2c74f4f920c5 100644 --- a/tests/phpunit/tests/abilities-api/wpAbility.php +++ b/tests/phpunit/tests/abilities-api/wpAbility.php @@ -382,146 +382,119 @@ public function test_meta_public_throws_exception_for_non_boolean() { } /** - * Tests that `deprecated` metadata defaults to false when not provided. + * Tests that `deprecated` rejects values other than an array. * * @ticket 64209 */ - public function test_meta_deprecated_defaults_to_false(): void { - $ability = new WP_Ability( self::$test_ability_name, self::$test_ability_properties ); - - $this->assertFalse( $ability->get_meta_item( 'deprecated' ) ); - } - - /** - * Tests that a null `deprecated` value is treated as unset. - * - * @ticket 64209 - */ - public function test_meta_deprecated_null_is_treated_as_unset(): void { + public function test_meta_deprecated_rejects_invalid_type(): void { $args = self::$test_ability_properties; - $args['meta']['deprecated'] = null; + $args['meta']['deprecated'] = true; - $ability = new WP_Ability( self::$test_ability_name, $args ); + $this->expectException( InvalidArgumentException::class ); + $this->expectExceptionMessage( 'The ability meta should provide `deprecated` as null or an array of deprecation details.' ); - $this->assertFalse( $ability->get_meta_item( 'deprecated' ) ); + new WP_Ability( self::$test_ability_name, $args ); } /** - * Tests that structured `deprecated` metadata is retained. + * Data provider for deprecation details that must include a `since` version. * - * @ticket 64209 + * @return array Data sets. */ - public function test_meta_deprecated_accepts_structured_details(): void { - $deprecated = array( - 'since' => '2.0.0', - 'replacement' => 'test/new-calculator', - 'message' => 'Use the new input format.', + public function data_meta_deprecated_requires_since(): array { + return array( + 'no details' => array( array() ), + 'replacement + message' => array( + array( + 'replacement' => 'test/new-calculator', + 'message' => 'Use the new input format.', + ), + ), ); - - $args = self::$test_ability_properties; - $args['meta']['deprecated'] = $deprecated; - - $ability = new WP_Ability( self::$test_ability_name, $args ); - - $this->assertSame( $deprecated, $ability->get_meta_item( 'deprecated' ) ); } /** - * Tests that `deprecated` rejects values other than false or an array. + * Tests that `deprecated` details must always include a `since` version. * * @ticket 64209 + * + * @dataProvider data_meta_deprecated_requires_since + * + * @param array $deprecated The deprecation details under test. */ - public function test_meta_deprecated_rejects_invalid_type(): void { + public function test_meta_deprecated_rejects_details_without_since( array $deprecated ): void { $args = self::$test_ability_properties; - $args['meta']['deprecated'] = true; + $args['meta']['deprecated'] = $deprecated; $this->expectException( InvalidArgumentException::class ); - $this->expectExceptionMessage( 'The ability meta should provide `deprecated` as false or an array of deprecation details.' ); + $this->expectExceptionMessage( 'The ability deprecation details must include a `since` version.' ); new WP_Ability( self::$test_ability_name, $args ); } /** - * Tests that `deprecated` requires at least one supported detail. + * Data provider for supported `deprecated` details with invalid values. * - * @ticket 64209 + * @return array Data sets. */ - public function test_meta_deprecated_rejects_empty_details(): void { - $args = self::$test_ability_properties; - $args['meta']['deprecated'] = array(); - - $this->expectException( InvalidArgumentException::class ); - $this->expectExceptionMessage( 'The ability deprecation details should provide at least one of `since`, `replacement`, or `message`.' ); - - new WP_Ability( self::$test_ability_name, $args ); + public function data_meta_deprecated_rejects_invalid_detail(): array { + return array( + 'integer since' => array( + array( 'since' => 2 ), + 'since', + ), + 'integer replacement' => array( + array( + 'since' => '2.0.0', + 'replacement' => 5, + ), + 'replacement', + ), + 'empty replacement' => array( + array( + 'since' => '2.0.0', + 'replacement' => '', + ), + 'replacement', + ), + ); } /** * Tests that supported `deprecated` details must be non-empty strings. * * @ticket 64209 - */ - public function test_meta_deprecated_rejects_invalid_detail(): void { - $args = self::$test_ability_properties; - $args['meta']['deprecated'] = array( 'since' => 2 ); - - $this->expectException( InvalidArgumentException::class ); - $this->expectExceptionMessage( 'The ability deprecation `since` value should be a non-empty string.' ); - - new WP_Ability( self::$test_ability_name, $args ); - } - - /** - * Tests that the `replacement` detail must be a namespaced ability name. * - * @ticket 64209 + * @dataProvider data_meta_deprecated_rejects_invalid_detail + * + * @param array $deprecated The deprecation details under test. + * @param string $key The detail key holding the invalid value. */ - public function test_meta_deprecated_rejects_invalid_replacement(): void { + public function test_meta_deprecated_rejects_invalid_detail( array $deprecated, string $key ): void { $args = self::$test_ability_properties; - $args['meta']['deprecated'] = array( 'replacement' => 'Not An Ability' ); + $args['meta']['deprecated'] = $deprecated; $this->expectException( InvalidArgumentException::class ); - $this->expectExceptionMessage( 'The ability deprecation `replacement` value should be a namespaced ability name' ); + $this->expectExceptionMessage( sprintf( 'The ability deprecation `%s` value should be a non-empty string.', $key ) ); new WP_Ability( self::$test_ability_name, $args ); } /** - * Tests that executing a deprecated ability emits its structured details. + * Tests that executing a deprecated ability does not emit a deprecation notice. + * + * Deprecation notices are emitted when an ability is retrieved by name, not on execution. + * Any unexpected notice fails the test. * * @ticket 64209 */ - public function test_execute_deprecated_ability_emits_deprecation(): void { + public function test_execute_deprecated_ability_does_not_emit_deprecation(): void { $args = self::$test_ability_properties; - $args['meta']['deprecated'] = array( - 'since' => '2.0.0', - 'replacement' => 'test/new-calculator', - 'message' => 'Use the new input format.', - ); - - $received = null; - $listener = static function ( $name, $replacement, $version, $message ) use ( &$received ): void { - $received = compact( 'name', 'replacement', 'version', 'message' ); - }; - - $this->setExpectedDeprecated( self::$test_ability_name ); - add_action( 'deprecated_ability_run', $listener, 10, 4 ); + $args['meta']['deprecated'] = array( 'since' => '2.0.0' ); $ability = new WP_Ability( self::$test_ability_name, $args ); - $result = $ability->execute(); - - remove_action( 'deprecated_ability_run', $listener, 10 ); - $this->assertSame( 0, $result ); - $this->assertSame( - array( - 'name' => self::$test_ability_name, - 'replacement' => 'test/new-calculator', - 'version' => '2.0.0', - 'message' => 'Use the new input format.', - ), - $received - ); + $this->assertSame( 0, $ability->execute() ); } /** diff --git a/tests/phpunit/tests/abilities-api/wpGetAbilities.php b/tests/phpunit/tests/abilities-api/wpGetAbilities.php index e2570900c2c66..54635fc6bdf56 100644 --- a/tests/phpunit/tests/abilities-api/wpGetAbilities.php +++ b/tests/phpunit/tests/abilities-api/wpGetAbilities.php @@ -120,11 +120,11 @@ private function register_test_ability( string $name, array $overrides = array() // ------------------------------------------------------------------------- /** - * Tests that deprecated abilities remain in discovery by default. + * Tests that deprecated abilities are excluded from discovery by default. * * @ticket 64209 */ - public function test_deprecated_abilities_are_included_by_default(): void { + public function test_deprecated_abilities_are_excluded_by_default(): void { $this->simulate_wp_abilities_init(); $this->register_test_ability( 'test/active-ability' ); @@ -132,7 +132,10 @@ public function test_deprecated_abilities_are_included_by_default(): void { 'test/deprecated-ability', array( 'meta' => array( - 'deprecated' => array( 'replacement' => 'test/active-ability' ), + 'deprecated' => array( + 'since' => '2.0.0', + 'replacement' => 'test/active-ability', + ), ), ) ); @@ -140,15 +143,15 @@ public function test_deprecated_abilities_are_included_by_default(): void { $result = wp_get_abilities(); $this->assertArrayHasKey( 'test/active-ability', $result ); - $this->assertArrayHasKey( 'test/deprecated-ability', $result ); + $this->assertArrayNotHasKey( 'test/deprecated-ability', $result ); } /** - * Tests that deprecated abilities can be explicitly excluded from discovery. + * Tests that deprecated abilities can be included in discovery. * * @ticket 64209 */ - public function test_deprecated_abilities_can_be_excluded(): void { + public function test_deprecated_abilities_can_be_included_in_discovery(): void { $this->simulate_wp_abilities_init(); $this->register_test_ability( 'test/active-ability' ); @@ -156,19 +159,25 @@ public function test_deprecated_abilities_can_be_excluded(): void { 'test/deprecated-ability', array( 'meta' => array( - 'deprecated' => array( 'replacement' => 'test/active-ability' ), + 'deprecated' => array( + 'since' => '2.0.0', + 'replacement' => 'test/active-ability', + ), ), ) ); - $result = wp_get_abilities( array( 'meta' => array( 'deprecated' => false ) ) ); + $result = wp_get_abilities( array( 'include_deprecated' => true ) ); $this->assertArrayHasKey( 'test/active-ability', $result ); - $this->assertArrayNotHasKey( 'test/deprecated-ability', $result ); + $this->assertArrayHasKey( 'test/deprecated-ability', $result ); } /** - * Tests that the meta filter can return only deprecated abilities. + * Tests that a `deprecated` meta condition returns only deprecated abilities. + * + * A `deprecated` meta condition implies `include_deprecated`, and an empty array + * matches any deprecation details. * * @ticket 64209 */ @@ -180,7 +189,10 @@ public function test_deprecated_abilities_can_be_filtered_exclusively(): void { 'test/deprecated-ability', array( 'meta' => array( - 'deprecated' => array( 'replacement' => 'test/active-ability' ), + 'deprecated' => array( + 'since' => '2.0.0', + 'replacement' => 'test/active-ability', + ), ), ) ); @@ -196,11 +208,11 @@ public function test_deprecated_abilities_can_be_filtered_exclusively(): void { } /** - * Tests that `true` works as a shorthand for any deprecated ability. + * Tests that a null `deprecated` meta condition returns only active abilities. * * @ticket 64209 */ - public function test_deprecated_abilities_can_be_filtered_with_true_shorthand(): void { + public function test_deprecated_null_condition_matches_active_abilities(): void { $this->simulate_wp_abilities_init(); $this->register_test_ability( 'test/active-ability' ); @@ -208,15 +220,61 @@ public function test_deprecated_abilities_can_be_filtered_with_true_shorthand(): 'test/deprecated-ability', array( 'meta' => array( - 'deprecated' => array( 'replacement' => 'test/active-ability' ), + 'deprecated' => array( + 'since' => '2.0.0', + 'replacement' => 'test/active-ability', + ), ), ) ); - $result = wp_get_abilities( array( 'meta' => array( 'deprecated' => true ) ) ); + $result = wp_get_abilities( + array( + 'meta' => array( 'deprecated' => null ), + ) + ); + + $this->assertArrayHasKey( 'test/active-ability', $result ); + $this->assertArrayNotHasKey( 'test/deprecated-ability', $result ); + } + + /** + * Tests that a `deprecated` meta condition can match specific deprecation details. + * + * @ticket 64209 + */ + public function test_deprecated_abilities_can_be_filtered_by_details(): void { + $this->simulate_wp_abilities_init(); + + $this->register_test_ability( 'test/active-ability' ); + $this->register_test_ability( + 'test/deprecated-since-two', + array( + 'meta' => array( + 'deprecated' => array( 'since' => '2.0.0' ), + ), + ) + ); + $this->register_test_ability( + 'test/deprecated-since-three', + array( + 'meta' => array( + 'deprecated' => array( 'since' => '3.1.0' ), + ), + ) + ); + + $result = wp_get_abilities( + array( + 'meta' => array( + 'deprecated' => array( 'since' => '2.0.0' ), + ), + ) + ); $this->assertArrayNotHasKey( 'test/active-ability', $result ); - $this->assertArrayHasKey( 'test/deprecated-ability', $result ); + $this->assertArrayHasKey( 'test/deprecated-since-two', $result ); + $this->assertArrayNotHasKey( 'test/deprecated-since-three', $result ); } // ------------------------------------------------------------------------- diff --git a/tests/phpunit/tests/abilities-api/wpRegisterAbility.php b/tests/phpunit/tests/abilities-api/wpRegisterAbility.php index 073c914c3a25e..17243fba795cf 100644 --- a/tests/phpunit/tests/abilities-api/wpRegisterAbility.php +++ b/tests/phpunit/tests/abilities-api/wpRegisterAbility.php @@ -196,7 +196,7 @@ public function test_register_valid_ability(): void { 'annotations' => $expected_annotations, 'show_in_rest' => true, 'public' => false, - 'deprecated' => false, + 'deprecated' => null, ) ); diff --git a/tests/phpunit/tests/rest-api/wpRestAbilitiesV1ListController.php b/tests/phpunit/tests/rest-api/wpRestAbilitiesV1ListController.php index fec59711b9c90..b01af77bb3729 100644 --- a/tests/phpunit/tests/rest-api/wpRestAbilitiesV1ListController.php +++ b/tests/phpunit/tests/rest-api/wpRestAbilitiesV1ListController.php @@ -76,7 +76,7 @@ public function set_up(): void { */ public function tear_down(): void { // Clean up test abilities. - foreach ( wp_get_abilities() as $ability ) { + foreach ( wp_get_abilities( array( 'include_deprecated' => true ) ) as $ability ) { if ( ! str_starts_with( $ability->get_name(), 'test/' ) ) { continue; } @@ -342,11 +342,11 @@ public function test_get_items(): void { } /** - * Tests that collection discovery includes deprecated abilities by default. + * Tests that collection discovery excludes deprecated abilities by default. * * @ticket 64209 */ - public function test_get_items_includes_deprecated_abilities_by_default(): void { + public function test_get_items_excludes_deprecated_abilities_by_default(): void { $this->register_deprecated_ability(); $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities' ); @@ -355,46 +355,44 @@ public function test_get_items_includes_deprecated_abilities_by_default(): void $this->assertSame( 200, $response->get_status() ); - $ability_meta = wp_list_pluck( $response->get_data(), 'meta', 'name' ); + $ability_names = wp_list_pluck( $response->get_data(), 'name' ); - $this->assertArrayHasKey( 'test/deprecated-calculator', $ability_meta ); - $this->assertSame( - array( - 'since' => '2.0.0', - 'replacement' => 'test/calculator', - ), - $ability_meta['test/deprecated-calculator']['deprecated'] - ); + $this->assertContains( 'test/calculator', $ability_names ); + $this->assertNotContains( 'test/deprecated-calculator', $ability_names ); } /** - * Tests that collection discovery can explicitly exclude deprecated abilities. + * Tests that the `include_deprecated` parameter adds deprecated abilities to the collection. * * @ticket 64209 */ - public function test_get_items_can_exclude_deprecated_abilities(): void { + public function test_get_items_include_deprecated(): void { $this->register_deprecated_ability(); $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities' ); $request->set_param( 'per_page', 100 ); - $request->set_param( 'meta', array( 'deprecated' => 'false' ) ); + $request->set_param( 'include_deprecated', true ); $response = $this->server->dispatch( $request ); $this->assertSame( 200, $response->get_status() ); - $this->assertNotContains( 'test/deprecated-calculator', wp_list_pluck( $response->get_data(), 'name' ) ); + + $ability_names = wp_list_pluck( $response->get_data(), 'name' ); + + $this->assertContains( 'test/calculator', $ability_names ); + $this->assertContains( 'test/deprecated-calculator', $ability_names ); } /** - * Tests that collection discovery can return only deprecated abilities. + * Tests that a `deprecated` meta filter returns matching deprecated abilities in the collection. * * @ticket 64209 */ - public function test_get_items_can_filter_only_deprecated_abilities(): void { + public function test_get_items_deprecated_meta_filter_returns_matching_deprecated_abilities(): void { $this->register_deprecated_ability(); $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities' ); $request->set_param( 'per_page', 100 ); - $request->set_param( 'meta', array( 'deprecated' => 'true' ) ); + $request->set_param( 'meta', array( 'deprecated' => array( 'since' => '2.0.0' ) ) ); $response = $this->server->dispatch( $request ); $this->assertSame( 200, $response->get_status() ); @@ -405,6 +403,32 @@ public function test_get_items_can_filter_only_deprecated_abilities(): void { $this->assertNotContains( 'test/calculator', $ability_names ); } + /** + * Tests that the deprecated abilities remain retrievable through their exact REST route. + * + * @ticket 64209 + */ + public function test_get_item_deprecated_ability_by_exact_name(): void { + $this->register_deprecated_ability(); + + $this->setExpectedDeprecated( 'test/deprecated-calculator' ); + + $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/test/deprecated-calculator' ); + $response = $this->server->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + + $data = $response->get_data(); + $this->assertSame( 'test/deprecated-calculator', $data['name'] ); + $this->assertSame( + array( + 'since' => '2.0.0', + 'replacement' => 'test/calculator', + ), + $data['meta']['deprecated'] + ); + } + /** * Test getting a specific ability. * @@ -783,25 +807,6 @@ public function test_get_schema_meta_declares_public(): void { $this->assertSame( 'boolean', $meta_properties['public']['type'] ); } - /** - * Tests that the item schema declares structured deprecation metadata. - * - * @ticket 64209 - */ - public function test_get_schema_meta_declares_deprecated(): void { - $request = new WP_REST_Request( 'OPTIONS', '/wp-abilities/v1/abilities' ); - $response = $this->server->dispatch( $request ); - $data = $response->get_data(); - - $deprecated = $data['schema']['properties']['meta']['properties']['deprecated']; - - $this->assertSame( array( 'boolean', 'object' ), $deprecated['type'] ); - $this->assertSame( - array( 'since', 'replacement', 'message' ), - array_keys( $deprecated['properties'] ) - ); - } - /** * Test ability name with valid special characters. * diff --git a/tests/phpunit/tests/rest-api/wpRestAbilitiesV1RunController.php b/tests/phpunit/tests/rest-api/wpRestAbilitiesV1RunController.php index dd5caca729a72..e2d654ae1d214 100644 --- a/tests/phpunit/tests/rest-api/wpRestAbilitiesV1RunController.php +++ b/tests/phpunit/tests/rest-api/wpRestAbilitiesV1RunController.php @@ -87,7 +87,7 @@ public function set_up(): void { */ public function tear_down(): void { // Clean up test abilities. - foreach ( wp_get_abilities() as $ability ) { + foreach ( wp_get_abilities( array( 'include_deprecated' => true ) ) as $ability ) { if ( ! str_starts_with( $ability->get_name(), 'test/' ) ) { continue; } @@ -450,7 +450,10 @@ public function test_execute_deprecated_ability_by_exact_name(): void { 'permission_callback' => '__return_true', 'meta' => array( 'show_in_rest' => true, - 'deprecated' => array( 'replacement' => 'test/calculator' ), + 'deprecated' => array( + 'since' => '2.0.0', + 'replacement' => 'test/calculator', + ), ), ) ); diff --git a/tests/qunit/fixtures/wp-api-generated.js b/tests/qunit/fixtures/wp-api-generated.js index 6cbeff3d195a5..9b59cab599ddf 100644 --- a/tests/qunit/fixtures/wp-api-generated.js +++ b/tests/qunit/fixtures/wp-api-generated.js @@ -12711,6 +12711,11 @@ mockedApiResponse.Schema = { "type": "string", "required": false }, + "include_deprecated": { + "description": "Whether to include deprecated abilities in the results.", + "type": "boolean", + "required": false + }, "namespace": { "description": "Limit results to abilities in a specific namespace.", "type": "string", @@ -12747,28 +12752,6 @@ mockedApiResponse.Schema = { } }, "additionalProperties": true - }, - "deprecated": { - "description": "Limit results by deprecation status or details. Use true to return only deprecated abilities, false to return only active abilities, or an object to match specific deprecation details.", - "type": [ - "boolean", - "object" - ], - "properties": { - "since": { - "description": "Version of the ability provider that deprecated the ability.", - "type": "string" - }, - "replacement": { - "description": "Namespaced ability to use instead.", - "type": "string" - }, - "message": { - "description": "Additional migration guidance.", - "type": "string" - } - }, - "additionalProperties": true } }, "additionalProperties": true, From 575c8f0612af26e00cab8ce0d59624aa09451a70 Mon Sep 17 00:00:00 2001 From: Dovid Levine Date: Wed, 30 Sep 2026 15:53:04 +0300 Subject: [PATCH 8/8] fix: include deprecation notices on execute() --- src/wp-includes/abilities-api.php | 4 ++-- src/wp-includes/abilities-api/class-wp-ability.php | 11 +++++++---- src/wp-includes/rest-api.php | 2 +- tests/phpunit/tests/abilities-api/wpAbility.php | 12 ++++++++---- 4 files changed, 18 insertions(+), 11 deletions(-) diff --git a/src/wp-includes/abilities-api.php b/src/wp-includes/abilities-api.php index fd34c11ecdd98..74f030e887bae 100644 --- a/src/wp-includes/abilities-api.php +++ b/src/wp-includes/abilities-api.php @@ -517,8 +517,8 @@ function wp_get_ability( string $name ): ?WP_Ability { * @type string $category Filter by category slug. Only abilities whose category * exactly matches the given slug are included. * @type bool $include_deprecated Whether to include deprecated abilities. Deprecated abilities - * retrieved this way do not trigger deprecation notices. - * Default false. + * retrieved this way do not trigger deprecation notices until + * they are executed. Default false. * @type string $namespace Filter by ability namespace prefix. Pass the namespace * without a trailing slash, e.g. `'woocommerce'` matches * `'woocommerce/create-order'`. diff --git a/src/wp-includes/abilities-api/class-wp-ability.php b/src/wp-includes/abilities-api/class-wp-ability.php index 0cad435410d40..d098ead684e2d 100644 --- a/src/wp-includes/abilities-api/class-wp-ability.php +++ b/src/wp-includes/abilities-api/class-wp-ability.php @@ -829,6 +829,9 @@ protected function validate_output( $output ) { * @return mixed|WP_Error The result of the ability execution, or WP_Error on failure. */ public function execute( $input = null ) { + // Triggers a deprecation notice if the ability is marked as as deprecated and hasn't been triggered earlier. + $this->_handle_ability_deprecation(); + /** * Fires when an ability is invoked, before any processing takes place. * @@ -940,15 +943,15 @@ public function execute( $input = null ) { } /** - * Informs when a deprecated ability has been retrieved. + * Informs when a deprecated ability has been called. * * There is a {@see 'deprecated_ability_run'} hook that will be called that can be used - * to get the backtrace up to what file and function retrieved the deprecated ability. + * to get the backtrace up to what file and function used the deprecated ability. * * The current behavior is to trigger a user error if `WP_DEBUG` is true. * * @since 7.2.0 - * @internal Triggered when a deprecated ability is retrieved. It should never be called directly in user + * @internal Triggered when a deprecated ability is called. It should never be called directly in user * code. There is no guarantee of backward compatibility. */ public function _handle_ability_deprecation(): void { @@ -963,7 +966,7 @@ public function _handle_ability_deprecation(): void { $message = $deprecation_meta['message'] ?? ''; /** - * Fires every time a deprecated ability is accessed. + * Fires every time a deprecated ability is called. * * @since 7.2.0 * diff --git a/src/wp-includes/rest-api.php b/src/wp-includes/rest-api.php index b0db22bcd3fac..cd46991b49cf5 100644 --- a/src/wp-includes/rest-api.php +++ b/src/wp-includes/rest-api.php @@ -776,7 +776,7 @@ function rest_handle_deprecated_argument( $function_name, $message, $version ) { * * @since 7.2.0 * - * @param string $ability_name The deprecated ability that was retrieved. + * @param string $ability_name The deprecated ability that was used. * @param string $replacement The ability that should be used as a replacement. * @param string $version The version of the ability provider that deprecated the ability. * @param string $message Additional migration guidance. diff --git a/tests/phpunit/tests/abilities-api/wpAbility.php b/tests/phpunit/tests/abilities-api/wpAbility.php index f2c74f4f920c5..e804bac947cd4 100644 --- a/tests/phpunit/tests/abilities-api/wpAbility.php +++ b/tests/phpunit/tests/abilities-api/wpAbility.php @@ -481,17 +481,21 @@ public function test_meta_deprecated_rejects_invalid_detail( array $deprecated, } /** - * Tests that executing a deprecated ability does not emit a deprecation notice. + * Tests that executing a deprecated ability emits a deprecation notice. * - * Deprecation notices are emitted when an ability is retrieved by name, not on execution. - * Any unexpected notice fails the test. + * Covers abilities that were retrieved without a notice, e.g. via `wp_get_abilities()`. * * @ticket 64209 + * + * @covers WP_Ability::execute + * @covers WP_Ability::_handle_ability_deprecation */ - public function test_execute_deprecated_ability_does_not_emit_deprecation(): void { + public function test_execute_deprecated_ability_emits_deprecation(): void { $args = self::$test_ability_properties; $args['meta']['deprecated'] = array( 'since' => '2.0.0' ); + $this->setExpectedDeprecated( self::$test_ability_name ); + $ability = new WP_Ability( self::$test_ability_name, $args ); $this->assertSame( 0, $ability->execute() );