diff --git a/src/wp-includes/abilities-api.php b/src/wp-includes/abilities-api.php index 393e40b56ed8c..74f030e887bae 100644 --- a/src/wp-includes/abilities-api.php +++ b/src/wp-includes/abilities-api.php @@ -231,9 +231,21 @@ * '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`. + * @since 7.2.0 Added the `deprecated` meta property. * * @see WP_Abilities_Registry::register() * @see wp_register_ability_category() @@ -282,6 +294,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 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 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. @@ -416,13 +437,13 @@ function wp_get_ability( string $name ): ?WP_Ability { /** * 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. @@ -433,9 +454,19 @@ function wp_get_ability( string $name ): ?WP_Ability { * * Examples: * - * // All abilities (unchanged behaviour). + * // All (non-deprecated) abilities. * $abilities = wp_get_abilities(); * + * // All abilities, including deprecated ones. + * $abilities = wp_get_abilities( array( + * 'include_deprecated' => true, + * ) ); + * + * // Only deprecated abilities. An empty array matches any deprecation details. + * $abilities = wp_get_abilities( array( + * 'meta' => array( 'deprecated' => array() ), + * ) ); + * * // Filter by category. * $abilities = wp_get_abilities( array( 'category' => 'content' ) ); * @@ -475,20 +506,26 @@ function wp_get_ability( string $name ): ?WP_Ability { * * @since 6.9.0 * @since 7.1.0 Added the `$args` parameter for filtering support. + * @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 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'`. * @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. @@ -512,23 +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; $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 813c3dd49f7be..01761fa8900c1 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() * @@ -78,6 +79,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 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 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. * } @@ -113,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. @@ -135,6 +146,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 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 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. * } @@ -267,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() * @@ -283,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 88853f6496a31..d098ead684e2d 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() * @@ -169,12 +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 bool $show_in_rest Optional. Whether to expose this ability in the REST API. - * Default is the value of `$public` when set, false otherwise. + * @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 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. * } * } */ @@ -211,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() * @@ -239,12 +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 bool $show_in_rest Optional. Whether to expose this ability in the REST API. - * Default is the value of `$public` when set, false otherwise. + * @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 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. * } * } * @return array { @@ -272,10 +292,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 null|array $deprecated { + * Deprecation details, or null when the ability is not deprecated. + * + * @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 $show_in_rest Whether to expose this ability in the REST API. * } * } * @throws InvalidArgumentException if an argument is invalid. @@ -351,6 +378,35 @@ protected function prepare_properties( array $args ): array { ); } + if ( isset( $args['meta']['deprecated'] ) ) { + if ( ! is_array( $args['meta']['deprecated'] ) ) { + throw new InvalidArgumentException( + __( 'The ability meta should provide `deprecated` as null or an array of deprecation details.' ) + ); + } + + 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; + } + + 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 + ) + ); + } + } + } + // Set defaults for optional meta. $args['meta'] = wp_parse_args( $args['meta'] ?? array(), @@ -371,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; } @@ -772,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. * @@ -882,6 +942,84 @@ public function execute( $input = null ) { return $result; } + /** + * 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 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 called. 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 called. + * + * @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 36b5b23de9f00..cd46991b49cf5 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,36 @@ function rest_handle_deprecated_argument( $function_name, $message, $version ) { header( sprintf( 'X-WP-DeprecatedParam: %s', $string ) ); } +/** + * Handles deprecated ability notices. + * + * @since 7.2.0 + * + * @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. + */ +function rest_handle_deprecated_ability( $ability_name, $replacement, $version, $message ) { + if ( ! WP_DEBUG || headers_sent() ) { + return; + } + + 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 ); + } else { + /* 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 ) { + $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 5547e7e002f02..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'] ); @@ -245,6 +250,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. */ @@ -317,6 +323,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 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.' ), + '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, @@ -332,38 +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 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( diff --git a/tests/phpunit/includes/abstract-testcase.php b/tests/phpunit/includes/abstract-testcase.php index 2dde7e8b5787c..4817a5300b901 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' ); } @@ -892,6 +894,28 @@ public function deprecated_function_run( $function_name, $replacement, $version, ) . ' ' . $message; } break; + + case 'deprecated_ability_run': + $additional_message = $message; + + 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 + ); + } + if ( $additional_message ) { + $message .= ' ' . $additional_message; + } + break; } $this->caught_deprecated[ $function_name ] = $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 f613736211f77..e804bac947cd4 100644 --- a/tests/phpunit/tests/abilities-api/wpAbility.php +++ b/tests/phpunit/tests/abilities-api/wpAbility.php @@ -381,6 +381,126 @@ public function test_meta_public_throws_exception_for_non_boolean() { new WP_Ability( self::$test_ability_name, $args ); } + /** + * Tests that `deprecated` rejects values other than 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 null or an array of deprecation details.' ); + + new WP_Ability( self::$test_ability_name, $args ); + } + + /** + * Data provider for deprecation details that must include a `since` version. + * + * @return array Data sets. + */ + 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.', + ), + ), + ); + } + + /** + * 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_details_without_since( array $deprecated ): void { + $args = self::$test_ability_properties; + $args['meta']['deprecated'] = $deprecated; + + $this->expectException( InvalidArgumentException::class ); + $this->expectExceptionMessage( 'The ability deprecation details must include a `since` version.' ); + + new WP_Ability( self::$test_ability_name, $args ); + } + + /** + * Data provider for supported `deprecated` details with invalid values. + * + * @return array Data sets. + */ + 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 + * + * @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_detail( array $deprecated, string $key ): void { + $args = self::$test_ability_properties; + $args['meta']['deprecated'] = $deprecated; + + $this->expectException( InvalidArgumentException::class ); + $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 a deprecation notice. + * + * 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_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() ); + } + /** * 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..54635fc6bdf56 100644 --- a/tests/phpunit/tests/abilities-api/wpGetAbilities.php +++ b/tests/phpunit/tests/abilities-api/wpGetAbilities.php @@ -115,6 +115,168 @@ private function register_test_ability( string $name, array $overrides = array() return $ability; } + // ------------------------------------------------------------------------- + // Deprecated abilities + // ------------------------------------------------------------------------- + + /** + * Tests that deprecated abilities are excluded from discovery by default. + * + * @ticket 64209 + */ + public function test_deprecated_abilities_are_excluded_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( + 'since' => '2.0.0', + 'replacement' => 'test/active-ability', + ), + ), + ) + ); + + $result = wp_get_abilities(); + + $this->assertArrayHasKey( 'test/active-ability', $result ); + $this->assertArrayNotHasKey( 'test/deprecated-ability', $result ); + } + + /** + * Tests that deprecated abilities can be included in discovery. + * + * @ticket 64209 + */ + public function test_deprecated_abilities_can_be_included_in_discovery(): 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( + 'since' => '2.0.0', + 'replacement' => 'test/active-ability', + ), + ), + ) + ); + + $result = wp_get_abilities( array( 'include_deprecated' => true ) ); + + $this->assertArrayHasKey( 'test/active-ability', $result ); + $this->assertArrayHasKey( 'test/deprecated-ability', $result ); + } + + /** + * 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 + */ + 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( + 'since' => '2.0.0', + '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 ); + } + + /** + * Tests that a null `deprecated` meta condition returns only active abilities. + * + * @ticket 64209 + */ + public function test_deprecated_null_condition_matches_active_abilities(): 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( + 'since' => '2.0.0', + 'replacement' => 'test/active-ability', + ), + ), + ) + ); + + $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-since-two', $result ); + $this->assertArrayNotHasKey( 'test/deprecated-since-three', $result ); + } + // ------------------------------------------------------------------------- // Category filter // ------------------------------------------------------------------------- diff --git a/tests/phpunit/tests/abilities-api/wpRegisterAbility.php b/tests/phpunit/tests/abilities-api/wpRegisterAbility.php index cfa81924a0de2..17243fba795cf 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' => null, ) ); diff --git a/tests/phpunit/tests/rest-api/wpRestAbilitiesV1ListController.php b/tests/phpunit/tests/rest-api/wpRestAbilitiesV1ListController.php index 3f31831a825a5..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; } @@ -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,94 @@ public function test_get_items(): void { $this->assertNotContains( 'test/not-show-in-rest', $ability_names ); } + /** + * Tests that collection discovery excludes deprecated abilities by default. + * + * @ticket 64209 + */ + 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' ); + $request->set_param( 'per_page', 100 ); + $response = $this->server->dispatch( $request ); + + $this->assertSame( 200, $response->get_status() ); + + $ability_names = wp_list_pluck( $response->get_data(), 'name' ); + + $this->assertContains( 'test/calculator', $ability_names ); + $this->assertNotContains( 'test/deprecated-calculator', $ability_names ); + } + + /** + * Tests that the `include_deprecated` parameter adds deprecated abilities to the collection. + * + * @ticket 64209 + */ + 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( 'include_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/calculator', $ability_names ); + $this->assertContains( 'test/deprecated-calculator', $ability_names ); + } + + /** + * Tests that a `deprecated` meta filter returns matching deprecated abilities in the collection. + * + * @ticket 64209 + */ + 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' => array( 'since' => '2.0.0' ) ) ); + $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 ); + } + + /** + * 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. * diff --git a/tests/phpunit/tests/rest-api/wpRestAbilitiesV1RunController.php b/tests/phpunit/tests/rest-api/wpRestAbilitiesV1RunController.php index e546ce05fd29e..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; } @@ -432,6 +432,41 @@ 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( + 'since' => '2.0.0', + '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..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",