<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN"
"http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head><meta http-equiv="content-type" content="text/html; charset=utf-8" />
<title>[61032] trunk: Abilities API: Introduce server-side registry and REST API endpoints</title>
</head>
<body>

<style type="text/css"><!--
#msg dl.meta { border: 1px #006 solid; background: #369; padding: 6px; color: #fff; }
#msg dl.meta dt { float: left; width: 6em; font-weight: bold; }
#msg dt:after { content:':';}
#msg dl, #msg dt, #msg ul, #msg li, #header, #footer, #logmsg { font-family: verdana,arial,helvetica,sans-serif; font-size: 10pt;  }
#msg dl a { font-weight: bold}
#msg dl a:link    { color:#fc3; }
#msg dl a:active  { color:#ff0; }
#msg dl a:visited { color:#cc6; }
h3 { font-family: verdana,arial,helvetica,sans-serif; font-size: 10pt; font-weight: bold; }
#msg pre { white-space: pre-line; overflow: auto; background: #ffc; border: 1px #fa0 solid; padding: 6px; }
#logmsg { background: #ffc; border: 1px #fa0 solid; padding: 1em 1em 0 1em; }
#logmsg p, #logmsg pre, #logmsg blockquote { margin: 0 0 1em 0; }
#logmsg p, #logmsg li, #logmsg dt, #logmsg dd { line-height: 14pt; }
#logmsg h1, #logmsg h2, #logmsg h3, #logmsg h4, #logmsg h5, #logmsg h6 { margin: .5em 0; }
#logmsg h1:first-child, #logmsg h2:first-child, #logmsg h3:first-child, #logmsg h4:first-child, #logmsg h5:first-child, #logmsg h6:first-child { margin-top: 0; }
#logmsg ul, #logmsg ol { padding: 0; list-style-position: inside; margin: 0 0 0 1em; }
#logmsg ul { text-indent: -1em; padding-left: 1em; }#logmsg ol { text-indent: -1.5em; padding-left: 1.5em; }
#logmsg > ul, #logmsg > ol { margin: 0 0 1em 0; }
#logmsg pre { background: #eee; padding: 1em; }
#logmsg blockquote { border: 1px solid #fa0; border-left-width: 10px; padding: 1em 1em 0 1em; background: white;}
#logmsg dl { margin: 0; }
#logmsg dt { font-weight: bold; }
#logmsg dd { margin: 0; padding: 0 0 0.5em 0; }
#logmsg dd:before { content:'\00bb';}
#logmsg table { border-spacing: 0px; border-collapse: collapse; border-top: 4px solid #fa0; border-bottom: 1px solid #fa0; background: #fff; }
#logmsg table th { text-align: left; font-weight: normal; padding: 0.2em 0.5em; border-top: 1px dotted #fa0; }
#logmsg table td { text-align: right; border-top: 1px dotted #fa0; padding: 0.2em 0.5em; }
#logmsg table thead th { text-align: center; border-bottom: 1px solid #fa0; }
#logmsg table th.Corner { text-align: left; }
#logmsg hr { border: none 0; border-top: 2px dashed #fa0; height: 1px; }
#header, #footer { color: #fff; background: #636; border: 1px #300 solid; padding: 6px; }
#patch { width: 100%; }
#patch h4 {font-family: verdana,arial,helvetica,sans-serif;font-size:10pt;padding:8px;background:#369;color:#fff;margin:0;}
#patch .propset h4, #patch .binary h4 {margin:0;}
#patch pre {padding:0;line-height:1.2em;margin:0;}
#patch .diff {width:100%;background:#eee;padding: 0 0 10px 0;overflow:auto;}
#patch .propset .diff, #patch .binary .diff  {padding:10px 0;}
#patch span {display:block;padding:0 10px;}
#patch .modfile, #patch .addfile, #patch .delfile, #patch .propset, #patch .binary, #patch .copfile {border:1px solid #ccc;margin:10px 0;}
#patch ins {background:#dfd;text-decoration:none;display:block;padding:0 10px;}
#patch del {background:#fdd;text-decoration:none;display:block;padding:0 10px;}
#patch .lines, .info {color:#888;background:#fff;}
--></style>
<div id="msg">
<dl class="meta" style="font-size: 105%">
<dt style="float: left; width: 6em; font-weight: bold">Revision</dt> <dd><a style="font-weight: bold" href="https://core.trac.wordpress.org/changeset/61032">61032</a><script type="application/ld+json">{"@context":"http://schema.org","@type":"EmailMessage","description":"Review this Commit","action":{"@type":"ViewAction","url":"https://core.trac.wordpress.org/changeset/61032","name":"Review Commit"}}</script></dd>
<dt style="float: left; width: 6em; font-weight: bold">Author</dt> <dd>gziolo</dd>
<dt style="float: left; width: 6em; font-weight: bold">Date</dt> <dd>2025-10-21 13:50:11 +0000 (Tue, 21 Oct 2025)</dd>
</dl>

<pre style='padding-left: 1em; margin: 2em 0; border-left: 2px solid #ccc; line-height: 1.25; font-size: 105%; font-family: sans-serif'>Abilities API: Introduce server-side registry and REST API endpoints

Feature proposal at https://make.wordpress.org/ai/2025/07/17/abilities-api/.
Project developed in https://github.com/WordPress/abilities-api.

Introduces a new Abilities API that allows WordPress plugins and themes to register and execute custom abilities with built-in permission checking, input/output validation via JSON Schema, and REST API integration.

## Public Functions

### Ability Management
- `wp_register_ability( string $name, array $args ): ?WP_Ability` - Registers a new ability (must be called on `wp_abilities_api_init` hook)
- `wp_unregister_ability( string $name ): ?WP_Ability` - Unregisters an ability
- `wp_has_ability( string $name ): bool` - Checks if an ability is registered
- `wp_get_ability( string $name ): ?WP_Ability` - Retrieves a registered ability
- `wp_get_abilities(): array` - Retrieves all registered abilities

### Ability Category Management
- `wp_register_ability_category( string $slug, array $args ): ?WP_Ability_Category` - Registers an ability category (must be called on `wp_abilities_api_categories_init` hook)
- `wp_unregister_ability_category( string $slug ): ?WP_Ability_Category` - Unregisters an ability category
- `wp_has_ability_category( string $slug ): bool` - Checks if an ability category is registered
- `wp_get_ability_category( string $slug ): ?WP_Ability_Category` - Retrieves a registered ability category
- `wp_get_ability_categories(): array` - Retrieves all registered ability categories

## Public Classes

- `WP_Ability` - Encapsulates ability properties and methods (execute, check_permission, validate_input, etc.)
- `WP_Ability_Category` - Encapsulates ability category properties
- `WP_Abilities_Registry` - Manages ability registration and lookup (private, accessed via functions)
- `WP_Ability_Categories_Registry` - Manages ability category registration (private, accessed via functions)
- `WP_REST_Abilities_V1_List_Controller` - REST controller for listing abilities
- `WP_REST_Abilities_V1_Run_Controller` - REST controller for executing abilities

## REST API Endpoints

### Namespace: `wp-abilities/v1`

#### List Abilities
- `GET /wp-abilities/v1/abilities` - Retrieve all registered abilities
  - Query parameters: `page`, `per_page`, `category`

#### Get Single Ability
- `GET /wp-abilities/v1/abilities/(?P<name>[a-zA-Z0-9\-\/]+)` - Retrieve a specific ability by name

#### Execute Ability
- `GET|POST|DELETE /wp-abilities/v1/abilities/(?P<name>[a-zA-Z0-9\-\/]+)/run` - Execute an ability
  - Supports multiple HTTP methods based on ability annotations
  - Validates input against ability's input schema
  - Validates output against ability's output schema
  - Performs permission checks via ability's permission callback

## Hooks

### Actions
- `wp_abilities_api_categories_init` - Fired when ability categories registry is initialized (register categories here)
- `wp_abilities_api_init` - Fired when abilities registry is initialized (register abilities here)
- `wp_before_execute_ability` - Fired before an ability gets executed, after input validation and permissions check

- `wp_after_execute_ability` - Fires immediately after an ability finished executing

### Filters
- `wp_register_ability_category_args` - Filters ability category arguments before registration
- `wp_register_ability_args` - Filters ability arguments before registration

Developed in https://github.com/WordPress/wordpress-develop/pull/9410.

Props gziolo, jorbin, justlevine, westonruter, jason_the_adams, flixos90, karmatosed, timothyblynjacobs.
Fixes <a href="https://core.trac.wordpress.org/ticket/64098">#64098</a>.</pre>

<h3>Modified Paths</h3>
<ul>
<li><a href="#trunksrcwpincludesrestapiphp">trunk/src/wp-includes/rest-api.php</a></li>
<li><a href="#trunksrcwpsettingsphp">trunk/src/wp-settings.php</a></li>
<li><a href="#trunktestsphpunittestsrestapirestschemasetupphp">trunk/tests/phpunit/tests/rest-api/rest-schema-setup.php</a></li>
<li><a href="#trunktestsqunitfixtureswpapigeneratedjs">trunk/tests/qunit/fixtures/wp-api-generated.js</a></li>
</ul>

<h3>Added Paths</h3>
<ul>
<li>trunk/src/wp-includes/abilities-api/</li>
<li><a href="#trunksrcwpincludesabilitiesapiclasswpabilitiesregistryphp">trunk/src/wp-includes/abilities-api/class-wp-abilities-registry.php</a></li>
<li><a href="#trunksrcwpincludesabilitiesapiclasswpabilitycategoriesregistryphp">trunk/src/wp-includes/abilities-api/class-wp-ability-categories-registry.php</a></li>
<li><a href="#trunksrcwpincludesabilitiesapiclasswpabilitycategoryphp">trunk/src/wp-includes/abilities-api/class-wp-ability-category.php</a></li>
<li><a href="#trunksrcwpincludesabilitiesapiclasswpabilityphp">trunk/src/wp-includes/abilities-api/class-wp-ability.php</a></li>
<li><a href="#trunksrcwpincludesabilitiesapiphp">trunk/src/wp-includes/abilities-api.php</a></li>
<li><a href="#trunksrcwpincludesrestapiendpointsclasswprestabilitiesv1listcontrollerphp">trunk/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-list-controller.php</a></li>
<li><a href="#trunksrcwpincludesrestapiendpointsclasswprestabilitiesv1runcontrollerphp">trunk/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-run-controller.php</a></li>
<li>trunk/tests/phpunit/tests/abilities-api/</li>
<li><a href="#trunktestsphpunittestsabilitiesapiwpAbilitiesRegistryphp">trunk/tests/phpunit/tests/abilities-api/wpAbilitiesRegistry.php</a></li>
<li><a href="#trunktestsphpunittestsabilitiesapiwpAbilityphp">trunk/tests/phpunit/tests/abilities-api/wpAbility.php</a></li>
<li><a href="#trunktestsphpunittestsabilitiesapiwpAbilityCategoryRegistryphp">trunk/tests/phpunit/tests/abilities-api/wpAbilityCategoryRegistry.php</a></li>
<li><a href="#trunktestsphpunittestsabilitiesapiwpRegisterAbilityphp">trunk/tests/phpunit/tests/abilities-api/wpRegisterAbility.php</a></li>
<li><a href="#trunktestsphpunittestsabilitiesapiwpRegisterAbilityCategoryphp">trunk/tests/phpunit/tests/abilities-api/wpRegisterAbilityCategory.php</a></li>
<li><a href="#trunktestsphpunittestsrestapiwpRestAbilitiesV1ListControllerphp">trunk/tests/phpunit/tests/rest-api/wpRestAbilitiesV1ListController.php</a></li>
<li><a href="#trunktestsphpunittestsrestapiwpRestAbilitiesV1RunControllerphp">trunk/tests/phpunit/tests/rest-api/wpRestAbilitiesV1RunController.php</a></li>
</ul>

</div>
<div id="patch">
<h3>Diff</h3>
<a id="trunksrcwpincludesabilitiesapiclasswpabilitiesregistryphp"></a>
<div class="addfile"><h4 style="background-color: #eee; color: inherit; margin: 1em 0; padding: 1.3em; font-size: 115%">Added: trunk/src/wp-includes/abilities-api/class-wp-abilities-registry.php</h4>
<pre class="diff"><span>
<span class="info" style="display: block; padding: 0 10px; color: #888">--- trunk/src/wp-includes/abilities-api/class-wp-abilities-registry.php                               (rev 0)
+++ trunk/src/wp-includes/abilities-api/class-wp-abilities-registry.php 2025-10-21 13:50:11 UTC (rev 61032)
</span><span class="lines" style="display: block; padding: 0 10px; color: #888">@@ -0,0 +1,320 @@
</span><ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+<?php
+/**
+ * Abilities API
+ *
+ * Defines WP_Abilities_Registry class.
+ *
+ * @package WordPress
+ * @subpackage Abilities API
+ * @since 6.9.0
+ */
+
+declare( strict_types = 1 );
+
+/**
+ * Manages the registration and lookup of abilities.
+ *
+ * @since 6.9.0
+ * @access private
+ */
+final class WP_Abilities_Registry {
+       /**
+        * The singleton instance of the registry.
+        *
+        * @since 6.9.0
+        * @var self|null
+        */
+       private static $instance = null;
+
+       /**
+        * Holds the registered abilities.
+        *
+        * @since 6.9.0
+        * @var WP_Ability[]
+        */
+       private $registered_abilities = array();
+
+       /**
+        * Registers a new ability.
+        *
+        * Do not use this method directly. Instead, use the `wp_register_ability()` function.
+        *
+        * @since 6.9.0
+        *
+        * @see wp_register_ability()
+        *
+        * @param string               $name The name of the ability. The name must be a string containing a namespace
+        *                                   prefix, i.e. `my-plugin/my-ability`. It can only contain lowercase
+        *                                   alphanumeric characters, dashes and the forward slash.
+        * @param array<string, mixed> $args {
+        *     An associative array of arguments for the ability.
+        *
+        *     @type string               $label                 The human-readable label for the ability.
+        *     @type string               $description           A detailed description of what the ability does.
+        *     @type string               $category              The ability category slug this ability belongs to.
+        *     @type callable             $execute_callback      A callback function to execute when the ability is invoked.
+        *                                                       Receives optional mixed input and returns mixed result or WP_Error.
+        *     @type callable             $permission_callback   A callback function to check permissions before execution.
+        *                                                       Receives optional mixed input and returns bool or WP_Error.
+        *     @type array<string, mixed> $input_schema          Optional. JSON Schema definition for the ability's input.
+        *     @type array<string, mixed> $output_schema         Optional. JSON Schema definition for the ability's output.
+        *     @type array<string, mixed> $meta                  {
+        *         Optional. Additional metadata for the ability.
+        *
+        *         @type array<string, null|bool> $annotations  Optional. Annotation metadata for the ability.
+        *         @type bool                     $show_in_rest Optional. Whether to expose this ability in the REST API. Default false.
+        *     }
+        *     @type string               $ability_class         Optional. Custom class to instantiate instead of WP_Ability.
+        * }
+        * @return WP_Ability|null The registered ability instance on success, null on failure.
+        */
+       public function register( string $name, array $args ): ?WP_Ability {
+               if ( ! preg_match( '/^[a-z0-9-]+\/[a-z0-9-]+$/', $name ) ) {
+                       _doing_it_wrong(
+                               __METHOD__,
+                               __(
+                                       'Ability name must be a string containing a namespace prefix, i.e. "my-plugin/my-ability". It can only contain lowercase alphanumeric characters, dashes and the forward slash.'
+                               ),
+                               '6.9.0'
+                       );
+                       return null;
+               }
+
+               if ( $this->is_registered( $name ) ) {
+                       _doing_it_wrong(
+                               __METHOD__,
+                               /* translators: %s: Ability name. */
+                               sprintf( __( 'Ability "%s" is already registered.' ), esc_html( $name ) ),
+                               '6.9.0'
+                       );
+                       return null;
+               }
+
+               /**
+                * Filters the ability arguments before they are validated and used to instantiate the ability.
+                *
+                * @since 6.9.0
+                *
+                * @param array<string, mixed> $args {
+                *     An associative array of arguments for the ability.
+                *
+                *     @type string               $label                 The human-readable label for the ability.
+                *     @type string               $description           A detailed description of what the ability does.
+                *     @type string               $category              The ability category slug this ability belongs to.
+                *     @type callable             $execute_callback      A callback function to execute when the ability is invoked.
+                *                                                       Receives optional mixed input and returns mixed result or WP_Error.
+                *     @type callable             $permission_callback   A callback function to check permissions before execution.
+                *                                                       Receives optional mixed input and returns bool or WP_Error.
+                *     @type array<string, mixed> $input_schema          Optional. JSON Schema definition for the ability's input.
+                *     @type array<string, mixed> $output_schema         Optional. JSON Schema definition for the ability's output.
+                *     @type array<string, mixed> $meta                  {
+                *         Optional. Additional metadata for the ability.
+                *
+                *         @type array<string, bool|string> $annotations  Optional. Annotation metadata for the ability.
+                *         @type bool                       $show_in_rest Optional. Whether to expose this ability in the REST API. Default false.
+                *     }
+                *     @type string               $ability_class         Optional. Custom class to instantiate instead of WP_Ability.
+                * }
+                * @param string               $name The name of the ability, with its namespace.
+                */
+               $args = apply_filters( 'wp_register_ability_args', $args, $name );
+
+               // Validate ability category exists if provided (will be validated as required in WP_Ability).
+               if ( isset( $args['category'] ) ) {
+                       $category_registry = WP_Ability_Categories_Registry::get_instance();
+                       if ( ! $category_registry->is_registered( $args['category'] ) ) {
+                               _doing_it_wrong(
+                                       __METHOD__,
+                                       sprintf(
+                                               /* translators: %1$s: ability category slug, %2$s: ability name */
+                                               __( 'Ability category "%1$s" is not registered. Please register the ability category before assigning it to ability "%2$s".' ),
+                                               esc_html( $args['category'] ),
+                                               esc_html( $name )
+                                       ),
+                                       '6.9.0'
+                               );
+                               return null;
+                       }
+               }
+
+               // The class is only used to instantiate the ability, and is not a property of the ability itself.
+               if ( isset( $args['ability_class'] ) && ! is_a( $args['ability_class'], WP_Ability::class, true ) ) {
+                       _doing_it_wrong(
+                               __METHOD__,
+                               __( 'The ability args should provide a valid `ability_class` that extends WP_Ability.' ),
+                               '6.9.0'
+                       );
+                       return null;
+               }
+
+               /** @var class-string<WP_Ability> */
+               $ability_class = $args['ability_class'] ?? WP_Ability::class;
+               unset( $args['ability_class'] );
+
+               try {
+                       // WP_Ability::prepare_properties() will throw an exception if the properties are invalid.
+                       $ability = new $ability_class( $name, $args );
+               } catch ( InvalidArgumentException $e ) {
+                       _doing_it_wrong(
+                               __METHOD__,
+                               $e->getMessage(),
+                               '6.9.0'
+                       );
+                       return null;
+               }
+
+               $this->registered_abilities[ $name ] = $ability;
+               return $ability;
+       }
+
+       /**
+        * Unregisters an ability.
+        *
+        * Do not use this method directly. Instead, use the `wp_unregister_ability()` function.
+        *
+        * @since 6.9.0
+        *
+        * @see wp_unregister_ability()
+        *
+        * @param string $name The name of the registered ability, with its namespace.
+        * @return WP_Ability|null The unregistered ability instance on success, null on failure.
+        */
+       public function unregister( string $name ): ?WP_Ability {
+               if ( ! $this->is_registered( $name ) ) {
+                       _doing_it_wrong(
+                               __METHOD__,
+                               /* translators: %s: Ability name. */
+                               sprintf( __( 'Ability "%s" not found.' ), esc_html( $name ) ),
+                               '6.9.0'
+                       );
+                       return null;
+               }
+
+               $unregistered_ability = $this->registered_abilities[ $name ];
+               unset( $this->registered_abilities[ $name ] );
+
+               return $unregistered_ability;
+       }
+
+       /**
+        * Retrieves the list of all registered abilities.
+        *
+        * Do not use this method directly. Instead, use the `wp_get_abilities()` function.
+        *
+        * @since 6.9.0
+        *
+        * @see wp_get_abilities()
+        *
+        * @return WP_Ability[] The array of registered abilities.
+        */
+       public function get_all_registered(): array {
+               return $this->registered_abilities;
+       }
+
+       /**
+        * Checks if an ability is registered.
+        *
+        * Do not use this method directly. Instead, use the `wp_has_ability()` function.
+        *
+        * @since 6.9.0
+        *
+        * @see wp_has_ability()
+        *
+        * @param string $name The name of the registered ability, with its namespace.
+        * @return bool True if the ability is registered, false otherwise.
+        */
+       public function is_registered( string $name ): bool {
+               return isset( $this->registered_abilities[ $name ] );
+       }
+
+       /**
+        * Retrieves a registered ability.
+        *
+        * Do not use this method directly. Instead, use the `wp_get_ability()` function.
+        *
+        * @since 6.9.0
+        *
+        * @see wp_get_ability()
+        *
+        * @param string $name The name of the registered ability, with its namespace.
+        * @return ?WP_Ability The registered ability instance, or null if it is not registered.
+        */
+       public function get_registered( string $name ): ?WP_Ability {
+               if ( ! $this->is_registered( $name ) ) {
+                       _doing_it_wrong(
+                               __METHOD__,
+                               /* translators: %s: Ability name. */
+                               sprintf( esc_html__( 'Ability "%s" not found.' ), esc_attr( $name ) ),
+                               '6.9.0'
+                       );
+                       return null;
+               }
+               return $this->registered_abilities[ $name ];
+       }
+
+       /**
+        * Utility method to retrieve the main instance of the registry class.
+        *
+        * The instance will be created if it does not exist yet.
+        *
+        * @since 6.9.0
+        *
+        * @return WP_Abilities_Registry|null The main registry instance, or null when `init` action has not fired.
+        */
+       public static function get_instance(): ?self {
+               if ( ! did_action( 'init' ) ) {
+                       _doing_it_wrong(
+                               __METHOD__,
+                               sprintf(
+                                       __( 'Ability API should not be initialized before the <code>init</code> action has fired' )
+                               ),
+                               '6.9.0'
+                       );
+                       return null;
+               }
+
+               if ( null === self::$instance ) {
+                       self::$instance = new self();
+
+                       // Ensure ability category registry is initialized first to allow categories to be registered
+                       // before abilities that depend on them.
+                       WP_Ability_Categories_Registry::get_instance();
+
+                       /**
+                        * Fires when preparing abilities registry.
+                        *
+                        * Abilities should be created and register their hooks on this action rather
+                        * than another action to ensure they're only loaded when needed.
+                        *
+                        * @since 6.9.0
+                        *
+                        * @param WP_Abilities_Registry $instance Abilities registry object.
+                        */
+                       do_action( 'wp_abilities_api_init', self::$instance );
+               }
+
+               return self::$instance;
+       }
+
+       /**
+        * Wakeup magic method.
+        *
+        * @since 6.9.0
+        * @throws LogicException If the registry object is unserialized.
+        *                        This is a security hardening measure to prevent unserialization of the registry.
+        */
+       public function __wakeup(): void {
+               throw new LogicException( __CLASS__ . ' should never be unserialized.' );
+       }
+
+       /**
+        * Sleep magic method.
+        *
+        * @since 6.9.0
+        * @throws LogicException If the registry object is serialized.
+        *                        This is a security hardening measure to prevent serialization of the registry.
+        */
+       public function __sleep(): array {
+               throw new LogicException( __CLASS__ . ' should never be serialized' );
+       }
+}
</ins><span class="cx" style="display: block; padding: 0 10px">Property changes on: trunk/src/wp-includes/abilities-api/class-wp-abilities-registry.php
</span><span class="cx" style="display: block; padding: 0 10px">___________________________________________________________________
</span></span></pre></div>
<a id="svneolstyle"></a>
<div class="addfile"><h4 style="background-color: #eee; color: inherit; margin: 1em 0; padding: 1.3em; font-size: 115%">Added: svn:eol-style</h4></div>
<ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+native
</ins><span class="cx" style="display: block; padding: 0 10px">\ No newline at end of property
</span><a id="trunksrcwpincludesabilitiesapiclasswpabilitycategoriesregistryphp"></a>
<div class="addfile"><h4 style="background-color: #eee; color: inherit; margin: 1em 0; padding: 1.3em; font-size: 115%">Added: trunk/src/wp-includes/abilities-api/class-wp-ability-categories-registry.php</h4>
<pre class="diff"><span>
<span class="info" style="display: block; padding: 0 10px; color: #888">--- trunk/src/wp-includes/abilities-api/class-wp-ability-categories-registry.php                              (rev 0)
+++ trunk/src/wp-includes/abilities-api/class-wp-ability-categories-registry.php        2025-10-21 13:50:11 UTC (rev 61032)
</span><span class="lines" style="display: block; padding: 0 10px; color: #888">@@ -0,0 +1,254 @@
</span><ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+<?php
+/**
+ * Abilities API
+ *
+ * Defines WP_Ability_Categories_Registry class.
+ *
+ * @package WordPress
+ * @subpackage Abilities API
+ * @since 6.9.0
+ */
+
+declare( strict_types = 1 );
+
+/**
+ * Manages the registration and lookup of ability categories.
+ *
+ * @since 6.9.0
+ * @access private
+ */
+final class WP_Ability_Categories_Registry {
+       /**
+        * The singleton instance of the registry.
+        *
+        * @since 6.9.0
+        * @var self|null
+        */
+       private static $instance = null;
+
+       /**
+        * Holds the registered ability categories.
+        *
+        * @since 6.9.0
+        * @var WP_Ability_Category[]
+        */
+       private $registered_categories = array();
+
+       /**
+        * Registers a new ability category.
+        *
+        * Do not use this method directly. Instead, use the `wp_register_ability_category()` function.
+        *
+        * @since 6.9.0
+        *
+        * @see wp_register_ability_category()
+        *
+        * @param string               $slug The unique slug for the ability category. Must contain only lowercase
+        *                                   alphanumeric characters and dashes.
+        * @param array<string, mixed> $args {
+        *     An associative array of arguments for the ability category.
+        *
+        *     @type string               $label       The human-readable label for the ability category.
+        *     @type string               $description A description of the ability category.
+        *     @type array<string, mixed> $meta        Optional. Additional metadata for the ability category.
+        * }
+        * @return WP_Ability_Category|null The registered ability category instance on success, null on failure.
+        */
+       public function register( string $slug, array $args ): ?WP_Ability_Category {
+               if ( $this->is_registered( $slug ) ) {
+                       _doing_it_wrong(
+                               __METHOD__,
+                               /* translators: %s: Ability category slug. */
+                               sprintf( __( 'Ability category "%s" is already registered.' ), esc_html( $slug ) ),
+                               '6.9.0'
+                       );
+                       return null;
+               }
+
+               if ( ! preg_match( '/^[a-z0-9]+(?:-[a-z0-9]+)*$/', $slug ) ) {
+                       _doing_it_wrong(
+                               __METHOD__,
+                               __( 'Ability category slug must contain only lowercase alphanumeric characters and dashes.' ),
+                               '6.9.0'
+                       );
+                       return null;
+               }
+
+               /**
+                * Filters the ability category arguments before they are validated and used to instantiate the ability category.
+                *
+                * @since 6.9.0
+                *
+                * @param array<string, mixed> $args {
+                *     The arguments used to instantiate the ability category.
+                *
+                *     @type string               $label       The human-readable label for the ability category.
+                *     @type string               $description A description of the ability category.
+                *     @type array<string, mixed> $meta        Optional. Additional metadata for the ability category.
+                * }
+                * @param string               $slug The slug of the ability category.
+                */
+               $args = apply_filters( 'wp_register_ability_category_args', $args, $slug );
+
+               try {
+                       // WP_Ability_Category::prepare_properties() will throw an exception if the properties are invalid.
+                       $category = new WP_Ability_Category( $slug, $args );
+               } catch ( InvalidArgumentException $e ) {
+                       _doing_it_wrong(
+                               __METHOD__,
+                               $e->getMessage(),
+                               '6.9.0'
+                       );
+                       return null;
+               }
+
+               $this->registered_categories[ $slug ] = $category;
+               return $category;
+       }
+
+       /**
+        * Unregisters an ability category.
+        *
+        * Do not use this method directly. Instead, use the `wp_unregister_ability_category()` function.
+        *
+        * @since 6.9.0
+        *
+        * @see wp_unregister_ability_category()
+        *
+        * @param string $slug The slug of the registered ability category.
+        * @return WP_Ability_Category|null The unregistered ability category instance on success, null on failure.
+        */
+       public function unregister( string $slug ): ?WP_Ability_Category {
+               if ( ! $this->is_registered( $slug ) ) {
+                       _doing_it_wrong(
+                               __METHOD__,
+                               /* translators: %s: Ability category slug. */
+                               sprintf( __( 'Ability category "%s" not found.' ), esc_html( $slug ) ),
+                               '6.9.0'
+                       );
+                       return null;
+               }
+
+               $unregistered_category = $this->registered_categories[ $slug ];
+               unset( $this->registered_categories[ $slug ] );
+
+               return $unregistered_category;
+       }
+
+       /**
+        * Retrieves the list of all registered ability categories.
+        *
+        * Do not use this method directly. Instead, use the `wp_get_ability_categories()` function.
+        *
+        * @since 6.9.0
+        *
+        * @see wp_get_ability_categories()
+        *
+        * @return array<string, WP_Ability_Category> The array of registered ability categories.
+        */
+       public function get_all_registered(): array {
+               return $this->registered_categories;
+       }
+
+       /**
+        * Checks if an ability category is registered.
+        *
+        * Do not use this method directly. Instead, use the `wp_has_ability_category()` function.
+        *
+        * @since 6.9.0
+        *
+        * @see wp_has_ability_category()
+        *
+        * @param string $slug The slug of the ability category.
+        * @return bool True if the ability category is registered, false otherwise.
+        */
+       public function is_registered( string $slug ): bool {
+               return isset( $this->registered_categories[ $slug ] );
+       }
+
+       /**
+        * Retrieves a registered ability category.
+        *
+        * Do not use this method directly. Instead, use the `wp_get_ability_category()` function.
+        *
+        * @since 6.9.0
+        *
+        * @see wp_get_ability_category()
+        *
+        * @param string $slug The slug of the registered ability category.
+        * @return WP_Ability_Category|null The registered ability category instance, or null if it is not registered.
+        */
+       public function get_registered( string $slug ): ?WP_Ability_Category {
+               if ( ! $this->is_registered( $slug ) ) {
+                       _doing_it_wrong(
+                               __METHOD__,
+                               /* translators: %s: Ability category slug. */
+                               sprintf( __( 'Ability category "%s" not found.' ), esc_html( $slug ) ),
+                               '6.9.0'
+                       );
+                       return null;
+               }
+               return $this->registered_categories[ $slug ];
+       }
+
+       /**
+        * Utility method to retrieve the main instance of the registry class.
+        *
+        * The instance will be created if it does not exist yet.
+        *
+        * @since 6.9.0
+        *
+        * @return WP_Ability_Categories_Registry|null The main registry instance, or null when `init` action has not fired.
+        */
+       public static function get_instance(): ?self {
+               if ( ! did_action( 'init' ) ) {
+                       _doing_it_wrong(
+                               __METHOD__,
+                               sprintf(
+                                       __( 'Ability API should not be initialized before the <code>init</code> action has fired' )
+                               ),
+                               '6.9.0'
+                       );
+                       return null;
+               }
+
+               if ( null === self::$instance ) {
+                       self::$instance = new self();
+
+                       /**
+                        * Fires when preparing ability categories registry.
+                        *
+                        * Ability categories should be registered on this action to ensure they're available when needed.
+                        *
+                        * @since 6.9.0
+                        *
+                        * @param WP_Ability_Categories_Registry $instance Ability categories registry object.
+                        */
+                       do_action( 'wp_abilities_api_categories_init', self::$instance );
+               }
+
+               return self::$instance;
+       }
+
+       /**
+        * Wakeup magic method.
+        *
+        * @since 6.9.0
+        * @throws LogicException If the registry object is unserialized.
+        *                        This is a security hardening measure to prevent unserialization of the registry.
+        */
+       public function __wakeup(): void {
+               throw new LogicException( __CLASS__ . ' should never be unserialized.' );
+       }
+
+       /**
+        * Sleep magic method.
+        *
+        * @since 6.9.0
+        * @throws LogicException If the registry object is serialized.
+        *                        This is a security hardening measure to prevent serialization of the registry.
+        */
+       public function __sleep(): array {
+               throw new LogicException( __CLASS__ . ' should never be serialized' );
+       }
+}
</ins><span class="cx" style="display: block; padding: 0 10px">Property changes on: trunk/src/wp-includes/abilities-api/class-wp-ability-categories-registry.php
</span><span class="cx" style="display: block; padding: 0 10px">___________________________________________________________________
</span></span></pre></div>
<a id="svneolstyle"></a>
<div class="addfile"><h4 style="background-color: #eee; color: inherit; margin: 1em 0; padding: 1.3em; font-size: 115%">Added: svn:eol-style</h4></div>
<ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+native
</ins><span class="cx" style="display: block; padding: 0 10px">\ No newline at end of property
</span><a id="trunksrcwpincludesabilitiesapiclasswpabilitycategoryphp"></a>
<div class="addfile"><h4 style="background-color: #eee; color: inherit; margin: 1em 0; padding: 1.3em; font-size: 115%">Added: trunk/src/wp-includes/abilities-api/class-wp-ability-category.php</h4>
<pre class="diff"><span>
<span class="info" style="display: block; padding: 0 10px; color: #888">--- trunk/src/wp-includes/abilities-api/class-wp-ability-category.php                         (rev 0)
+++ trunk/src/wp-includes/abilities-api/class-wp-ability-category.php   2025-10-21 13:50:11 UTC (rev 61032)
</span><span class="lines" style="display: block; padding: 0 10px; color: #888">@@ -0,0 +1,216 @@
</span><ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+<?php
+/**
+ * Abilities API
+ *
+ * Defines WP_Ability_Category class.
+ *
+ * @package WordPress
+ * @subpackage Abilities API
+ * @since 6.9.0
+ */
+
+declare( strict_types = 1 );
+
+/**
+ * Encapsulates the properties and methods related to a specific ability category.
+ *
+ * @since 6.9.0
+ *
+ * @see WP_Ability_Categories_Registry
+ */
+final class WP_Ability_Category {
+
+       /**
+        * The unique slug for the ability category.
+        *
+        * @since 6.9.0
+        * @var string
+        */
+       protected $slug;
+
+       /**
+        * The human-readable ability category label.
+        *
+        * @since 6.9.0
+        * @var string
+        */
+       protected $label;
+
+       /**
+        * The detailed ability category description.
+        *
+        * @since 6.9.0
+        * @var string
+        */
+       protected $description;
+
+       /**
+        * The optional ability category metadata.
+        *
+        * @since 6.9.0
+        * @var array<string, mixed>
+        */
+       protected $meta = array();
+
+       /**
+        * Constructor.
+        *
+        * Do not use this constructor directly. Instead, use the `wp_register_ability_category()` function.
+        *
+        * @access private
+        *
+        * @since 6.9.0
+        *
+        * @see wp_register_ability_category()
+        *
+        * @param string               $slug The unique slug for the ability category.
+        * @param array<string, mixed> $args {
+        *     An associative array of arguments for the ability category.
+        *
+        *     @type string               $label       The human-readable label for the ability category.
+        *     @type string               $description A description of the ability category.
+        *     @type array<string, mixed> $meta        Optional. Additional metadata for the ability category.
+        * }
+        */
+       public function __construct( string $slug, array $args ) {
+               if ( empty( $slug ) ) {
+                       throw new InvalidArgumentException(
+                               esc_html__( 'The ability category slug cannot be empty.' )
+                       );
+               }
+
+               $this->slug = $slug;
+
+               $properties = $this->prepare_properties( $args );
+
+               foreach ( $properties as $property_name => $property_value ) {
+                       if ( ! property_exists( $this, $property_name ) ) {
+                               _doing_it_wrong(
+                                       __METHOD__,
+                                       sprintf(
+                                               /* translators: %s: Property name. */
+                                               __( 'Property "%1$s" is not a valid property for ability category "%2$s". Please check the %3$s class for allowed properties.' ),
+                                               '<code>' . esc_html( $property_name ) . '</code>',
+                                               '<code>' . esc_html( $this->slug ) . '</code>',
+                                               '<code>' . __CLASS__ . '</code>'
+                                       ),
+                                       '6.9.0'
+                               );
+                               continue;
+                       }
+
+                       $this->$property_name = $property_value;
+               }
+       }
+
+       /**
+        * Prepares and validates the properties used to instantiate the ability category.
+        *
+        * @since 6.9.0
+        *
+        * @param array<string, mixed> $args $args {
+        *     An associative array of arguments used to instantiate the ability category class.
+        *
+        *     @type string               $label       The human-readable label for the ability category.
+        *     @type string               $description A description of the ability category.
+        *     @type array<string, mixed> $meta        Optional. Additional metadata for the ability category.
+        * }
+        * @return array<string, mixed> $args {
+        *     An associative array with validated and prepared ability category properties.
+        *
+        *     @type string               $label       The human-readable label for the ability category.
+        *     @type string               $description A description of the ability category.
+        *     @type array<string, mixed> $meta        Optional. Additional metadata for the ability category.
+        * }
+        * @throws InvalidArgumentException if an argument is invalid.
+        */
+       protected function prepare_properties( array $args ): array {
+               // Required args must be present and of the correct type.
+               if ( empty( $args['label'] ) || ! is_string( $args['label'] ) ) {
+                       throw new InvalidArgumentException(
+                               __( 'The ability category properties must contain a `label` string.' )
+                       );
+               }
+
+               if ( empty( $args['description'] ) || ! is_string( $args['description'] ) ) {
+                       throw new InvalidArgumentException(
+                               __( 'The ability category properties must contain a `description` string.' )
+                       );
+               }
+
+               // Optional args only need to be of the correct type if they are present.
+               if ( isset( $args['meta'] ) && ! is_array( $args['meta'] ) ) {
+                       throw new InvalidArgumentException(
+                               __( 'The ability category properties should provide a valid `meta` array.' )
+                       );
+               }
+
+               return $args;
+       }
+
+       /**
+        * Retrieves the slug of the ability category.
+        *
+        * @since 6.9.0
+        *
+        * @return string The ability category slug.
+        */
+       public function get_slug(): string {
+               return $this->slug;
+       }
+
+       /**
+        * Retrieves the human-readable label for the ability category.
+        *
+        * @since 6.9.0
+        *
+        * @return string The human-readable ability category label.
+        */
+       public function get_label(): string {
+               return $this->label;
+       }
+
+       /**
+        * Retrieves the detailed description for the ability category.
+        *
+        * @since 6.9.0
+        *
+        * @return string The detailed description for the ability category.
+        */
+       public function get_description(): string {
+               return $this->description;
+       }
+
+       /**
+        * Retrieves the metadata for the ability category.
+        *
+        * @since 6.9.0
+        *
+        * @return array<string,mixed> The metadata for the ability category.
+        */
+       public function get_meta(): array {
+               return $this->meta;
+       }
+
+       /**
+        * Wakeup magic method.
+        *
+        * @since 6.9.0
+        * @throws LogicException If the ability category object is unserialized.
+        *                        This is a security hardening measure to prevent unserialization of the ability category.
+        */
+       public function __wakeup(): void {
+               throw new LogicException( __CLASS__ . ' should never be unserialized.' );
+       }
+
+       /**
+        * Sleep magic method.
+        *
+        * @since 6.9.0
+        * @throws LogicException If the ability category object is serialized.
+        *                        This is a security hardening measure to prevent serialization of the ability category.
+        */
+       public function __sleep(): array {
+               throw new LogicException( __CLASS__ . ' should never be serialized' );
+       }
+}
</ins><span class="cx" style="display: block; padding: 0 10px">Property changes on: trunk/src/wp-includes/abilities-api/class-wp-ability-category.php
</span><span class="cx" style="display: block; padding: 0 10px">___________________________________________________________________
</span></span></pre></div>
<a id="svneolstyle"></a>
<div class="addfile"><h4 style="background-color: #eee; color: inherit; margin: 1em 0; padding: 1.3em; font-size: 115%">Added: svn:eol-style</h4></div>
<ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+native
</ins><span class="cx" style="display: block; padding: 0 10px">\ No newline at end of property
</span><a id="trunksrcwpincludesabilitiesapiclasswpabilityphp"></a>
<div class="addfile"><h4 style="background-color: #eee; color: inherit; margin: 1em 0; padding: 1.3em; font-size: 115%">Added: trunk/src/wp-includes/abilities-api/class-wp-ability.php</h4>
<pre class="diff"><span>
<span class="info" style="display: block; padding: 0 10px; color: #888">--- trunk/src/wp-includes/abilities-api/class-wp-ability.php                          (rev 0)
+++ trunk/src/wp-includes/abilities-api/class-wp-ability.php    2025-10-21 13:50:11 UTC (rev 61032)
</span><span class="lines" style="display: block; padding: 0 10px; color: #888">@@ -0,0 +1,617 @@
</span><ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+<?php
+/**
+ * Abilities API
+ *
+ * Defines WP_Ability class.
+ *
+ * @package WordPress
+ * @subpackage Abilities API
+ * @since 6.9.0
+ */
+
+declare( strict_types = 1 );
+
+/**
+ * Encapsulates the properties and methods related to a specific ability in the registry.
+ *
+ * @since 6.9.0
+ *
+ * @see WP_Abilities_Registry
+ */
+class WP_Ability {
+
+       /**
+        * The default value for the `show_in_rest` meta.
+        *
+        * @since 6.9.0
+        * @var bool
+        */
+       protected const DEFAULT_SHOW_IN_REST = false;
+
+       /**
+        * The default ability annotations.
+        * They are not guaranteed to provide a faithful description of ability behavior.
+        *
+        * @since 6.9.0
+        * @var array<string, (null|bool)>
+        */
+       protected static $default_annotations = array(
+               // If true, the ability does not modify its environment.
+               'readonly'    => null,
+               /*
+                * If true, the ability may perform destructive updates to its environment.
+                * If false, the ability performs only additive updates.
+                */
+               'destructive' => null,
+               /*
+                * If true, calling the ability repeatedly with the same arguments will have no additional effect
+                * on its environment.
+                */
+               'idempotent'  => null,
+       );
+
+       /**
+        * The name of the ability, with its namespace.
+        * Example: `my-plugin/my-ability`.
+        *
+        * @since 6.9.0
+        * @var string
+        */
+       protected $name;
+
+       /**
+        * The human-readable ability label.
+        *
+        * @since 6.9.0
+        * @var string
+        */
+       protected $label;
+
+       /**
+        * The detailed ability description.
+        *
+        * @since 6.9.0
+        * @var string
+        */
+       protected $description;
+
+       /**
+        * The ability category.
+        *
+        * @since 6.9.0
+        * @var string
+        */
+       protected $category;
+
+       /**
+        * The optional ability input schema.
+        *
+        * @since 6.9.0
+        * @var array<string, mixed>
+        */
+       protected $input_schema = array();
+
+       /**
+        * The optional ability output schema.
+        *
+        * @since 6.9.0
+        * @var array<string, mixed>
+        */
+       protected $output_schema = array();
+
+       /**
+        * The ability execute callback.
+        *
+        * @since 6.9.0
+        * @var callable( mixed $input= ): (mixed|WP_Error)
+        */
+       protected $execute_callback;
+
+       /**
+        * The optional ability permission callback.
+        *
+        * @since 6.9.0
+        * @var callable( mixed $input= ): (bool|WP_Error)
+        */
+       protected $permission_callback;
+
+       /**
+        * The optional ability metadata.
+        *
+        * @since 6.9.0
+        * @var array<string, mixed>
+        */
+       protected $meta;
+
+       /**
+        * Constructor.
+        *
+        * Do not use this constructor directly. Instead, use the `wp_register_ability()` function.
+        *
+        * @access private
+        *
+        * @since 6.9.0
+        *
+        * @see wp_register_ability()
+        *
+        * @param string               $name The name of the ability, with its namespace.
+        * @param array<string, mixed> $args {
+        *     An associative array of arguments for the ability.
+        *
+        *     @type string               $label                 The human-readable label for the ability.
+        *     @type string               $description           A detailed description of what the ability does.
+        *     @type string               $category              The ability category slug this ability belongs to.
+        *     @type callable             $execute_callback      A callback function to execute when the ability is invoked.
+        *                                                       Receives optional mixed input and returns mixed result or WP_Error.
+        *     @type callable             $permission_callback   A callback function to check permissions before execution.
+        *                                                       Receives optional mixed input and returns bool or WP_Error.
+        *     @type array<string, mixed> $input_schema          Optional. JSON Schema definition for the ability's input.
+        *     @type array<string, mixed> $output_schema         Optional. JSON Schema definition for the ability's output.
+        *     @type array<string, mixed> $meta                  {
+        *         Optional. Additional metadata for the ability.
+        *
+        *         @type array<string, null|bool> $annotations  Optional. Annotation metadata for the ability.
+        *         @type bool                     $show_in_rest Optional. Whether to expose this ability in the REST API. Default false.
+        *     }
+        * }
+        */
+       public function __construct( string $name, array $args ) {
+               $this->name = $name;
+
+               $properties = $this->prepare_properties( $args );
+
+               foreach ( $properties as $property_name => $property_value ) {
+                       if ( ! property_exists( $this, $property_name ) ) {
+                               _doing_it_wrong(
+                                       __METHOD__,
+                                       sprintf(
+                                               /* translators: %s: Property name. */
+                                               __( 'Property "%1$s" is not a valid property for ability "%2$s". Please check the %3$s class for allowed properties.' ),
+                                               '<code>' . esc_html( $property_name ) . '</code>',
+                                               '<code>' . esc_html( $this->name ) . '</code>',
+                                               '<code>' . self::class . '</code>'
+                                       ),
+                                       '6.9.0'
+                               );
+                               continue;
+                       }
+
+                       $this->$property_name = $property_value;
+               }
+       }
+
+       /**
+        * Prepares and validates the properties used to instantiate the ability.
+        *
+        * Errors are thrown as exceptions instead of WP_Errors to allow for simpler handling and overloading. They are then
+        * caught and converted to a WP_Error when by WP_Abilities_Registry::register().
+        *
+        * @since 6.9.0
+        *
+        * @see WP_Abilities_Registry::register()
+        *
+        * @param array<string, mixed> $args {
+        *     An associative array of arguments used to instantiate the ability class.
+        *
+        *     @type string               $label                 The human-readable label for the ability.
+        *     @type string               $description           A detailed description of what the ability does.
+        *     @type string               $category              The ability category slug this ability belongs to.
+        *     @type callable             $execute_callback      A callback function to execute when the ability is invoked.
+        *                                                       Receives optional mixed input and returns mixed result or WP_Error.
+        *     @type callable             $permission_callback   A callback function to check permissions before execution.
+        *                                                       Receives optional mixed input and returns bool or WP_Error.
+        *     @type array<string, mixed> $input_schema          Optional. JSON Schema definition for the ability's input. Required if ability accepts an input.
+        *     @type array<string, mixed> $output_schema         Optional. JSON Schema definition for the ability's output.
+        *     @type array<string, mixed> $meta                  {
+        *         Optional. Additional metadata for the ability.
+        *
+        *         @type array<string, null|bool> $annotations  Optional. Annotation metadata for the ability.
+        *         @type bool                     $show_in_rest Optional. Whether to expose this ability in the REST API. Default false.
+        *     }
+        * }
+        * @return array<string, mixed> {
+        *     An associative array of arguments with validated and prepared properties for the ability class.
+        *
+        *     @type string               $label                 The human-readable label for the ability.
+        *     @type string               $description           A detailed description of what the ability does.
+        *     @type string               $category              The ability category slug this ability belongs to.
+        *     @type callable             $execute_callback      A callback function to execute when the ability is invoked.
+        *                                                       Receives optional mixed input and returns mixed result or WP_Error.
+        *     @type callable             $permission_callback   A callback function to check permissions before execution.
+        *                                                       Receives optional mixed input and returns bool or WP_Error.
+        *     @type array<string, mixed> $input_schema          Optional. JSON Schema definition for the ability's input.
+        *     @type array<string, mixed> $output_schema         Optional. JSON Schema definition for the ability's output.
+        *     @type array<string, mixed> $meta                  {
+        *         Additional metadata for the ability.
+        *
+        *         @type array<string, null|bool> $annotations  Optional. Annotation metadata for the ability.
+        *         @type bool                     $show_in_rest Whether to expose this ability in the REST API. Default false.
+        *     }
+        * }
+        * @throws InvalidArgumentException if an argument is invalid.
+        */
+       protected function prepare_properties( array $args ): array {
+               // Required args must be present and of the correct type.
+               if ( empty( $args['label'] ) || ! is_string( $args['label'] ) ) {
+                       throw new InvalidArgumentException(
+                               __( 'The ability properties must contain a `label` string.' )
+                       );
+               }
+
+               if ( empty( $args['description'] ) || ! is_string( $args['description'] ) ) {
+                       throw new InvalidArgumentException(
+                               __( 'The ability properties must contain a `description` string.' )
+                       );
+               }
+
+               if ( empty( $args['category'] ) || ! is_string( $args['category'] ) ) {
+                       throw new InvalidArgumentException(
+                               __( 'The ability properties must contain a `category` string.' )
+                       );
+               }
+
+               if ( empty( $args['execute_callback'] ) || ! is_callable( $args['execute_callback'] ) ) {
+                       throw new InvalidArgumentException(
+                               __( 'The ability properties must contain a valid `execute_callback` function.' )
+                       );
+               }
+
+               if ( empty( $args['permission_callback'] ) || ! is_callable( $args['permission_callback'] ) ) {
+                       throw new InvalidArgumentException(
+                               __( 'The ability properties must provide a valid `permission_callback` function.' )
+                       );
+               }
+
+               // Optional args only need to be of the correct type if they are present.
+               if ( isset( $args['input_schema'] ) && ! is_array( $args['input_schema'] ) ) {
+                       throw new InvalidArgumentException(
+                               __( 'The ability properties should provide a valid `input_schema` definition.' )
+                       );
+               }
+
+               if ( isset( $args['output_schema'] ) && ! is_array( $args['output_schema'] ) ) {
+                       throw new InvalidArgumentException(
+                               __( 'The ability properties should provide a valid `output_schema` definition.' )
+                       );
+               }
+
+               if ( isset( $args['meta'] ) && ! is_array( $args['meta'] ) ) {
+                       throw new InvalidArgumentException(
+                               __( 'The ability properties should provide a valid `meta` array.' )
+                       );
+               }
+
+               if ( isset( $args['meta']['annotations'] ) && ! is_array( $args['meta']['annotations'] ) ) {
+                       throw new InvalidArgumentException(
+                               __( 'The ability meta should provide a valid `annotations` array.' )
+                       );
+               }
+
+               if ( isset( $args['meta']['show_in_rest'] ) && ! is_bool( $args['meta']['show_in_rest'] ) ) {
+                       throw new InvalidArgumentException(
+                               __( 'The ability meta should provide a valid `show_in_rest` boolean.' )
+                       );
+               }
+
+               // Set defaults for optional meta.
+               $args['meta']                = wp_parse_args(
+                       $args['meta'] ?? array(),
+                       array(
+                               'annotations'  => static::$default_annotations,
+                               'show_in_rest' => self::DEFAULT_SHOW_IN_REST,
+                       )
+               );
+               $args['meta']['annotations'] = wp_parse_args(
+                       $args['meta']['annotations'],
+                       static::$default_annotations
+               );
+
+               return $args;
+       }
+
+       /**
+        * Retrieves the name of the ability, with its namespace.
+        * Example: `my-plugin/my-ability`.
+        *
+        * @since 6.9.0
+        *
+        * @return string The ability name, with its namespace.
+        */
+       public function get_name(): string {
+               return $this->name;
+       }
+
+       /**
+        * Retrieves the human-readable label for the ability.
+        *
+        * @since 6.9.0
+        *
+        * @return string The human-readable ability label.
+        */
+       public function get_label(): string {
+               return $this->label;
+       }
+
+       /**
+        * Retrieves the detailed description for the ability.
+        *
+        * @since 6.9.0
+        *
+        * @return string The detailed description for the ability.
+        */
+       public function get_description(): string {
+               return $this->description;
+       }
+
+       /**
+        * Retrieves the ability category for the ability.
+        *
+        * @since 6.9.0
+        *
+        * @return string The ability category for the ability.
+        */
+       public function get_category(): string {
+               return $this->category;
+       }
+
+       /**
+        * Retrieves the input schema for the ability.
+        *
+        * @since 6.9.0
+        *
+        * @return array<string, mixed> The input schema for the ability.
+        */
+       public function get_input_schema(): array {
+               return $this->input_schema;
+       }
+
+       /**
+        * Retrieves the output schema for the ability.
+        *
+        * @since 6.9.0
+        *
+        * @return array<string, mixed> The output schema for the ability.
+        */
+       public function get_output_schema(): array {
+               return $this->output_schema;
+       }
+
+       /**
+        * Retrieves the metadata for the ability.
+        *
+        * @since 6.9.0
+        *
+        * @return array<string, mixed> The metadata for the ability.
+        */
+       public function get_meta(): array {
+               return $this->meta;
+       }
+
+       /**
+        * Retrieves a specific metadata item for the ability.
+        *
+        * @since 6.9.0
+        *
+        * @param string $key           The metadata key to retrieve.
+        * @param mixed  $default_value Optional. The default value to return if the metadata item is not found. Default `null`.
+        * @return mixed The value of the metadata item, or the default value if not found.
+        */
+       public function get_meta_item( string $key, $default_value = null ) {
+               return array_key_exists( $key, $this->meta ) ? $this->meta[ $key ] : $default_value;
+       }
+
+       /**
+        * Validates input data against the input schema.
+        *
+        * @since 6.9.0
+        *
+        * @param mixed $input Optional. The input data to validate. Default `null`.
+        * @return true|WP_Error Returns true if valid or the WP_Error object if validation fails.
+        */
+       public function validate_input( $input = null ) {
+               $input_schema = $this->get_input_schema();
+               if ( empty( $input_schema ) ) {
+                       if ( null === $input ) {
+                               return true;
+                       }
+
+                       return new WP_Error(
+                               'ability_missing_input_schema',
+                               sprintf(
+                                       /* translators: %s ability name. */
+                                       __( 'Ability "%s" does not define an input schema required to validate the provided input.' ),
+                                       $this->name
+                               )
+                       );
+               }
+
+               $valid_input = rest_validate_value_from_schema( $input, $input_schema, 'input' );
+               if ( is_wp_error( $valid_input ) ) {
+                       return new WP_Error(
+                               'ability_invalid_input',
+                               sprintf(
+                                       /* translators: %1$s ability name, %2$s error message. */
+                                       __( 'Ability "%1$s" has invalid input. Reason: %2$s' ),
+                                       $this->name,
+                                       $valid_input->get_error_message()
+                               )
+                       );
+               }
+
+               return true;
+       }
+
+       /**
+        * Invokes a callable, ensuring the input is passed through only if the input schema is defined.
+        *
+        * @since 6.9.0
+        *
+        * @param callable $callback The callable to invoke.
+        * @param mixed    $input    Optional. The input data for the ability. Default `null`.
+        * @return mixed The result of the callable execution.
+        */
+       protected function invoke_callback( callable $callback, $input = null ) {
+               $args = array();
+               if ( ! empty( $this->get_input_schema() ) ) {
+                       $args[] = $input;
+               }
+
+               return $callback( ...$args );
+       }
+
+       /**
+        * Checks whether the ability has the necessary permissions.
+        *
+        * Please note that input is not automatically validated against the input schema.
+        * Use `validate_input()` method to validate input before calling this method if needed.
+        *
+        * @since 6.9.0
+        *
+        * @see validate_input()
+        *
+        * @param mixed $input Optional. The valid input data for permission checking. Default `null`.
+        * @return bool|WP_Error Whether the ability has the necessary permission.
+        */
+       public function check_permissions( $input = null ) {
+               return $this->invoke_callback( $this->permission_callback, $input );
+       }
+
+       /**
+        * Executes the ability callback.
+        *
+        * @since 6.9.0
+        *
+        * @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.
+        */
+       protected function do_execute( $input = null ) {
+               if ( ! is_callable( $this->execute_callback ) ) {
+                       return new WP_Error(
+                               'ability_invalid_execute_callback',
+                               /* translators: %s ability name. */
+                               sprintf( __( 'Ability "%s" does not have a valid execute callback.' ), $this->name )
+                       );
+               }
+
+               return $this->invoke_callback( $this->execute_callback, $input );
+       }
+
+       /**
+        * Validates output data against the output schema.
+        *
+        * @since 6.9.0
+        *
+        * @param mixed $output The output data to validate.
+        * @return true|WP_Error Returns true if valid, or a WP_Error object if validation fails.
+        */
+       protected function validate_output( $output ) {
+               $output_schema = $this->get_output_schema();
+               if ( empty( $output_schema ) ) {
+                       return true;
+               }
+
+               $valid_output = rest_validate_value_from_schema( $output, $output_schema, 'output' );
+               if ( is_wp_error( $valid_output ) ) {
+                       return new WP_Error(
+                               'ability_invalid_output',
+                               sprintf(
+                                       /* translators: %1$s ability name, %2$s error message. */
+                                       __( 'Ability "%1$s" has invalid output. Reason: %2$s' ),
+                                       $this->name,
+                                       $valid_output->get_error_message()
+                               )
+                       );
+               }
+
+               return true;
+       }
+
+       /**
+        * Executes the ability after input validation and running a permission check.
+        * Before returning the return value, it also validates the output.
+        *
+        * @since 6.9.0
+        *
+        * @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.
+        */
+       public function execute( $input = null ) {
+               $is_valid = $this->validate_input( $input );
+               if ( is_wp_error( $is_valid ) ) {
+                       return $is_valid;
+               }
+
+               $has_permissions = $this->check_permissions( $input );
+               if ( true !== $has_permissions ) {
+                       if ( is_wp_error( $has_permissions ) ) {
+                               // Don't leak the permission check error to someone without the correct perms.
+                               _doing_it_wrong(
+                                       __METHOD__,
+                                       esc_html( $has_permissions->get_error_message() ),
+                                       '6.9.0'
+                               );
+                       }
+
+                       return new WP_Error(
+                               'ability_invalid_permissions',
+                               /* translators: %s ability name. */
+                               sprintf( __( 'Ability "%s" does not have necessary permission.' ), $this->name )
+                       );
+               }
+
+               /**
+                * Fires before an ability gets executed, after input validation and permissions check.
+                *
+                * @since 6.9.0
+                *
+                * @param string $ability_name The name of the ability.
+                * @param mixed  $input        The input data for the ability.
+                */
+               do_action( 'wp_before_execute_ability', $this->name, $input );
+
+               $result = $this->do_execute( $input );
+               if ( is_wp_error( $result ) ) {
+                       return $result;
+               }
+
+               $is_valid = $this->validate_output( $result );
+               if ( is_wp_error( $is_valid ) ) {
+                       return $is_valid;
+               }
+
+               /**
+                * Fires immediately after an ability finished executing.
+                *
+                * @since 6.9.0
+                *
+                * @param string $ability_name The name of the ability.
+                * @param mixed  $input        The input data for the ability.
+                * @param mixed  $result       The result of the ability execution.
+                */
+               do_action( 'wp_after_execute_ability', $this->name, $input, $result );
+
+               return $result;
+       }
+
+       /**
+        * Wakeup magic method.
+        *
+        * @since 6.9.0
+        * @throws LogicException If the ability object is unserialized.
+        *                        This is a security hardening measure to prevent unserialization of the ability.
+        */
+       public function __wakeup(): void {
+               throw new LogicException( __CLASS__ . ' should never be unserialized.' );
+       }
+
+       /**
+        * Sleep magic method.
+        *
+        * @since 6.9.0
+        * @throws LogicException If the ability object is serialized.
+        *                        This is a security hardening measure to prevent serialization of the ability.
+        */
+       public function __sleep(): array {
+               throw new LogicException( __CLASS__ . ' should never be serialized' );
+       }
+}
</ins><span class="cx" style="display: block; padding: 0 10px">Property changes on: trunk/src/wp-includes/abilities-api/class-wp-ability.php
</span><span class="cx" style="display: block; padding: 0 10px">___________________________________________________________________
</span></span></pre></div>
<a id="svneolstyle"></a>
<div class="addfile"><h4 style="background-color: #eee; color: inherit; margin: 1em 0; padding: 1.3em; font-size: 115%">Added: svn:eol-style</h4></div>
<ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+native
</ins><span class="cx" style="display: block; padding: 0 10px">\ No newline at end of property
</span><a id="trunksrcwpincludesabilitiesapiphp"></a>
<div class="addfile"><h4 style="background-color: #eee; color: inherit; margin: 1em 0; padding: 1.3em; font-size: 115%">Added: trunk/src/wp-includes/abilities-api.php</h4>
<pre class="diff"><span>
<span class="info" style="display: block; padding: 0 10px; color: #888">--- trunk/src/wp-includes/abilities-api.php                           (rev 0)
+++ trunk/src/wp-includes/abilities-api.php     2025-10-21 13:50:11 UTC (rev 61032)
</span><span class="lines" style="display: block; padding: 0 10px; color: #888">@@ -0,0 +1,260 @@
</span><ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+<?php
+/**
+ * Abilities API
+ *
+ * Defines functions for managing abilities in WordPress.
+ *
+ * @package WordPress
+ * @subpackage Abilities_API
+ * @since 6.9.0
+ */
+
+declare( strict_types = 1 );
+
+/**
+ * Registers a new ability using Abilities API.
+ *
+ * Note: Should only be used on the {@see 'wp_abilities_api_init'} hook.
+ *
+ * @since 6.9.0
+ *
+ * @see WP_Abilities_Registry::register()
+ *
+ * @param string               $name The name of the ability. The name must be a string containing a namespace
+ *                                   prefix, i.e. `my-plugin/my-ability`. It can only contain lowercase
+ *                                   alphanumeric characters, dashes and the forward slash.
+ * @param array<string, mixed> $args {
+ *     An associative array of arguments for the ability.
+ *
+ *     @type string               $label               The human-readable label for the ability.
+ *     @type string               $description         A detailed description of what the ability does.
+ *     @type string               $category            The ability category slug this ability belongs to.
+ *     @type callable             $execute_callback    A callback function to execute when the ability is invoked.
+ *                                                     Receives optional mixed input and returns mixed result or WP_Error.
+ *     @type callable             $permission_callback A callback function to check permissions before execution.
+ *                                                     Receives optional mixed input and returns bool or WP_Error.
+ *     @type array<string, mixed> $input_schema        Optional. JSON Schema definition for the ability's input.
+ *     @type array<string, mixed> $output_schema       Optional. JSON Schema definition for the ability's output.
+ *     @type array<string, mixed> $meta                  {
+ *         Optional. Additional metadata for the ability.
+ *
+ *         @type array<string, null|bool> $annotations  Optional. Annotation metadata for the ability.
+ *         @type bool                     $show_in_rest Optional. Whether to expose this ability in the REST API. Default false.
+ *     }
+ *     @type string               $ability_class       Optional. Custom class to instantiate instead of WP_Ability.
+ * }
+ * @return WP_Ability|null An instance of registered ability on success, null on failure.
+ */
+function wp_register_ability( string $name, array $args ): ?WP_Ability {
+       if ( ! did_action( 'wp_abilities_api_init' ) ) {
+               _doing_it_wrong(
+                       __FUNCTION__,
+                       sprintf(
+                               /* translators: 1: abilities_api_init, 2: string value of the ability name. */
+                               esc_html__( 'Abilities must be registered on the %1$s action. The ability %2$s was not registered.' ),
+                               '<code>abilities_api_init</code>',
+                               '<code>' . esc_html( $name ) . '</code>'
+                       ),
+                       '6.9.0'
+               );
+               return null;
+       }
+
+       $registry = WP_Abilities_Registry::get_instance();
+       if ( null === $registry ) {
+               return null;
+       }
+
+       return $registry->register( $name, $args );
+}
+
+/**
+ * Unregisters an ability from the Abilities API.
+ *
+ * @since 6.9.0
+ *
+ * @see WP_Abilities_Registry::unregister()
+ *
+ * @param string $name The name of the registered ability, with its namespace.
+ * @return WP_Ability|null The unregistered ability instance on success, null on failure.
+ */
+function wp_unregister_ability( string $name ): ?WP_Ability {
+       $registry = WP_Abilities_Registry::get_instance();
+       if ( null === $registry ) {
+               return null;
+       }
+
+       return $registry->unregister( $name );
+}
+
+/**
+ * Checks if an ability is registered.
+ *
+ * @since 6.9.0
+ *
+ * @see WP_Abilities_Registry::is_registered()
+ *
+ * @param string $name The name of the registered ability, with its namespace.
+ * @return bool True if the ability is registered, false otherwise.
+ */
+function wp_has_ability( string $name ): bool {
+       $registry = WP_Abilities_Registry::get_instance();
+       if ( null === $registry ) {
+               return false;
+       }
+
+       return $registry->is_registered( $name );
+}
+
+/**
+ * Retrieves a registered ability using Abilities API.
+ *
+ * @since 6.9.0
+ *
+ * @see WP_Abilities_Registry::get_registered()
+ *
+ * @param string $name The name of the registered ability, with its namespace.
+ * @return WP_Ability|null The registered ability instance, or null if it is not registered.
+ */
+function wp_get_ability( string $name ): ?WP_Ability {
+       $registry = WP_Abilities_Registry::get_instance();
+       if ( null === $registry ) {
+               return null;
+       }
+
+       return $registry->get_registered( $name );
+}
+
+/**
+ * Retrieves all registered abilities using Abilities API.
+ *
+ * @since 6.9.0
+ *
+ * @see WP_Abilities_Registry::get_all_registered()
+ *
+ * @return WP_Ability[] The array of registered abilities.
+ */
+function wp_get_abilities(): array {
+       $registry = WP_Abilities_Registry::get_instance();
+       if ( null === $registry ) {
+               return array();
+       }
+
+       return $registry->get_all_registered();
+}
+
+/**
+ * Registers a new ability category.
+ *
+ * @since 6.9.0
+ *
+ * @see WP_Ability_Categories_Registry::register()
+ *
+ * @param string               $slug The unique slug for the ability category. Must contain only lowercase
+ *                                   alphanumeric characters and dashes.
+ * @param array<string, mixed> $args {
+ *     An associative array of arguments for the ability category.
+ *
+ *     @type string               $label       The human-readable label for the ability category.
+ *     @type string               $description A description of the ability category.
+ *     @type array<string, mixed> $meta        Optional. Additional metadata for the ability category.
+ * }
+ * @return WP_Ability_Category|null The registered ability category instance on success, null on failure.
+ */
+function wp_register_ability_category( string $slug, array $args ): ?WP_Ability_Category {
+       if ( ! did_action( 'wp_abilities_api_categories_init' ) ) {
+               _doing_it_wrong(
+                       __METHOD__,
+                       sprintf(
+                               /* translators: 1: abilities_api_categories_init, 2: ability category slug. */
+                               __( 'Ability categories must be registered on the %1$s action. The ability category %2$s was not registered.' ),
+                               '<code>wp_abilities_api_categories_init</code>',
+                               '<code>' . esc_html( $slug ) . '</code>'
+                       ),
+                       '6.9.0'
+               );
+               return null;
+       }
+
+       $registry = WP_Ability_Categories_Registry::get_instance();
+       if ( null === $registry ) {
+               return null;
+       }
+
+       return $registry->register( $slug, $args );
+}
+
+/**
+ * Unregisters an ability category.
+ *
+ * @since 6.9.0
+ *
+ * @see WP_Ability_Categories_Registry::unregister()
+ *
+ * @param string $slug The slug of the registered ability category.
+ * @return WP_Ability_Category|null The unregistered ability category instance on success, null on failure.
+ */
+function wp_unregister_ability_category( string $slug ): ?WP_Ability_Category {
+       $registry = WP_Ability_Categories_Registry::get_instance();
+       if ( null === $registry ) {
+               return null;
+       }
+
+       return $registry->unregister( $slug );
+}
+
+/**
+ * Checks if an ability category is registered.
+ *
+ * @since 6.9.0
+ *
+ * @see WP_Ability_Categories_Registry::is_registered()
+ *
+ * @param string $slug The slug of the ability category.
+ * @return bool True if the ability category is registered, false otherwise.
+ */
+function wp_has_ability_category( string $slug ): bool {
+       $registry = WP_Ability_Categories_Registry::get_instance();
+       if ( null === $registry ) {
+               return false;
+       }
+
+       return $registry->is_registered( $slug );
+}
+
+/**
+ * Retrieves a registered ability category.
+ *
+ * @since 6.9.0
+ *
+ * @see WP_Ability_Categories_Registry::get_registered()
+ *
+ * @param string $slug The slug of the registered ability category.
+ * @return WP_Ability_Category|null The registered ability category instance, or null if it is not registered.
+ */
+function wp_get_ability_category( string $slug ): ?WP_Ability_Category {
+       $registry = WP_Ability_Categories_Registry::get_instance();
+       if ( null === $registry ) {
+               return null;
+       }
+
+       return $registry->get_registered( $slug );
+}
+
+/**
+ * Retrieves all registered ability categories.
+ *
+ * @since 6.9.0
+ *
+ * @see WP_Ability_Categories_Registry::get_all_registered()
+ *
+ * @return WP_Ability_Category[] The array of registered ability categories.
+ */
+function wp_get_ability_categories(): array {
+       $registry = WP_Ability_Categories_Registry::get_instance();
+       if ( null === $registry ) {
+               return array();
+       }
+
+       return $registry->get_all_registered();
+}
</ins><span class="cx" style="display: block; padding: 0 10px">Property changes on: trunk/src/wp-includes/abilities-api.php
</span><span class="cx" style="display: block; padding: 0 10px">___________________________________________________________________
</span></span></pre></div>
<a id="svneolstyle"></a>
<div class="addfile"><h4 style="background-color: #eee; color: inherit; margin: 1em 0; padding: 1.3em; font-size: 115%">Added: svn:eol-style</h4></div>
<ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+native
</ins><span class="cx" style="display: block; padding: 0 10px">\ No newline at end of property
</span><a id="trunksrcwpincludesrestapiendpointsclasswprestabilitiesv1listcontrollerphp"></a>
<div class="addfile"><h4 style="background-color: #eee; color: inherit; margin: 1em 0; padding: 1.3em; font-size: 115%">Added: trunk/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-list-controller.php</h4>
<pre class="diff"><span>
<span class="info" style="display: block; padding: 0 10px; color: #888">--- trunk/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-list-controller.php                           (rev 0)
+++ trunk/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-list-controller.php     2025-10-21 13:50:11 UTC (rev 61032)
</span><span class="lines" style="display: block; padding: 0 10px; color: #888">@@ -0,0 +1,343 @@
</span><ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+<?php
+/**
+ * REST API list controller for Abilities API.
+ *
+ * @package WordPress
+ * @subpackage Abilities_API
+ * @since 6.9.0
+ */
+
+declare( strict_types = 1 );
+
+/**
+ * Core controller used to access abilities via the REST API.
+ *
+ * @since 6.9.0
+ *
+ * @see WP_REST_Controller
+ */
+class WP_REST_Abilities_V1_List_Controller extends WP_REST_Controller {
+
+       /**
+        * REST API namespace.
+        *
+        * @since 6.9.0
+        * @var string
+        */
+       protected $namespace = 'wp-abilities/v1';
+
+       /**
+        * REST API base route.
+        *
+        * @since 6.9.0
+        * @var string
+        */
+       protected $rest_base = 'abilities';
+
+       /**
+        * Registers the routes for abilities.
+        *
+        * @since 6.9.0
+        *
+        * @see register_rest_route()
+        */
+       public function register_routes(): void {
+               register_rest_route(
+                       $this->namespace,
+                       '/' . $this->rest_base,
+                       array(
+                               array(
+                                       'methods'             => WP_REST_Server::READABLE,
+                                       'callback'            => array( $this, 'get_items' ),
+                                       'permission_callback' => array( $this, 'get_items_permissions_check' ),
+                                       'args'                => $this->get_collection_params(),
+                               ),
+                               'schema' => array( $this, 'get_public_item_schema' ),
+                       )
+               );
+
+               register_rest_route(
+                       $this->namespace,
+                       '/' . $this->rest_base . '/(?P<name>[a-zA-Z0-9\-\/]+)',
+                       array(
+                               'args'   => array(
+                                       'name' => array(
+                                               'description' => __( 'Unique identifier for the ability.' ),
+                                               'type'        => 'string',
+                                               'pattern'     => '^[a-zA-Z0-9\-\/]+$',
+                                       ),
+                               ),
+                               array(
+                                       'methods'             => WP_REST_Server::READABLE,
+                                       'callback'            => array( $this, 'get_item' ),
+                                       'permission_callback' => array( $this, 'get_item_permissions_check' ),
+                               ),
+                               'schema' => array( $this, 'get_public_item_schema' ),
+                       )
+               );
+       }
+
+       /**
+        * Retrieves all abilities.
+        *
+        * @since 6.9.0
+        *
+        * @param WP_REST_Request $request Full details about the request.
+        * @return WP_REST_Response Response object on success.
+        */
+       public function get_items( $request ) {
+               $abilities = array_filter(
+                       wp_get_abilities(),
+                       static function ( $ability ) {
+                               return $ability->get_meta_item( 'show_in_rest' );
+                       }
+               );
+
+               // Filter by ability category if specified.
+               $category = $request['category'];
+               if ( ! empty( $category ) ) {
+                       $abilities = array_filter(
+                               $abilities,
+                               static function ( $ability ) use ( $category ) {
+                                       return $ability->get_category() === $category;
+                               }
+                       );
+                       // Reset array keys after filtering.
+                       $abilities = array_values( $abilities );
+               }
+
+               $page     = $request['page'];
+               $per_page = $request['per_page'];
+               $offset   = ( $page - 1 ) * $per_page;
+
+               $total_abilities = count( $abilities );
+               $max_pages       = ceil( $total_abilities / $per_page );
+
+               if ( $request->get_method() === 'HEAD' ) {
+                       $response = new WP_REST_Response( array() );
+               } else {
+                       $abilities = array_slice( $abilities, $offset, $per_page );
+
+                       $data = array();
+                       foreach ( $abilities as $ability ) {
+                               $item   = $this->prepare_item_for_response( $ability, $request );
+                               $data[] = $this->prepare_response_for_collection( $item );
+                       }
+
+                       $response = rest_ensure_response( $data );
+               }
+
+               $response->header( 'X-WP-Total', (string) $total_abilities );
+               $response->header( 'X-WP-TotalPages', (string) $max_pages );
+
+               $query_params = $request->get_query_params();
+               $base         = add_query_arg( urlencode_deep( $query_params ), rest_url( sprintf( '%s/%s', $this->namespace, $this->rest_base ) ) );
+
+               if ( $page > 1 ) {
+                       $prev_page = $page - 1;
+                       $prev_link = add_query_arg( 'page', $prev_page, $base );
+                       $response->link_header( 'prev', $prev_link );
+               }
+
+               if ( $page < $max_pages ) {
+                       $next_page = $page + 1;
+                       $next_link = add_query_arg( 'page', $next_page, $base );
+                       $response->link_header( 'next', $next_link );
+               }
+
+               return $response;
+       }
+
+       /**
+        * Retrieves a specific ability.
+        *
+        * @since 6.9.0
+        *
+        * @param WP_REST_Request $request Full details about the request.
+        * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
+        */
+       public function get_item( $request ) {
+               $ability = wp_get_ability( $request['name'] );
+               if ( ! $ability || ! $ability->get_meta_item( 'show_in_rest' ) ) {
+                       return new WP_Error(
+                               'rest_ability_not_found',
+                               __( 'Ability not found.' ),
+                               array( 'status' => 404 )
+                       );
+               }
+
+               $data = $this->prepare_item_for_response( $ability, $request );
+               return rest_ensure_response( $data );
+       }
+
+       /**
+        * Checks if a given request has access to read ability items.
+        *
+        * @since 6.9.0
+        *
+        * @param WP_REST_Request $request Full details about the request.
+        * @return bool True if the request has read access.
+        */
+       public function get_items_permissions_check( $request ) {
+               return current_user_can( 'read' );
+       }
+
+       /**
+        * Checks if a given request has access to read an ability item.
+        *
+        * @since 6.9.0
+        *
+        * @param WP_REST_Request $request Full details about the request.
+        * @return bool True if the request has read access.
+        */
+       public function get_item_permissions_check( $request ) {
+               return current_user_can( 'read' );
+       }
+
+       /**
+        * Prepares an ability for response.
+        *
+        * @since 6.9.0
+        *
+        * @param WP_Ability      $ability The ability object.
+        * @param WP_REST_Request $request Request object.
+        * @return WP_REST_Response Response object.
+        */
+       public function prepare_item_for_response( $ability, $request ) {
+               $data = array(
+                       'name'          => $ability->get_name(),
+                       'label'         => $ability->get_label(),
+                       'description'   => $ability->get_description(),
+                       'category'      => $ability->get_category(),
+                       'input_schema'  => $ability->get_input_schema(),
+                       'output_schema' => $ability->get_output_schema(),
+                       'meta'          => $ability->get_meta(),
+               );
+
+               $context = $request['context'] ?? 'view';
+               $data    = $this->add_additional_fields_to_object( $data, $request );
+               $data    = $this->filter_response_by_context( $data, $context );
+
+               $response = rest_ensure_response( $data );
+
+               $fields = $this->get_fields_for_response( $request );
+               if ( rest_is_field_included( '_links', $fields ) || rest_is_field_included( '_embedded', $fields ) ) {
+                       $links = array(
+                               'self'       => array(
+                                       'href' => rest_url( sprintf( '%s/%s/%s', $this->namespace, $this->rest_base, $ability->get_name() ) ),
+                               ),
+                               'collection' => array(
+                                       'href' => rest_url( sprintf( '%s/%s', $this->namespace, $this->rest_base ) ),
+                               ),
+                       );
+
+                       $links['wp:action-run'] = array(
+                               'href' => rest_url( sprintf( '%s/%s/%s/run', $this->namespace, $this->rest_base, $ability->get_name() ) ),
+                       );
+
+                       $response->add_links( $links );
+               }
+
+               return $response;
+       }
+
+       /**
+        * Retrieves the ability's schema, conforming to JSON Schema.
+        *
+        * @since 6.9.0
+        *
+        * @return array<string, mixed> Item schema data.
+        */
+       public function get_item_schema(): array {
+               $schema = array(
+                       '$schema'    => 'http://json-schema.org/draft-04/schema#',
+                       'title'      => 'ability',
+                       'type'       => 'object',
+                       'properties' => array(
+                               'name'          => array(
+                                       'description' => __( 'Unique identifier for the ability.' ),
+                                       'type'        => 'string',
+                                       'context'     => array( 'view', 'edit', 'embed' ),
+                                       'readonly'    => true,
+                               ),
+                               'label'         => array(
+                                       'description' => __( 'Display label for the ability.' ),
+                                       'type'        => 'string',
+                                       'context'     => array( 'view', 'edit', 'embed' ),
+                                       'readonly'    => true,
+                               ),
+                               'description'   => array(
+                                       'description' => __( 'Description of the ability.' ),
+                                       'type'        => 'string',
+                                       'context'     => array( 'view', 'edit' ),
+                                       'readonly'    => true,
+                               ),
+                               'category'      => array(
+                                       'description' => __( 'Ability category this ability belongs to.' ),
+                                       'type'        => 'string',
+                                       'context'     => array( 'view', 'edit', 'embed' ),
+                                       'readonly'    => true,
+                               ),
+                               'input_schema'  => array(
+                                       'description' => __( 'JSON Schema for the ability input.' ),
+                                       'type'        => 'object',
+                                       'context'     => array( 'view', 'edit' ),
+                                       'readonly'    => true,
+                               ),
+                               'output_schema' => array(
+                                       'description' => __( 'JSON Schema for the ability output.' ),
+                                       'type'        => 'object',
+                                       'context'     => array( 'view', 'edit' ),
+                                       'readonly'    => true,
+                               ),
+                               'meta'          => array(
+                                       'description' => __( 'Meta information about the ability.' ),
+                                       'type'        => 'object',
+                                       'properties'  => array(
+                                               'annotations' => array(
+                                                       'description' => __( 'Annotations for the ability.' ),
+                                                       'type'        => array( 'boolean', 'null' ),
+                                                       'default'     => null,
+                                               ),
+                                       ),
+                                       'context'     => array( 'view', 'edit' ),
+                                       'readonly'    => true,
+                               ),
+                       ),
+               );
+
+               return $this->add_additional_fields_schema( $schema );
+       }
+
+       /**
+        * Retrieves the query params for collections.
+        *
+        * @since 6.9.0
+        *
+        * @return array<string, mixed> Collection parameters.
+        */
+       public function get_collection_params(): array {
+               return 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(
+                               'description' => __( 'Maximum number of items to be returned in result set.' ),
+                               'type'        => 'integer',
+                               'default'     => 50,
+                               'minimum'     => 1,
+                               'maximum'     => 100,
+                       ),
+                       'category' => array(
+                               'description'       => __( 'Limit results to abilities in specific ability category.' ),
+                               'type'              => 'string',
+                               'sanitize_callback' => 'sanitize_key',
+                               'validate_callback' => 'rest_validate_request_arg',
+                       ),
+               );
+       }
+}
</ins><span class="cx" style="display: block; padding: 0 10px">Property changes on: trunk/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-list-controller.php
</span><span class="cx" style="display: block; padding: 0 10px">___________________________________________________________________
</span></span></pre></div>
<a id="svneolstyle"></a>
<div class="addfile"><h4 style="background-color: #eee; color: inherit; margin: 1em 0; padding: 1.3em; font-size: 115%">Added: svn:eol-style</h4></div>
<ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+native
</ins><span class="cx" style="display: block; padding: 0 10px">\ No newline at end of property
</span><a id="trunksrcwpincludesrestapiendpointsclasswprestabilitiesv1runcontrollerphp"></a>
<div class="addfile"><h4 style="background-color: #eee; color: inherit; margin: 1em 0; padding: 1.3em; font-size: 115%">Added: trunk/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-run-controller.php</h4>
<pre class="diff"><span>
<span class="info" style="display: block; padding: 0 10px; color: #888">--- trunk/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-run-controller.php                            (rev 0)
+++ trunk/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-run-controller.php      2025-10-21 13:50:11 UTC (rev 61032)
</span><span class="lines" style="display: block; padding: 0 10px; color: #888">@@ -0,0 +1,243 @@
</span><ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+<?php
+/**
+ * REST API run controller for Abilities API.
+ *
+ * @package WordPress
+ * @subpackage Abilities_API
+ * @since 6.9.0
+ */
+
+declare( strict_types = 1 );
+
+/**
+ * Core controller used to execute abilities via the REST API.
+ *
+ * @since 6.9.0
+ *
+ * @see WP_REST_Controller
+ */
+class WP_REST_Abilities_V1_Run_Controller extends WP_REST_Controller {
+
+       /**
+        * REST API namespace.
+        *
+        * @since 6.9.0
+        * @var string
+        */
+       protected $namespace = 'wp-abilities/v1';
+
+       /**
+        * REST API base route.
+        *
+        * @since 6.9.0
+        * @var string
+        */
+       protected $rest_base = 'abilities';
+
+       /**
+        * Registers the routes for ability execution.
+        *
+        * @since 6.9.0
+        *
+        * @see register_rest_route()
+        */
+       public function register_routes(): void {
+               register_rest_route(
+                       $this->namespace,
+                       '/' . $this->rest_base . '/(?P<name>[a-zA-Z0-9\-\/]+?)/run',
+                       array(
+                               'args'   => array(
+                                       'name' => array(
+                                               'description' => __( 'Unique identifier for the ability.' ),
+                                               'type'        => 'string',
+                                               'pattern'     => '^[a-zA-Z0-9\-\/]+$',
+                                       ),
+                               ),
+
+                               // TODO: We register ALLMETHODS because at route registration time, we don't know which abilities
+                               // exist or their annotations (`destructive`, `idempotent`, `readonly`). This is due to WordPress
+                               // load order - routes are registered early, before plugins have registered their abilities.
+                               // This approach works but could be improved with lazy route registration or a different
+                               // architecture that allows type-specific routes after abilities are registered.
+                               // This was the same issue that we ended up seeing with the Feature API.
+                               array(
+                                       'methods'             => WP_REST_Server::ALLMETHODS,
+                                       'callback'            => array( $this, 'execute_ability' ),
+                                       'permission_callback' => array( $this, 'check_ability_permissions' ),
+                                       'args'                => $this->get_run_args(),
+                               ),
+                               'schema' => array( $this, 'get_run_schema' ),
+                       )
+               );
+       }
+
+       /**
+        * Executes an ability.
+        *
+        * @since 6.9.0
+        *
+        * @param WP_REST_Request $request Full details about the request.
+        * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
+        */
+       public function execute_ability( $request ) {
+               $ability = wp_get_ability( $request['name'] );
+               if ( ! $ability ) {
+                       return new WP_Error(
+                               'rest_ability_not_found',
+                               __( 'Ability not found.' ),
+                               array( 'status' => 404 )
+                       );
+               }
+
+               $input  = $this->get_input_from_request( $request );
+               $result = $ability->execute( $input );
+               if ( is_wp_error( $result ) ) {
+                       return $result;
+               }
+
+               return rest_ensure_response( $result );
+       }
+
+       /**
+        * Validates if the HTTP method matches the expected method for the ability based on its annotations.
+        *
+        * @since 6.9.0
+        *
+        * @param string                     $request_method The HTTP method of the request.
+        * @param array<string, (null|bool)> $annotations    The ability annotations.
+        * @return true|WP_Error True on success, or WP_Error object on failure.
+        */
+       public function validate_request_method( string $request_method, array $annotations ) {
+               $expected_method = 'POST';
+               if ( ! empty( $annotations['readonly'] ) ) {
+                       $expected_method = 'GET';
+               } elseif ( ! empty( $annotations['destructive'] ) && ! empty( $annotations['idempotent'] ) ) {
+                       $expected_method = 'DELETE';
+               }
+
+               if ( $expected_method === $request_method ) {
+                       return true;
+               }
+
+               $error_message = __( 'Abilities that perform updates require POST method.' );
+               if ( 'GET' === $expected_method ) {
+                       $error_message = __( 'Read-only abilities require GET method.' );
+               } elseif ( 'DELETE' === $expected_method ) {
+                       $error_message = __( 'Abilities that perform destructive actions require DELETE method.' );
+               }
+               return new WP_Error(
+                       'rest_ability_invalid_method',
+                       $error_message,
+                       array( 'status' => 405 )
+               );
+       }
+
+       /**
+        * Checks if a given request has permission to execute a specific ability.
+        *
+        * @since 6.9.0
+        *
+        * @param WP_REST_Request $request Full details about the request.
+        * @return true|WP_Error True if the request has execution permission, WP_Error object otherwise.
+        */
+       public function check_ability_permissions( $request ) {
+               $ability = wp_get_ability( $request['name'] );
+               if ( ! $ability || ! $ability->get_meta_item( 'show_in_rest' ) ) {
+                       return new WP_Error(
+                               'rest_ability_not_found',
+                               __( 'Ability not found.' ),
+                               array( 'status' => 404 )
+                       );
+               }
+
+               $is_valid = $this->validate_request_method(
+                       $request->get_method(),
+                       $ability->get_meta_item( 'annotations' )
+               );
+               if ( is_wp_error( $is_valid ) ) {
+                       return $is_valid;
+               }
+
+               $input    = $this->get_input_from_request( $request );
+               $is_valid = $ability->validate_input( $input );
+               if ( is_wp_error( $is_valid ) ) {
+                       $is_valid->add_data( array( 'status' => 400 ) );
+                       return $is_valid;
+               }
+
+               $result = $ability->check_permissions( $input );
+               if ( is_wp_error( $result ) ) {
+                       $result->add_data( array( 'status' => rest_authorization_required_code() ) );
+                       return $result;
+               }
+               if ( ! $result ) {
+                       return new WP_Error(
+                               'rest_ability_cannot_execute',
+                               __( 'Sorry, you are not allowed to execute this ability.' ),
+                               array( 'status' => rest_authorization_required_code() )
+                       );
+               }
+
+               return true;
+       }
+
+       /**
+        * Extracts input parameters from the request.
+        *
+        * @since 6.9.0
+        *
+        * @param WP_REST_Request $request The request object.
+        * @return mixed|null The input parameters.
+        */
+       private function get_input_from_request( $request ) {
+               if ( in_array( $request->get_method(), array( 'GET', 'DELETE' ), true ) ) {
+                       // For GET and DELETE requests, look for 'input' query parameter.
+                       $query_params = $request->get_query_params();
+                       return $query_params['input'] ?? null;
+               }
+
+               // For POST requests, look for 'input' in JSON body.
+               $json_params = $request->get_json_params();
+               return $json_params['input'] ?? null;
+       }
+
+       /**
+        * Retrieves the arguments for ability execution endpoint.
+        *
+        * @since 6.9.0
+        *
+        * @return array<string, mixed> Arguments for the run endpoint.
+        */
+       public function get_run_args(): array {
+               return array(
+                       'input' => array(
+                               'description' => __( 'Input parameters for the ability execution.' ),
+                               'type'        => array( 'integer', 'number', 'boolean', 'string', 'array', 'object', 'null' ),
+                               'default'     => null,
+                       ),
+               );
+       }
+
+       /**
+        * Retrieves the schema for ability execution endpoint.
+        *
+        * @since 6.9.0
+        *
+        * @return array<string, mixed> Schema for the run endpoint.
+        */
+       public function get_run_schema(): array {
+               return array(
+                       '$schema'    => 'http://json-schema.org/draft-04/schema#',
+                       'title'      => 'ability-execution',
+                       'type'       => 'object',
+                       'properties' => array(
+                               'result' => array(
+                                       'description' => __( 'The result of the ability execution.' ),
+                                       'type'        => array( 'integer', 'number', 'boolean', 'string', 'array', 'object', 'null' ),
+                                       'context'     => array( 'view', 'edit' ),
+                                       'readonly'    => true,
+                               ),
+                       ),
+               );
+       }
+}
</ins><span class="cx" style="display: block; padding: 0 10px">Property changes on: trunk/src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-run-controller.php
</span><span class="cx" style="display: block; padding: 0 10px">___________________________________________________________________
</span></span></pre></div>
<a id="svneolstyle"></a>
<div class="addfile"><h4 style="background-color: #eee; color: inherit; margin: 1em 0; padding: 1.3em; font-size: 115%">Added: svn:eol-style</h4></div>
<ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+native
</ins><span class="cx" style="display: block; padding: 0 10px">\ No newline at end of property
</span><a id="trunksrcwpincludesrestapiphp"></a>
<div class="modfile"><h4 style="background-color: #eee; color: inherit; margin: 1em 0; padding: 1.3em; font-size: 115%">Modified: trunk/src/wp-includes/rest-api.php</h4>
<pre class="diff"><span>
<span class="info" style="display: block; padding: 0 10px; color: #888">--- trunk/src/wp-includes/rest-api.php        2025-10-21 13:47:20 UTC (rev 61031)
+++ trunk/src/wp-includes/rest-api.php  2025-10-21 13:50:11 UTC (rev 61032)
</span><span class="lines" style="display: block; padding: 0 10px; color: #888">@@ -483,6 +483,12 @@
</span><span class="cx" style="display: block; padding: 0 10px">        // Font Collections.
</span><span class="cx" style="display: block; padding: 0 10px">        $font_collections_controller = new WP_REST_Font_Collections_Controller();
</span><span class="cx" style="display: block; padding: 0 10px">        $font_collections_controller->register_routes();
</span><ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+
+       // Abilities.
+       $abilities_run_controller  = new WP_REST_Abilities_V1_Run_Controller();
+       $abilities_run_controller->register_routes();
+       $abilities_list_controller = new WP_REST_Abilities_V1_List_Controller();
+       $abilities_list_controller->register_routes();
</ins><span class="cx" style="display: block; padding: 0 10px"> }
</span><span class="cx" style="display: block; padding: 0 10px"> 
</span><span class="cx" style="display: block; padding: 0 10px"> /**
</span></span></pre></div>
<a id="trunksrcwpsettingsphp"></a>
<div class="modfile"><h4 style="background-color: #eee; color: inherit; margin: 1em 0; padding: 1.3em; font-size: 115%">Modified: trunk/src/wp-settings.php</h4>
<pre class="diff"><span>
<span class="info" style="display: block; padding: 0 10px; color: #888">--- trunk/src/wp-settings.php 2025-10-21 13:47:20 UTC (rev 61031)
+++ trunk/src/wp-settings.php   2025-10-21 13:50:11 UTC (rev 61032)
</span><span class="lines" style="display: block; padding: 0 10px; color: #888">@@ -285,6 +285,11 @@
</span><span class="cx" style="display: block; padding: 0 10px"> require ABSPATH . WPINC . '/nav-menu.php';
</span><span class="cx" style="display: block; padding: 0 10px"> require ABSPATH . WPINC . '/admin-bar.php';
</span><span class="cx" style="display: block; padding: 0 10px"> require ABSPATH . WPINC . '/class-wp-application-passwords.php';
</span><ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+require ABSPATH . WPINC . '/abilities-api/class-wp-ability-category.php';
+require ABSPATH . WPINC . '/abilities-api/class-wp-ability-categories-registry.php';
+require ABSPATH . WPINC . '/abilities-api/class-wp-ability.php';
+require ABSPATH . WPINC . '/abilities-api/class-wp-abilities-registry.php';
+require ABSPATH . WPINC . '/abilities-api.php';
</ins><span class="cx" style="display: block; padding: 0 10px"> require ABSPATH . WPINC . '/rest-api.php';
</span><span class="cx" style="display: block; padding: 0 10px"> require ABSPATH . WPINC . '/rest-api/class-wp-rest-server.php';
</span><span class="cx" style="display: block; padding: 0 10px"> require ABSPATH . WPINC . '/rest-api/class-wp-rest-response.php';
</span><span class="lines" style="display: block; padding: 0 10px; color: #888">@@ -331,6 +336,8 @@
</span><span class="cx" style="display: block; padding: 0 10px"> require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-font-families-controller.php';
</span><span class="cx" style="display: block; padding: 0 10px"> require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-font-faces-controller.php';
</span><span class="cx" style="display: block; padding: 0 10px"> require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-font-collections-controller.php';
</span><ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-abilities-v1-list-controller.php';
+require ABSPATH . WPINC . '/rest-api/endpoints/class-wp-rest-abilities-v1-run-controller.php';
</ins><span class="cx" style="display: block; padding: 0 10px"> require ABSPATH . WPINC . '/rest-api/fields/class-wp-rest-meta-fields.php';
</span><span class="cx" style="display: block; padding: 0 10px"> require ABSPATH . WPINC . '/rest-api/fields/class-wp-rest-comment-meta-fields.php';
</span><span class="cx" style="display: block; padding: 0 10px"> require ABSPATH . WPINC . '/rest-api/fields/class-wp-rest-post-meta-fields.php';
</span></span></pre></div>
<a id="trunktestsphpunittestsabilitiesapiwpAbilitiesRegistryphp"></a>
<div class="addfile"><h4 style="background-color: #eee; color: inherit; margin: 1em 0; padding: 1.3em; font-size: 115%">Added: trunk/tests/phpunit/tests/abilities-api/wpAbilitiesRegistry.php</h4>
<pre class="diff"><span>
<span class="info" style="display: block; padding: 0 10px; color: #888">--- trunk/tests/phpunit/tests/abilities-api/wpAbilitiesRegistry.php                           (rev 0)
+++ trunk/tests/phpunit/tests/abilities-api/wpAbilitiesRegistry.php     2025-10-21 13:50:11 UTC (rev 61032)
</span><span class="lines" style="display: block; padding: 0 10px; color: #888">@@ -0,0 +1,651 @@
</span><ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+<?php declare( strict_types=1 );
+
+/**
+ * Tests for the abilities registry functionality.
+ *
+ * @covers WP_Abilities_Registry
+ *
+ * @group abilities-api
+ */
+class Tests_Abilities_API_WpAbilitiesRegistry extends WP_UnitTestCase {
+
+       public static $test_ability_name = 'test/add-numbers';
+       public static $test_ability_args = array();
+
+       /**
+        * Mock abilities registry.
+        *
+        * @var WP_Abilities_Registry
+        */
+       private $registry = null;
+
+       /**
+        * Set up each test method.
+        */
+       public function set_up(): void {
+               parent::set_up();
+
+               $this->registry = new WP_Abilities_Registry();
+
+               remove_all_filters( 'wp_register_ability_args' );
+
+               // Fire the init hook to allow test ability category registration.
+               do_action( 'wp_abilities_api_categories_init' );
+               wp_register_ability_category(
+                       'math',
+                       array(
+                               'label'       => 'Math',
+                               'description' => 'Mathematical operations and calculations.',
+                       )
+               );
+
+               self::$test_ability_args = array(
+                       'label'               => 'Add numbers',
+                       'description'         => 'Calculates the result of adding two numbers.',
+                       'category'            => 'math',
+                       'input_schema'        => array(
+                               'type'                 => 'object',
+                               'properties'           => array(
+                                       'a' => array(
+                                               'type'        => 'number',
+                                               'description' => 'First number.',
+                                               'required'    => true,
+                                       ),
+                                       'b' => array(
+                                               'type'        => 'number',
+                                               'description' => 'Second number.',
+                                               'required'    => true,
+                                       ),
+                               ),
+                               'additionalProperties' => false,
+                       ),
+                       'output_schema'       => array(
+                               'type'        => 'number',
+                               'description' => 'The result of adding the two numbers.',
+                               'required'    => true,
+                       ),
+                       'execute_callback'    => static function ( array $input ): int {
+                               return $input['a'] + $input['b'];
+                       },
+                       'permission_callback' => static function (): bool {
+                               return true;
+                       },
+                       'meta'                => array(
+                               'foo' => 'bar',
+                       ),
+               );
+       }
+
+       /**
+        * Tear down each test method.
+        */
+       public function tear_down(): void {
+               $this->registry = null;
+
+               remove_all_filters( 'wp_register_ability_args' );
+
+               // Clean up registered test ability category.
+               wp_unregister_ability_category( 'math' );
+
+               parent::tear_down();
+       }
+
+       /**
+        * Should reject ability name without a namespace.
+        *
+        * @ticket 64098
+        *
+        * @covers WP_Abilities_Registry::register
+        *
+        * @expectedIncorrectUsage WP_Abilities_Registry::register
+        */
+       public function test_register_invalid_name_without_namespace() {
+               $result = $this->registry->register( 'without-namespace', self::$test_ability_args );
+               $this->assertNull( $result );
+       }
+
+       /**
+        * Should reject ability name with invalid characters.
+        *
+        * @ticket 64098
+        *
+        * @covers WP_Abilities_Registry::register
+        *
+        * @expectedIncorrectUsage WP_Abilities_Registry::register
+        */
+       public function test_register_invalid_characters_in_name() {
+               $result = $this->registry->register( 'still/_doing_it_wrong', array() );
+               $this->assertNull( $result );
+       }
+
+       /**
+        * Should reject ability name with uppercase characters.
+        *
+        * @ticket 64098
+        *
+        * @covers WP_Abilities_Registry::register
+        *
+        * @expectedIncorrectUsage WP_Abilities_Registry::register
+        */
+       public function test_register_invalid_uppercase_characters_in_name() {
+               $result = $this->registry->register( 'Test/AddNumbers', self::$test_ability_args );
+               $this->assertNull( $result );
+       }
+
+       /**
+        * Should reject ability registration without a label.
+        *
+        * @ticket 64098
+        *
+        * @covers WP_Abilities_Registry::register
+        * @covers WP_Ability::prepare_properties
+        *
+        * @expectedIncorrectUsage WP_Abilities_Registry::register
+        */
+       public function test_register_invalid_missing_label() {
+               // Remove the label from the args.
+               unset( self::$test_ability_args['label'] );
+
+               $result = $this->registry->register( self::$test_ability_name, self::$test_ability_args );
+               $this->assertNull( $result );
+       }
+
+       /**
+        * Should reject ability registration with invalid label type.
+        *
+        * @ticket 64098
+        *
+        * @covers WP_Abilities_Registry::register
+        * @covers WP_Ability::prepare_properties
+        *
+        * @expectedIncorrectUsage WP_Abilities_Registry::register
+        */
+       public function test_register_invalid_label_type() {
+               self::$test_ability_args['label'] = false;
+
+               $result = $this->registry->register( self::$test_ability_name, self::$test_ability_args );
+               $this->assertNull( $result );
+       }
+
+       /**
+        * Should reject ability registration without a description.
+        *
+        * @ticket 64098
+        *
+        * @covers WP_Abilities_Registry::register
+        * @covers WP_Ability::prepare_properties
+        *
+        * @expectedIncorrectUsage WP_Abilities_Registry::register
+        */
+       public function test_register_invalid_missing_description() {
+               // Remove the description from the args.
+               unset( self::$test_ability_args['description'] );
+
+               $result = $this->registry->register( self::$test_ability_name, self::$test_ability_args );
+               $this->assertNull( $result );
+       }
+
+       /**
+        * Should reject ability registration with invalid description type.
+        *
+        * @ticket 64098
+        *
+        * @covers WP_Abilities_Registry::register
+        * @covers WP_Ability::prepare_properties
+        *
+        * @expectedIncorrectUsage WP_Abilities_Registry::register
+        */
+       public function test_register_invalid_description_type() {
+               self::$test_ability_args['description'] = false;
+
+               $result = $this->registry->register( self::$test_ability_name, self::$test_ability_args );
+               $this->assertNull( $result );
+       }
+
+       /**
+        * Tests registering an ability with non-existent category.
+        *
+        * @ticket 64098
+        *
+        * @expectedIncorrectUsage WP_Abilities_Registry::register
+        */
+       public function test_register_ability_nonexistent_category(): void {
+               $args = array_merge(
+                       self::$test_ability_args,
+                       array( 'category' => 'nonexistent' )
+               );
+
+               $result = $this->registry->register( self::$test_ability_name, $args );
+
+               $this->assertNull( $result, 'Should return null when category does not exist.' );
+       }
+
+       /**
+        * Should reject ability registration without an execute callback.
+        *
+        * @ticket 64098
+        *
+        * @covers WP_Abilities_Registry::register
+        * @covers WP_Ability::prepare_properties
+        *
+        * @expectedIncorrectUsage WP_Abilities_Registry::register
+        */
+       public function test_register_invalid_missing_execute_callback() {
+               // Remove the execute_callback from the args.
+               unset( self::$test_ability_args['execute_callback'] );
+
+               $result = $this->registry->register( self::$test_ability_name, self::$test_ability_args );
+               $this->assertNull( $result );
+       }
+
+       /**
+        * Should reject ability registration if the execute callback is not a callable.
+        *
+        * @ticket 64098
+        *
+        * @covers WP_Abilities_Registry::register
+        * @covers WP_Ability::prepare_properties
+        *
+        * @expectedIncorrectUsage WP_Abilities_Registry::register
+        */
+       public function test_register_incorrect_execute_callback_type() {
+               self::$test_ability_args['execute_callback'] = 'not-a-callback';
+
+               $result = $this->registry->register( self::$test_ability_name, self::$test_ability_args );
+               $this->assertNull( $result );
+       }
+
+       /**
+        * Should reject ability registration without an execute callback.
+        *
+        * @ticket 64098
+        *
+        * @covers WP_Abilities_Registry::register
+        * @covers WP_Ability::prepare_properties
+        *
+        * @expectedIncorrectUsage WP_Abilities_Registry::register
+        */
+       public function test_register_invalid_missing_permission_callback() {
+               // Remove the permission_callback from the args.
+               unset( self::$test_ability_args['permission_callback'] );
+
+               $result = $this->registry->register( self::$test_ability_name, self::$test_ability_args );
+               $this->assertNull( $result );
+       }
+
+       /**
+        * Should reject ability registration if the permission callback is not a callable.
+        *
+        * @ticket 64098
+        *
+        * @covers WP_Abilities_Registry::register
+        * @covers WP_Ability::prepare_properties
+        *
+        * @expectedIncorrectUsage WP_Abilities_Registry::register
+        */
+       public function test_register_incorrect_permission_callback_type() {
+               self::$test_ability_args['permission_callback'] = 'not-a-callback';
+
+               $result = $this->registry->register( self::$test_ability_name, self::$test_ability_args );
+               $this->assertNull( $result );
+       }
+
+       /**
+        * Should reject ability registration if the input schema is not an array.
+        *
+        * @ticket 64098
+        *
+        * @covers WP_Abilities_Registry::register
+        * @covers WP_Ability::prepare_properties
+        *
+        * @expectedIncorrectUsage WP_Abilities_Registry::register
+        */
+       public function test_register_incorrect_input_schema_type() {
+               self::$test_ability_args['input_schema'] = 'not-an-array';
+
+               $result = $this->registry->register( self::$test_ability_name, self::$test_ability_args );
+               $this->assertNull( $result );
+       }
+
+       /**
+        * Should reject ability registration if the output schema is not an array.
+        *
+        * @ticket 64098
+        *
+        * @covers WP_Abilities_Registry::register
+        * @covers WP_Ability::prepare_properties
+        *
+        * @expectedIncorrectUsage WP_Abilities_Registry::register
+        */
+       public function test_register_incorrect_output_schema_type() {
+               self::$test_ability_args['output_schema'] = 'not-an-array';
+
+               $result = $this->registry->register( self::$test_ability_name, self::$test_ability_args );
+               $this->assertNull( $result );
+       }
+
+
+       /**
+        * Should reject ability registration with invalid `annotations` type.
+        *
+        * @ticket 64098
+        *
+        * @covers WP_Abilities_Registry::register
+        * @covers WP_Ability::prepare_properties
+        *
+        * @expectedIncorrectUsage WP_Abilities_Registry::register
+        */
+       public function test_register_invalid_annotations_type() {
+               self::$test_ability_args['meta']['annotations'] = false;
+
+               $result = $this->registry->register( self::$test_ability_name, self::$test_ability_args );
+               $this->assertNull( $result );
+       }
+
+       /**
+        * Should reject ability registration with invalid meta type.
+        *
+        * @ticket 64098
+        *
+        * @covers WP_Abilities_Registry::register
+        * @covers WP_Ability::prepare_properties
+        *
+        * @expectedIncorrectUsage WP_Abilities_Registry::register
+        */
+       public function test_register_invalid_meta_type() {
+               self::$test_ability_args['meta'] = false;
+
+               $result = $this->registry->register( self::$test_ability_name, self::$test_ability_args );
+               $this->assertNull( $result );
+       }
+
+       /**
+        * Should reject ability registration with invalid show in REST type.
+        *
+        * @ticket 64098
+        *
+        * @covers WP_Abilities_Registry::register
+        * @covers WP_Ability::prepare_properties
+        *
+        * @expectedIncorrectUsage WP_Abilities_Registry::register
+        */
+       public function test_register_invalid_show_in_rest_type() {
+               self::$test_ability_args['meta']['show_in_rest'] = 5;
+
+               $result = $this->registry->register( self::$test_ability_name, self::$test_ability_args );
+               $this->assertNull( $result );
+       }
+
+       /**
+        * Should reject registration for already registered ability.
+        *
+        * @ticket 64098
+        *
+        * @covers WP_Abilities_Registry::register
+        *
+        * @expectedIncorrectUsage WP_Abilities_Registry::register
+        */
+       public function test_register_incorrect_already_registered_ability() {
+               $this->registry->register( self::$test_ability_name, self::$test_ability_args );
+
+               $result = $this->registry->register( self::$test_ability_name, self::$test_ability_args );
+
+               $this->assertNull( $result );
+       }
+
+       /**
+        * Should successfully register a new ability.
+        *
+        * @ticket 64098
+        *
+        * @covers WP_Abilities_Registry::register
+        */
+       public function test_register_new_ability() {
+               $result = $this->registry->register( self::$test_ability_name, self::$test_ability_args );
+
+               $this->assertEquals(
+                       new WP_Ability( self::$test_ability_name, self::$test_ability_args ),
+                       $result
+               );
+       }
+
+       /**
+        * Should return false for ability that's not registered.
+        *
+        * @ticket 64098
+        *
+        * @covers WP_Abilities_Registry::is_registered
+        */
+       public function test_is_registered_for_unknown_ability() {
+               $result = $this->registry->is_registered( 'test/unknown' );
+               $this->assertFalse( $result );
+       }
+
+       /**
+        * Should return true if ability is registered.
+        *
+        * @ticket 64098
+        *
+        * @covers WP_Abilities_Registry::register
+        * @covers WP_Abilities_Registry::is_registered
+        */
+       public function test_is_registered_for_known_ability() {
+               $this->registry->register( 'test/one', self::$test_ability_args );
+               $this->registry->register( 'test/two', self::$test_ability_args );
+               $this->registry->register( 'test/three', self::$test_ability_args );
+
+               $result = $this->registry->is_registered( 'test/one' );
+               $this->assertTrue( $result );
+       }
+
+       /**
+        * Should not find ability that's not registered.
+        *
+        * @ticket 64098
+        *
+        * @covers WP_Abilities_Registry::get_registered
+        *
+        * @expectedIncorrectUsage WP_Abilities_Registry::get_registered
+        */
+       public function test_get_registered_rejects_unknown_ability_name() {
+               $ability = $this->registry->get_registered( 'test/unknown' );
+               $this->assertNull( $ability );
+       }
+
+       /**
+        * Should find registered ability by name.
+        *
+        * @ticket 64098
+        *
+        * @covers WP_Abilities_Registry::register
+        * @covers WP_Abilities_Registry::get_registered
+        */
+       public function test_get_registered_for_known_ability() {
+               $this->registry->register( 'test/one', self::$test_ability_args );
+               $this->registry->register( 'test/two', self::$test_ability_args );
+               $this->registry->register( 'test/three', self::$test_ability_args );
+
+               $result = $this->registry->get_registered( 'test/two' );
+               $this->assertEquals( 'test/two', $result->get_name() );
+       }
+
+       /**
+        * Unregistering should fail if an ability is not registered.
+        *
+        * @ticket 64098
+        *
+        * @covers WP_Abilities_Registry::unregister
+        *
+        * @expectedIncorrectUsage WP_Abilities_Registry::unregister
+        */
+       public function test_unregister_not_registered_ability() {
+               $result = $this->registry->unregister( 'test/unregistered' );
+               $this->assertNull( $result );
+       }
+
+       /**
+        * Should unregister ability by name.
+        *
+        * @ticket 64098
+        *
+        * @covers WP_Abilities_Registry::register
+        * @covers WP_Abilities_Registry::unregister
+        */
+       public function test_unregister_for_known_ability() {
+               $this->registry->register( 'test/one', self::$test_ability_args );
+               $this->registry->register( 'test/two', self::$test_ability_args );
+               $this->registry->register( 'test/three', self::$test_ability_args );
+
+               $result = $this->registry->unregister( 'test/three' );
+               $this->assertEquals( 'test/three', $result->get_name() );
+
+               $this->assertFalse( $this->registry->is_registered( 'test/three' ) );
+       }
+
+       /**
+        * Should retrieve all registered abilities.
+        *
+        * @ticket 64098
+        *
+        * @covers WP_Abilities_Registry::register
+        * @covers WP_Abilities_Registry::get_all_registered
+        */
+       public function test_get_all_registered() {
+               $ability_one_name = 'test/one';
+               $this->registry->register( $ability_one_name, self::$test_ability_args );
+
+               $ability_two_name = 'test/two';
+               $this->registry->register( $ability_two_name, self::$test_ability_args );
+
+               $ability_three_name = 'test/three';
+               $this->registry->register( $ability_three_name, self::$test_ability_args );
+
+               $result = $this->registry->get_all_registered();
+               $this->assertCount( 3, $result );
+               $this->assertSame( $ability_one_name, $result[ $ability_one_name ]->get_name() );
+               $this->assertSame( $ability_two_name, $result[ $ability_two_name ]->get_name() );
+               $this->assertSame( $ability_three_name, $result[ $ability_three_name ]->get_name() );
+       }
+
+       /**
+        * Test register_ability_args filter modifies the args before ability instantiation.
+        *
+        * @ticket 64098
+        */
+       public function test_register_ability_args_filter_modifies_args() {
+               $was_filter_callback_fired = false;
+
+               // Define the filter.
+               add_filter(
+                       'wp_register_ability_args',
+                       static function ( $args ) use ( &$was_filter_callback_fired ) {
+                               $args['label']             = 'Modified label';
+                               $original_execute_callback = $args['execute_callback'];
+                               $args['execute_callback']  = static function ( array $input ) use ( &$was_filter_callback_fired, $original_execute_callback ) {
+                                       $was_filter_callback_fired = true;
+                                       return $original_execute_callback( $input );
+                               };
+
+                               return $args;
+                       },
+                       10
+               );
+
+               // Register the ability.
+               $ability = $this->registry->register( self::$test_ability_name, self::$test_ability_args );
+
+               // Check the label was modified by the filter.
+               $this->assertSame( 'Modified label', $ability->get_label() );
+
+               // Call the execute callback.
+               $result = $ability->execute(
+                       array(
+                               'a' => 1,
+                               'b' => 2,
+                       )
+               );
+
+               $this->assertTrue( $was_filter_callback_fired, 'The execute callback defined in the filter was not fired.' );
+               $this->assertSame( 3, $result, 'The original execute callback did not return the expected result.' );
+       }
+
+       /**
+        * Test register_ability_args filter can block ability registration by returning invalid args.
+        *
+        * @ticket 64098
+        *
+        * @expectedIncorrectUsage WP_Abilities_Registry::register
+        */
+       public function test_register_ability_args_filter_blocks_registration() {
+               // Define the filter.
+               add_filter(
+                       'wp_register_ability_args',
+                       static function ( $args ) {
+                               // Remove the label to make the args invalid.
+                               unset( $args['label'] );
+                               return $args;
+                       },
+                       10
+               );
+
+               // Register the ability.
+               $ability = $this->registry->register( self::$test_ability_name, self::$test_ability_args );
+
+               // Check the ability was not registered.
+               $this->assertNull( $ability, 'The ability was registered even though the args were made invalid by the filter.' );
+       }
+
+       /**
+        * Test register_ability_args filter can block an invalid ability class from being used.
+        *
+        * @ticket 64098
+        *
+        * @expectedIncorrectUsage WP_Abilities_Registry::register
+        */
+       public function test_register_ability_args_filter_blocks_invalid_ability_class() {
+               // Define the filter.
+               add_filter(
+                       'wp_register_ability_args',
+                       static function ( $args ) {
+                               // Set an invalid ability class.
+                               $args['ability_class'] = 'NonExistentClass';
+                               return $args;
+                       },
+                       10
+               );
+               // Register the ability.
+               $ability = $this->registry->register( self::$test_ability_name, self::$test_ability_args );
+
+               // Check the ability was not registered.
+               $this->assertNull( $ability, 'The ability was registered even though the ability class was made invalid by the filter.' );
+       }
+
+       /**
+        * Tests register_ability_args filter is only applied to the specific ability being registered.
+        *
+        * @ticket 64098
+        */
+       public function test_register_ability_args_filter_only_applies_to_specific_ability() {
+               add_filter(
+                       'wp_register_ability_args',
+                       static function ( $args, $name ) {
+                               if ( self::$test_ability_name !== $name ) {
+                                       // Do not modify args for other abilities.
+                                       return $args;
+                               }
+
+                               $args['label'] = 'Modified label for specific ability';
+                               return $args;
+                       },
+                       10,
+                       2
+               );
+
+               // Register the first ability, which the filter should modify.
+               $filtered_ability = $this->registry->register( self::$test_ability_name, self::$test_ability_args );
+               $this->assertSame( 'Modified label for specific ability', $filtered_ability->get_label() );
+
+               $unfiltered_ability = $this->registry->register( 'test/another-ability', self::$test_ability_args );
+               $this->assertNotSame( $filtered_ability->get_label(), $unfiltered_ability->get_label(), 'The filter incorrectly modified the args for an ability it should not have.' );
+       }
+}
</ins><span class="cx" style="display: block; padding: 0 10px">Property changes on: trunk/tests/phpunit/tests/abilities-api/wpAbilitiesRegistry.php
</span><span class="cx" style="display: block; padding: 0 10px">___________________________________________________________________
</span></span></pre></div>
<a id="svneolstyle"></a>
<div class="addfile"><h4 style="background-color: #eee; color: inherit; margin: 1em 0; padding: 1.3em; font-size: 115%">Added: svn:eol-style</h4></div>
<ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+native
</ins><span class="cx" style="display: block; padding: 0 10px">\ No newline at end of property
</span><a id="trunktestsphpunittestsabilitiesapiwpAbilityphp"></a>
<div class="addfile"><h4 style="background-color: #eee; color: inherit; margin: 1em 0; padding: 1.3em; font-size: 115%">Added: trunk/tests/phpunit/tests/abilities-api/wpAbility.php</h4>
<pre class="diff"><span>
<span class="info" style="display: block; padding: 0 10px; color: #888">--- trunk/tests/phpunit/tests/abilities-api/wpAbility.php                             (rev 0)
+++ trunk/tests/phpunit/tests/abilities-api/wpAbility.php       2025-10-21 13:50:11 UTC (rev 61032)
</span><span class="lines" style="display: block; padding: 0 10px; color: #888">@@ -0,0 +1,794 @@
</span><ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+<?php declare( strict_types=1 );
+
+/**
+ * Tests for the abilities registry functionality.
+ *
+ * @covers WP_Ability
+ *
+ * @group abilities-api
+ */
+class Tests_Abilities_API_WpAbility extends WP_UnitTestCase {
+
+       public static $test_ability_name       = 'test/calculator';
+       public static $test_ability_properties = array();
+
+       /**
+        * Set up each test method.
+        */
+       public function set_up(): void {
+               parent::set_up();
+
+               // Fire the init hook to allow test ability category registration.
+               do_action( 'wp_abilities_api_categories_init' );
+               wp_register_ability_category(
+                       'math',
+                       array(
+                               'label'       => 'Math',
+                               'description' => 'Mathematical operations and calculations.',
+                       )
+               );
+
+               self::$test_ability_properties = array(
+                       'label'               => 'Calculator',
+                       'description'         => 'Calculates the result of math operations.',
+                       'category'            => 'math',
+                       'output_schema'       => array(
+                               'type'        => 'number',
+                               'description' => 'The result of performing a math operation.',
+                               'required'    => true,
+                       ),
+                       'execute_callback'    => static function (): int {
+                               return 0;
+                       },
+                       'permission_callback' => static function (): bool {
+                               return true;
+                       },
+                       'meta'                => array(
+                               'annotations' => array(
+                                       'readonly'    => true,
+                                       'destructive' => false,
+                               ),
+                       ),
+               );
+       }
+
+       /**
+        * Tear down after each test.
+        */
+       public function tear_down(): void {
+               // Clean up registered test ability category.
+               wp_unregister_ability_category( 'math' );
+
+               parent::tear_down();
+       }
+
+       /**
+        * Direct instantiation of WP_Ability with invalid properties should throw an exception.
+        *
+        * @ticket 64098
+        *
+        * @covers WP_Ability::__construct
+        * @covers WP_Ability::prepare_properties
+        */
+       public function test_wp_ability_invalid_properties_throws_exception() {
+               $this->expectException( InvalidArgumentException::class );
+               new WP_Ability(
+                       'test/invalid',
+                       array(
+                               'label'            => '',
+                               'description'      => '',
+                               'execute_callback' => null,
+                       )
+               );
+       }
+
+       /*
+        * Tests that getting non-existing metadata item returns default value.
+        *
+        * @ticket 64098
+        */
+       public function test_meta_get_non_existing_item_returns_default() {
+               $ability = new WP_Ability( self::$test_ability_name, self::$test_ability_properties );
+
+               $this->assertNull(
+                       $ability->get_meta_item( 'non_existing' ),
+                       'Non-existing metadata item should return null.'
+               );
+       }
+
+       /**
+        * Tests that getting non-existing metadata item with custom default returns that default.
+        *
+        * @ticket 64098
+        */
+       public function test_meta_get_non_existing_item_with_custom_default() {
+               $ability = new WP_Ability( self::$test_ability_name, self::$test_ability_properties );
+
+               $this->assertSame(
+                       'default_value',
+                       $ability->get_meta_item( 'non_existing', 'default_value' ),
+                       'Non-existing metadata item should return custom default value.'
+               );
+       }
+
+       /**
+        * Tests getting all annotations when selective overrides are applied.
+        *
+        * @ticket 64098
+        */
+       public function test_get_merged_annotations_from_meta() {
+               $ability = new WP_Ability( self::$test_ability_name, self::$test_ability_properties );
+
+               $this->assertSame(
+                       array_merge(
+                               self::$test_ability_properties['meta']['annotations'],
+                               array(
+                                       'idempotent' => null,
+                               )
+                       ),
+                       $ability->get_meta_item( 'annotations' )
+               );
+       }
+
+       /**
+        * Tests getting default annotations when not provided.
+        *
+        * @ticket 64098
+        */
+       public function test_get_default_annotations_from_meta() {
+               $args = self::$test_ability_properties;
+               unset( $args['meta']['annotations'] );
+
+               $ability = new WP_Ability( self::$test_ability_name, $args );
+
+               $this->assertSame(
+                       array(
+                               'readonly'    => null,
+                               'destructive' => null,
+                               'idempotent'  => null,
+                       ),
+                       $ability->get_meta_item( 'annotations' )
+               );
+       }
+
+       /**
+        * Tests getting all annotations when values overridden.
+        *
+        * @ticket 64098
+        */
+       public function test_get_overridden_annotations_from_meta() {
+               $annotations = array(
+                       'readonly'    => true,
+                       'destructive' => false,
+                       'idempotent'  => false,
+               );
+               $args        = array_merge(
+                       self::$test_ability_properties,
+                       array(
+                               'meta' => array(
+                                       'annotations' => $annotations,
+                               ),
+                       )
+               );
+
+               $ability = new WP_Ability( self::$test_ability_name, $args );
+
+               $this->assertSame( $annotations, $ability->get_meta_item( 'annotations' ) );
+       }
+
+       /**
+        * Tests that invalid `annotations` value throws an exception.
+        *
+        * @ticket 64098
+        */
+       public function test_annotations_from_meta_throws_exception() {
+               $args = array_merge(
+                       self::$test_ability_properties,
+                       array(
+                               'meta' => array(
+                                       'annotations' => 5,
+                               ),
+                       )
+               );
+
+               $this->expectException( InvalidArgumentException::class );
+               $this->expectExceptionMessage( 'The ability meta should provide a valid `annotations` array.' );
+
+               new WP_Ability( self::$test_ability_name, $args );
+       }
+
+       /**
+        * Tests that `show_in_rest` metadata defaults to false when not provided.
+        *
+        * @ticket 64098
+        */
+       public function test_meta_show_in_rest_defaults_to_false() {
+               $ability = new WP_Ability( self::$test_ability_name, self::$test_ability_properties );
+
+               $this->assertFalse(
+                       $ability->get_meta_item( 'show_in_rest' ),
+                       '`show_in_rest` metadata should default to false.'
+               );
+       }
+
+       /**
+        * Tests that `show_in_rest` metadata can be set to true.
+        *
+        * @ticket 64098
+        */
+       public function test_meta_show_in_rest_can_be_set_to_true() {
+               $args    = array_merge(
+                       self::$test_ability_properties,
+                       array(
+                               'meta' => array(
+                                       'show_in_rest' => true,
+                               ),
+                       )
+               );
+               $ability = new WP_Ability( self::$test_ability_name, $args );
+
+               $this->assertTrue(
+                       $ability->get_meta_item( 'show_in_rest' ),
+                       '`show_in_rest` metadata should be true.'
+               );
+       }
+
+       /**
+        * Tests that `show_in_rest` can be set to false.
+        *
+        * @ticket 64098
+        */
+       public function test_show_in_rest_can_be_set_to_false() {
+               $args    = array_merge(
+                       self::$test_ability_properties,
+                       array(
+                               'meta' => array(
+                                       'show_in_rest' => false,
+                               ),
+                       )
+               );
+               $ability = new WP_Ability( self::$test_ability_name, $args );
+
+               $this->assertFalse(
+                       $ability->get_meta_item( 'show_in_rest' ),
+                       '`show_in_rest` metadata should be false.'
+               );
+       }
+
+       /**
+        * Tests that invalid `show_in_rest` value throws an exception.
+        *
+        * @ticket 64098
+        */
+       public function test_show_in_rest_throws_exception() {
+               $args = array_merge(
+                       self::$test_ability_properties,
+                       array(
+                               'meta' => array(
+                                       'show_in_rest' => 5,
+                               ),
+                       )
+               );
+
+               $this->expectException( InvalidArgumentException::class );
+               $this->expectExceptionMessage( 'The ability meta should provide a valid `show_in_rest` boolean.' );
+
+               new WP_Ability( self::$test_ability_name, $args );
+       }
+
+       /**
+        * Data provider for testing the execution of the ability.
+        *
+        * @return array<string, array{0: array, 1: callable, 2: mixed, 3: mixed}> Data sets with different configurations.
+        */
+       public function data_execute_input() {
+               return array(
+                       'null input'    => array(
+                               array(
+                                       'type'        => array( 'null', 'integer' ),
+                                       'description' => 'The null or integer to convert to integer.',
+                                       'required'    => true,
+                               ),
+                               static function ( $input ): int {
+                                       return null === $input ? 0 : (int) $input;
+                               },
+                               null,
+                               0,
+                       ),
+                       'boolean input' => array(
+                               array(
+                                       'type'        => 'boolean',
+                                       'description' => 'The boolean to convert to integer.',
+                                       'required'    => true,
+                               ),
+                               static function ( bool $input ): int {
+                                       return $input ? 1 : 0;
+                               },
+                               true,
+                               1,
+                       ),
+                       'integer input' => array(
+                               array(
+                                       'type'        => 'integer',
+                                       'description' => 'The integer to add 5 to.',
+                                       'required'    => true,
+                               ),
+                               static function ( int $input ): int {
+                                       return 5 + $input;
+                               },
+                               2,
+                               7,
+                       ),
+                       'number input'  => array(
+                               array(
+                                       'type'        => 'number',
+                                       'description' => 'The floating number to round.',
+                                       'required'    => true,
+                               ),
+                               static function ( float $input ): int {
+                                       return (int) round( $input );
+                               },
+                               2.7,
+                               3,
+                       ),
+                       'string input'  => array(
+                               array(
+                                       'type'        => 'string',
+                                       'description' => 'The string to measure the length of.',
+                                       'required'    => true,
+                               ),
+                               static function ( string $input ): int {
+                                       return strlen( $input );
+                               },
+                               'Hello world!',
+                               12,
+                       ),
+                       'object input'  => array(
+                               array(
+                                       'type'                 => 'object',
+                                       'description'          => 'An object containing two numbers to add.',
+                                       'properties'           => array(
+                                               'a' => array(
+                                                       'type'        => 'integer',
+                                                       'description' => 'First number.',
+                                                       'required'    => true,
+                                               ),
+                                               'b' => array(
+                                                       'type'        => 'integer',
+                                                       'description' => 'Second number.',
+                                                       'required'    => true,
+                                               ),
+                                       ),
+                                       'additionalProperties' => false,
+                               ),
+                               static function ( array $input ): int {
+                                       return $input['a'] + $input['b'];
+                               },
+                               array(
+                                       'a' => 2,
+                                       'b' => 3,
+                               ),
+                               5,
+                       ),
+                       'array input'   => array(
+                               array(
+                                       'type'        => 'array',
+                                       'description' => 'An array containing two numbers to add.',
+                                       'required'    => true,
+                                       'minItems'    => 2,
+                                       'maxItems'    => 2,
+                                       'items'       => array(
+                                               'type' => 'integer',
+                                       ),
+                               ),
+                               static function ( array $input ): int {
+                                       return $input[0] + $input[1];
+                               },
+                               array( 2, 3 ),
+                               5,
+                       ),
+               );
+       }
+
+       /**
+        * Tests the execution of the ability.
+        *
+        * @ticket 64098
+        *
+        * @dataProvider data_execute_input
+        *
+        * @param array    $input_schema      The input schema for the ability.
+        * @param callable $execute_callback  The execute callback for the ability.
+        * @param mixed    $input             The input to pass to the execute method.
+        * @param mixed    $result            The expected result from the execute method.
+        */
+       public function test_execute_input( $input_schema, $execute_callback, $input, $result ) {
+               $args = array_merge(
+                       self::$test_ability_properties,
+                       array(
+                               'input_schema'     => $input_schema,
+                               'execute_callback' => $execute_callback,
+                       )
+               );
+
+               $ability = new WP_Ability( self::$test_ability_name, $args );
+
+               $this->assertSame( $result, $ability->execute( $input ) );
+       }
+
+       /**
+        * A static method to be used as a callback in tests.
+        *
+        * @param string $input An input string.
+        * @return int The length of the input string.
+        */
+       public static function my_static_execute_callback( string $input ): int {
+               return strlen( $input );
+       }
+
+       /**
+        * An instance method to be used as a callback in tests.
+        *
+        * @param string $input An input string.
+        * @return int The length of the input string.
+        */
+       public function my_instance_execute_callback( string $input ): int {
+               return strlen( $input );
+       }
+
+       /**
+        * Data provider for testing different types of execute callbacks.
+        *
+        * @return array<string, array{0: callable}> Data sets with different execute callbacks.
+        */
+       public function data_execute_callback() {
+               return array(
+                       'function name string'       => array(
+                               'strlen',
+                       ),
+                       'closure'                    => array(
+                               static function ( string $input ): int {
+                                       return strlen( $input );
+                               },
+                       ),
+                       'static class method string' => array(
+                               'Tests_Abilities_API_WpAbility::my_static_execute_callback',
+                       ),
+                       'static class method array'  => array(
+                               array( 'Tests_Abilities_API_WpAbility', 'my_static_execute_callback' ),
+                       ),
+                       'object method'              => array(
+                               array( $this, 'my_instance_execute_callback' ),
+                       ),
+               );
+       }
+
+       /**
+        * Tests the execution of the ability with different types of callbacks.
+        *
+        * @ticket 64098
+        *
+        * @dataProvider data_execute_callback
+        *
+        * @param callable $execute_callback The execute callback to test.
+        */
+       public function test_execute_with_different_callbacks( $execute_callback ) {
+               $args = array_merge(
+                       self::$test_ability_properties,
+                       array(
+                               'input_schema'     => array(
+                                       'type'        => 'string',
+                                       'description' => 'Test input string.',
+                                       'required'    => true,
+                               ),
+                               'execute_callback' => $execute_callback,
+                       )
+               );
+
+               $ability = new WP_Ability( self::$test_ability_name, $args );
+
+               $this->assertSame( 6, $ability->execute( 'hello!' ) );
+       }
+
+       /**
+        * Tests the execution of the ability with no input.
+        *
+        * @ticket 64098
+        */
+       public function test_execute_no_input() {
+               $args = array_merge(
+                       self::$test_ability_properties,
+                       array(
+                               'execute_callback' => static function (): int {
+                                       return 42;
+                               },
+                       )
+               );
+
+               $ability = new WP_Ability( self::$test_ability_name, $args );
+
+               $this->assertSame( 42, $ability->execute() );
+       }
+
+       /**
+        * Tests that before_execute_ability action is fired with correct parameters.
+        *
+        * @ticket 64098
+        */
+       public function test_before_execute_ability_action() {
+               $action_ability_name = null;
+               $action_input        = null;
+
+               $args = array_merge(
+                       self::$test_ability_properties,
+                       array(
+                               'input_schema'     => array(
+                                       'type'        => 'integer',
+                                       'description' => 'Test input parameter.',
+                                       'required'    => true,
+                               ),
+                               'execute_callback' => static function ( int $input ): int {
+                                       return $input * 2;
+                               },
+                       )
+               );
+
+               $callback = static function ( $ability_name, $input ) use ( &$action_ability_name, &$action_input ) {
+                       $action_ability_name = $ability_name;
+                       $action_input        = $input;
+               };
+
+               add_action( 'wp_before_execute_ability', $callback, 10, 2 );
+
+               $ability = new WP_Ability( self::$test_ability_name, $args );
+               $result  = $ability->execute( 5 );
+
+               remove_action( 'wp_before_execute_ability', $callback );
+
+               $this->assertSame( self::$test_ability_name, $action_ability_name, 'Action should receive correct ability name' );
+               $this->assertSame( 5, $action_input, 'Action should receive correct input' );
+               $this->assertSame( 10, $result, 'Ability should execute correctly' );
+       }
+
+       /**
+        * Tests that before_execute_ability action is fired with null input when no input schema is defined.
+        *
+        * @ticket 64098
+        */
+       public function test_before_execute_ability_action_no_input() {
+               $action_ability_name = null;
+               $action_input        = null;
+
+               $args = array_merge(
+                       self::$test_ability_properties,
+                       array(
+                               'execute_callback' => static function (): int {
+                                       return 42;
+                               },
+                       )
+               );
+
+               $callback = static function ( $ability_name, $input ) use ( &$action_ability_name, &$action_input ) {
+                       $action_ability_name = $ability_name;
+                       $action_input        = $input;
+               };
+
+               add_action( 'wp_before_execute_ability', $callback, 10, 2 );
+
+               $ability = new WP_Ability( self::$test_ability_name, $args );
+               $result  = $ability->execute();
+
+               remove_action( 'wp_before_execute_ability', $callback );
+
+               $this->assertSame( self::$test_ability_name, $action_ability_name, 'Action should receive correct ability name' );
+               $this->assertNull( $action_input, 'Action should receive null input when no input provided' );
+               $this->assertSame( 42, $result, 'Ability should execute correctly' );
+       }
+
+       /**
+        * Tests that after_execute_ability action is fired with correct parameters.
+        *
+        * @ticket 64098
+        */
+       public function test_after_execute_ability_action() {
+               $action_ability_name = null;
+               $action_input        = null;
+               $action_result       = null;
+
+               $args = array_merge(
+                       self::$test_ability_properties,
+                       array(
+                               'input_schema'     => array(
+                                       'type'        => 'integer',
+                                       'description' => 'Test input parameter.',
+                                       'required'    => true,
+                               ),
+                               'execute_callback' => static function ( int $input ): int {
+                                       return $input * 3;
+                               },
+                       )
+               );
+
+               $callback = static function ( $ability_name, $input, $result ) use ( &$action_ability_name, &$action_input, &$action_result ) {
+                       $action_ability_name = $ability_name;
+                       $action_input        = $input;
+                       $action_result       = $result;
+               };
+
+               add_action( 'wp_after_execute_ability', $callback, 10, 3 );
+
+               $ability = new WP_Ability( self::$test_ability_name, $args );
+               $result  = $ability->execute( 7 );
+
+               remove_action( 'wp_after_execute_ability', $callback );
+
+               $this->assertSame( self::$test_ability_name, $action_ability_name, 'Action should receive correct ability name' );
+               $this->assertSame( 7, $action_input, 'Action should receive correct input' );
+               $this->assertSame( 21, $action_result, 'Action should receive correct result' );
+               $this->assertSame( 21, $result, 'Ability should execute correctly' );
+       }
+
+       /**
+        * Tests that after_execute_ability action is fired with null input when no input schema is defined.
+        *
+        * @ticket 64098
+        */
+       public function test_after_execute_ability_action_no_input() {
+               $action_ability_name = null;
+               $action_input        = null;
+               $action_result       = null;
+
+               $args = array_merge(
+                       self::$test_ability_properties,
+                       array(
+                               'output_schema'    => array(),
+                               'execute_callback' => static function (): string {
+                                       return 'test-result';
+                               },
+                       )
+               );
+
+               $callback = static function ( $ability_name, $input, $result ) use ( &$action_ability_name, &$action_input, &$action_result ) {
+                       $action_ability_name = $ability_name;
+                       $action_input        = $input;
+                       $action_result       = $result;
+               };
+
+               add_action( 'wp_after_execute_ability', $callback, 10, 3 );
+
+               $ability = new WP_Ability( self::$test_ability_name, $args );
+               $result  = $ability->execute();
+
+               remove_action( 'wp_after_execute_ability', $callback );
+
+               $this->assertSame( self::$test_ability_name, $action_ability_name, 'Action should receive correct ability name' );
+               $this->assertNull( $action_input, 'Action should receive null input when no input provided' );
+               $this->assertSame( 'test-result', $action_result, 'Action should receive correct result' );
+               $this->assertSame( 'test-result', $result, 'Ability should execute correctly' );
+       }
+
+       /**
+        * Tests that neither action is fired when execution fails due to permission issues.
+        *
+        * @ticket 64098
+        */
+       public function test_actions_not_fired_on_permission_failure() {
+               $before_action_fired = false;
+               $after_action_fired  = false;
+
+               $args = array_merge(
+                       self::$test_ability_properties,
+                       array(
+                               'permission_callback' => static function (): bool {
+                                       return false;
+                               },
+                       )
+               );
+
+               $before_callback = static function () use ( &$before_action_fired ) {
+                       $before_action_fired = true;
+               };
+
+               $after_callback = static function () use ( &$after_action_fired ) {
+                       $after_action_fired = true;
+               };
+
+               add_action( 'wp_before_execute_ability', $before_callback );
+               add_action( 'wp_after_execute_ability', $after_callback );
+
+               $ability = new WP_Ability( self::$test_ability_name, $args );
+               $result  = $ability->execute();
+
+               remove_action( 'wp_before_execute_ability', $before_callback );
+               remove_action( 'wp_after_execute_ability', $after_callback );
+
+               $this->assertFalse( $before_action_fired, 'before_execute_ability action should not be fired on permission failure' );
+               $this->assertFalse( $after_action_fired, 'after_execute_ability action should not be fired on permission failure' );
+               $this->assertInstanceOf( WP_Error::class, $result, 'Should return WP_Error on permission failure' );
+       }
+
+       /**
+        * Tests that after_execute_ability action is not fired when execution callback returns WP_Error.
+        *
+        * @ticket 64098
+        */
+       public function test_after_action_not_fired_on_execution_error() {
+               $before_action_fired = false;
+               $after_action_fired  = false;
+
+               $args = array_merge(
+                       self::$test_ability_properties,
+                       array(
+                               'execute_callback' => static function () {
+                                       return new WP_Error( 'test_error', 'Test execution error' );
+                               },
+                       )
+               );
+
+               $before_callback = static function () use ( &$before_action_fired ) {
+                       $before_action_fired = true;
+               };
+
+               $after_callback = static function () use ( &$after_action_fired ) {
+                       $after_action_fired = true;
+               };
+
+               add_action( 'wp_before_execute_ability', $before_callback );
+               add_action( 'wp_after_execute_ability', $after_callback );
+
+               $ability = new WP_Ability( self::$test_ability_name, $args );
+               $result  = $ability->execute();
+
+               remove_action( 'wp_before_execute_ability', $before_callback );
+               remove_action( 'wp_after_execute_ability', $after_callback );
+
+               $this->assertTrue( $before_action_fired, 'before_execute_ability action should be fired even if execution fails' );
+               $this->assertFalse( $after_action_fired, 'after_execute_ability action should not be fired when execution returns WP_Error' );
+               $this->assertInstanceOf( WP_Error::class, $result, 'Should return WP_Error from execution callback' );
+       }
+
+       /**
+        * Tests that after_execute_ability action is not fired when output validation fails.
+        *
+        * @ticket 64098
+        */
+       public function test_after_action_not_fired_on_output_validation_error() {
+               $before_action_fired = false;
+               $after_action_fired  = false;
+
+               $args = array_merge(
+                       self::$test_ability_properties,
+                       array(
+                               'output_schema'    => array(
+                                       'type'        => 'string',
+                                       'description' => 'Expected string output.',
+                                       'required'    => true,
+                               ),
+                               'execute_callback' => static function (): int {
+                                       return 42;
+                               },
+                       )
+               );
+
+               $before_callback = static function () use ( &$before_action_fired ) {
+                       $before_action_fired = true;
+               };
+
+               $after_callback = static function () use ( &$after_action_fired ) {
+                       $after_action_fired = true;
+               };
+
+               add_action( 'wp_before_execute_ability', $before_callback );
+               add_action( 'wp_after_execute_ability', $after_callback );
+
+               $ability = new WP_Ability( self::$test_ability_name, $args );
+               $result  = $ability->execute();
+
+               remove_action( 'wp_before_execute_ability', $before_callback );
+               remove_action( 'wp_after_execute_ability', $after_callback );
+
+               $this->assertTrue( $before_action_fired, 'before_execute_ability action should be fired even if output validation fails' );
+               $this->assertFalse( $after_action_fired, 'after_execute_ability action should not be fired when output validation fails' );
+               $this->assertInstanceOf( WP_Error::class, $result, 'Should return WP_Error for output validation failure' );
+       }
+}
</ins><span class="cx" style="display: block; padding: 0 10px">Property changes on: trunk/tests/phpunit/tests/abilities-api/wpAbility.php
</span><span class="cx" style="display: block; padding: 0 10px">___________________________________________________________________
</span></span></pre></div>
<a id="svneolstyle"></a>
<div class="addfile"><h4 style="background-color: #eee; color: inherit; margin: 1em 0; padding: 1.3em; font-size: 115%">Added: svn:eol-style</h4></div>
<ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+native
</ins><span class="cx" style="display: block; padding: 0 10px">\ No newline at end of property
</span><a id="trunktestsphpunittestsabilitiesapiwpAbilityCategoryRegistryphp"></a>
<div class="addfile"><h4 style="background-color: #eee; color: inherit; margin: 1em 0; padding: 1.3em; font-size: 115%">Added: trunk/tests/phpunit/tests/abilities-api/wpAbilityCategoryRegistry.php</h4>
<pre class="diff"><span>
<span class="info" style="display: block; padding: 0 10px; color: #888">--- trunk/tests/phpunit/tests/abilities-api/wpAbilityCategoryRegistry.php                             (rev 0)
+++ trunk/tests/phpunit/tests/abilities-api/wpAbilityCategoryRegistry.php       2025-10-21 13:50:11 UTC (rev 61032)
</span><span class="lines" style="display: block; padding: 0 10px; color: #888">@@ -0,0 +1,718 @@
</span><ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+<?php declare( strict_types=1 );
+
+/**
+ * Tests for the ability category functionality.
+ *
+ * @covers WP_Ability_Category
+ * @covers WP_Ability_Categories_Registry
+ *
+ * @group abilities-api
+ */
+class Tests_Abilities_API_WpAbilityCategoryRegistry extends WP_UnitTestCase {
+
+       /**
+        * Category registry instance.
+        *
+        * @var WP_Ability_Categories_Registry
+        */
+       private $registry;
+
+       /**
+        * Captured `_doing_it_wrong` calls during a test.
+        *
+        * @var array<int,array{function:string,message:string,version:string}>
+        */
+       private $doing_it_wrong_log = array();
+
+       /**
+        * Set up before each test.
+        */
+       public function set_up(): void {
+               parent::set_up();
+
+               $this->registry           = new WP_Ability_Categories_Registry();
+               $this->doing_it_wrong_log = array();
+
+               add_action( 'doing_it_wrong_run', array( $this, 'record_doing_it_wrong' ), 10, 3 );
+       }
+
+       /**
+        * Tear down after each test.
+        */
+       public function tear_down(): void {
+               remove_action( 'doing_it_wrong_run', array( $this, 'record_doing_it_wrong' ) );
+               $this->doing_it_wrong_log = array();
+
+               $this->registry = null;
+
+               parent::tear_down();
+       }
+
+       /**
+        * Records `_doing_it_wrong` calls for later assertions.
+        *
+        * @param string $the_method Function name flagged by `_doing_it_wrong`.
+        * @param string $message  Message supplied to `_doing_it_wrong`.
+        * @param string $version  Version string supplied to `_doing_it_wrong`.
+        */
+       public function record_doing_it_wrong( string $the_method, string $message, string $version ): void {
+               $this->doing_it_wrong_log[] = array(
+                       'function' => $the_method,
+                       'message'  => $message,
+                       'version'  => $version,
+               );
+       }
+
+       /**
+        * Asserts that `_doing_it_wrong` was triggered for the expected function.
+        *
+        * @param string      $the_method         Function name expected to trigger `_doing_it_wrong`.
+        * @param string|null $message_contains Optional. String that should be contained in the error message.
+        */
+       private function assertDoingItWrongTriggered( string $the_method, ?string $message_contains = null ): void {
+               foreach ( $this->doing_it_wrong_log as $entry ) {
+                       if ( $the_method === $entry['function'] ) {
+                               // If message check is specified, verify it contains the expected text.
+                               if ( null !== $message_contains && false === strpos( $entry['message'], $message_contains ) ) {
+                                       continue;
+                               }
+                               return;
+                       }
+               }
+
+               if ( null !== $message_contains ) {
+                       $this->fail(
+                               sprintf(
+                                       'Failed asserting that _doing_it_wrong() was triggered for %s with message containing "%s".',
+                                       $the_method,
+                                       $message_contains
+                               )
+                       );
+               } else {
+                       $this->fail( sprintf( 'Failed asserting that _doing_it_wrong() was triggered for %s.', $the_method ) );
+               }
+       }
+
+       /**
+        * Test registering a valid category.
+        *
+        * @ticket 64098
+        */
+       public function test_register_valid_category(): void {
+               $result = $this->registry->register(
+                       'test-math',
+                       array(
+                               'label'       => 'Math',
+                               'description' => 'Mathematical operations.',
+                       )
+               );
+
+               $this->assertInstanceOf( WP_Ability_Category::class, $result );
+               $this->assertSame( 'test-math', $result->get_slug() );
+               $this->assertSame( 'Math', $result->get_label() );
+               $this->assertSame( 'Mathematical operations.', $result->get_description() );
+       }
+
+       /**
+        * Test registering category with invalid slug format.
+        *
+        * @ticket 64098
+        *
+        * @expectedIncorrectUsage WP_Ability_Categories_Registry::register
+        */
+       public function test_register_category_invalid_slug_format(): void {
+               // Uppercase characters not allowed.
+               $result = $this->registry->register(
+                       'Test-Math',
+                       array(
+                               'label'       => 'Math',
+                               'description' => 'Mathematical operations.',
+                       )
+               );
+
+               $this->assertNull( $result );
+               $this->assertDoingItWrongTriggered( 'WP_Ability_Categories_Registry::register', 'slug must contain only lowercase' );
+       }
+
+       /**
+        * Test registering category with invalid slug - underscore.
+        *
+        * @ticket 64098
+        *
+        * @expectedIncorrectUsage WP_Ability_Categories_Registry::register
+        */
+       public function test_register_category_invalid_slug_underscore(): void {
+               $result = $this->registry->register(
+                       'test_math',
+                       array(
+                               'label'       => 'Math',
+                               'description' => 'Mathematical operations.',
+                       )
+               );
+
+               $this->assertNull( $result );
+               $this->assertDoingItWrongTriggered( 'WP_Ability_Categories_Registry::register', 'slug must contain only lowercase' );
+       }
+
+       /**
+        * Test registering category without label.
+        *
+        * @ticket 64098
+        *
+        * @expectedIncorrectUsage WP_Ability_Categories_Registry::register
+        */
+       public function test_register_category_missing_label(): void {
+               $result = $this->registry->register(
+                       'test-math',
+                       array(
+                               'description' => 'Mathematical operations.',
+                       )
+               );
+
+               $this->assertNull( $result );
+               $this->assertDoingItWrongTriggered( 'WP_Ability_Categories_Registry::register' );
+       }
+
+       /**
+        * Test registering category without description.
+        *
+        * @ticket 64098
+        *
+        * @expectedIncorrectUsage WP_Ability_Categories_Registry::register
+        */
+       public function test_register_category_missing_description(): void {
+               $result = $this->registry->register(
+                       'test-math',
+                       array(
+                               'label' => 'Math',
+                       )
+               );
+
+               $this->assertNull( $result );
+               $this->assertDoingItWrongTriggered( 'WP_Ability_Categories_Registry::register' );
+       }
+
+       /**
+        * Test registering duplicate category.
+        *
+        * @ticket 64098
+        *
+        * @expectedIncorrectUsage WP_Ability_Categories_Registry::register
+        */
+       public function test_register_duplicate_category(): void {
+               $result = $this->registry->register(
+                       'test-math',
+                       array(
+                               'label'       => 'Math',
+                               'description' => 'Mathematical operations.',
+                       )
+               );
+
+               $this->assertInstanceOf( WP_Ability_Category::class, $result );
+
+               $result = $this->registry->register(
+                       'test-math',
+                       array(
+                               'label'       => 'Math 2',
+                               'description' => 'Another math category.',
+                       )
+               );
+
+               $this->assertNull( $result );
+               $this->assertDoingItWrongTriggered( 'WP_Ability_Categories_Registry::register', 'already registered' );
+       }
+
+       /**
+        * Test unregistering existing category.
+        *
+        * @ticket 64098
+        */
+       public function test_unregister_existing_category(): void {
+               $this->registry->register(
+                       'test-math',
+                       array(
+                               'label'       => 'Math',
+                               'description' => 'Mathematical operations.',
+                       )
+               );
+
+               $result = $this->registry->unregister( 'test-math' );
+
+               $this->assertInstanceOf( WP_Ability_Category::class, $result );
+               $this->assertFalse( $this->registry->is_registered( 'test-math' ) );
+       }
+
+       /**
+        * Test unregistering non-existent category.
+        *
+        * @ticket 64098
+        *
+        * @expectedIncorrectUsage WP_Ability_Categories_Registry::unregister
+        */
+       public function test_unregister_nonexistent_category(): void {
+               $result = $this->registry->unregister( 'test-nonexistent' );
+
+               $this->assertNull( $result );
+               $this->assertDoingItWrongTriggered( 'WP_Ability_Categories_Registry::unregister' );
+       }
+
+       /**
+        * Test retrieving existing category.
+        *
+        * @ticket 64098
+        */
+       public function test_get_existing_category(): void {
+               $this->registry->register(
+                       'test-math',
+                       array(
+                               'label'       => 'Math',
+                               'description' => 'Mathematical operations.',
+                       )
+               );
+
+               $result = $this->registry->get_registered( 'test-math' );
+
+               $this->assertInstanceOf( WP_Ability_Category::class, $result );
+               $this->assertSame( 'test-math', $result->get_slug() );
+       }
+
+       /**
+        * Test retrieving non-existent category.
+        *
+        * @ticket 64098
+        *
+        * @expectedIncorrectUsage WP_Ability_Categories_Registry::get_registered
+        */
+       public function test_get_nonexistent_category(): void {
+               $result = $this->registry->get_registered( 'test-nonexistent' );
+
+               $this->assertNull( $result );
+               $this->assertDoingItWrongTriggered( 'WP_Ability_Categories_Registry::get_registered' );
+       }
+
+       /**
+        * Tests checking if an ability category is registered.
+        *
+        * @ticket 64098
+        */
+       public function test_has_registered_ability_category(): void {
+               $category_slug = 'test-math';
+               $this->registry->register(
+                       $category_slug,
+                       array(
+                               'label'       => 'Math',
+                               'description' => 'Mathematical operations.',
+                       )
+               );
+
+               $result = $this->registry->is_registered( $category_slug );
+
+               $this->assertTrue( $result );
+       }
+
+       /**
+        * Tests checking if a non-existent ability category is registered.
+        *
+        * @ticket 64098
+        */
+       public function test_has_registered_nonexistent_ability_category(): void {
+               $result = $this->registry->is_registered( 'test/non-existent' );
+
+               $this->assertFalse( $result );
+       }
+
+       /**
+        * Test retrieving all registered categories.
+        *
+        * @ticket 64098
+        */
+       public function test_get_all_categories(): void {
+               $this->registry->register(
+                       'test-math',
+                       array(
+                               'label'       => 'Math',
+                               'description' => 'Mathematical operations.',
+                       )
+               );
+
+               $this->registry->register(
+                       'test-system',
+                       array(
+                               'label'       => 'System',
+                               'description' => 'System operations.',
+                       )
+               );
+
+               $categories = $this->registry->get_all_registered();
+
+               $this->assertIsArray( $categories );
+               $this->assertCount( 2, $categories );
+               $this->assertArrayHasKey( 'test-math', $categories );
+               $this->assertArrayHasKey( 'test-system', $categories );
+       }
+
+       /**
+        * Test category is_registered method.
+        *
+        * @ticket 64098
+        */
+       public function test_category_is_registered(): void {
+               $this->assertFalse( $this->registry->is_registered( 'test-math' ) );
+
+               $this->registry->register(
+                       'test-math',
+                       array(
+                               'label'       => 'Math',
+                               'description' => 'Mathematical operations.',
+                       )
+               );
+
+               $this->assertTrue( $this->registry->is_registered( 'test-math' ) );
+       }
+
+       /**
+        * Test category with special characters in label and description.
+        *
+        * @ticket 64098
+        */
+       public function test_category_with_special_characters(): void {
+               $result = $this->registry->register(
+                       'test-special',
+                       array(
+                               'label'       => 'Math & Science <tag>',
+                               'description' => 'Operations with "quotes" and \'apostrophes\'.',
+                       )
+               );
+
+               $this->assertInstanceOf( WP_Ability_Category::class, $result );
+               $this->assertSame( 'Math & Science <tag>', $result->get_label() );
+               $this->assertSame( 'Operations with "quotes" and \'apostrophes\'.', $result->get_description() );
+       }
+
+       /**
+        * Data provider for valid ability category slugs.
+        *
+        * @return array<int, array<string>> Valid ability category slugs.
+        */
+       public function data_valid_slug_provider(): array {
+               return array(
+                       array( 'test-simple' ),
+                       array( 'test-multiple-words' ),
+                       array( 'test-with-numbers-123' ),
+                       array( 'test-a' ),
+                       array( 'test-123' ),
+               );
+       }
+
+       /**
+        * Test category slug validation with valid formats.
+        *
+        * @ticket 64098
+        *
+        * @dataProvider data_valid_slug_provider
+        *
+        * @param string $slug The category slug to test.
+        */
+       public function test_category_slug_valid_formats( string $slug ): void {
+               $result = $this->registry->register(
+                       $slug,
+                       array(
+                               'label'       => 'Test',
+                               'description' => 'Test description.',
+                       )
+               );
+
+               $this->assertInstanceOf( WP_Ability_Category::class, $result, "Slug '{$slug}' should be valid" );
+       }
+
+       /**
+        * Data provider for invalid ability category slugs.
+        *
+        * @return array<int, array<string>> Invalid ability category slugs.
+        */
+       public function data_invalid_slug_provider(): array {
+               return array(
+                       array( 'Test-Uppercase' ),
+                       array( 'test_underscore' ),
+                       array( 'test.dot' ),
+                       array( 'test/slash' ),
+                       array( 'test space' ),
+                       array( '-test-start-dash' ),
+                       array( 'test-end-dash-' ),
+                       array( 'test--double-dash' ),
+               );
+       }
+
+       /**
+        * Test category slug validation with invalid formats.
+        *
+        * @ticket 64098
+        *
+        * @dataProvider data_invalid_slug_provider
+        * @expectedIncorrectUsage WP_Ability_Categories_Registry::register
+        *
+        * @param string $slug The category slug to test.
+        */
+       public function test_category_slug_invalid_formats( string $slug ): void {
+               $result = $this->registry->register(
+                       $slug,
+                       array(
+                               'label'       => 'Test',
+                               'description' => 'Test description.',
+                       )
+               );
+
+               $this->assertNull( $result, "Slug '{$slug}' should be invalid" );
+               $this->assertDoingItWrongTriggered( 'WP_Ability_Categories_Registry::register' );
+       }
+
+       /**
+        * Test registering category with non-string label.
+        *
+        * @ticket 64098
+        *
+        * @expectedIncorrectUsage WP_Ability_Categories_Registry::register
+        */
+       public function test_category_constructor_non_string_label(): void {
+               $result = $this->registry->register(
+                       'test-invalid',
+                       array(
+                               'label'       => 123, // Integer instead of string
+                               'description' => 'Valid description.',
+                       )
+               );
+
+               $this->assertNull( $result );
+               $this->assertDoingItWrongTriggered( 'WP_Ability_Categories_Registry::register' );
+       }
+
+       /**
+        * Test registering category with empty label.
+        *
+        * @ticket 64098
+        *
+        * @expectedIncorrectUsage WP_Ability_Categories_Registry::register
+        */
+       public function test_category_constructor_empty_label(): void {
+               $result = $this->registry->register(
+                       'test-invalid',
+                       array(
+                               'label'       => '',
+                               'description' => 'Valid description.',
+                       )
+               );
+
+               $this->assertNull( $result );
+               $this->assertDoingItWrongTriggered( 'WP_Ability_Categories_Registry::register' );
+       }
+
+       /**
+        * Test registering category with non-string description.
+        *
+        * @ticket 64098
+        *
+        * @expectedIncorrectUsage WP_Ability_Categories_Registry::register
+        */
+       public function test_category_constructor_non_string_description(): void {
+               $result = $this->registry->register(
+                       'test-invalid',
+                       array(
+                               'label'       => 'Valid Label',
+                               'description' => array( 'invalid' ), // Array instead of string
+                       )
+               );
+
+               $this->assertNull( $result );
+               $this->assertDoingItWrongTriggered( 'WP_Ability_Categories_Registry::register' );
+       }
+
+       /**
+        * Test registering category with empty description.
+        *
+        * @ticket 64098
+        *
+        * @expectedIncorrectUsage WP_Ability_Categories_Registry::register
+        */
+       public function test_category_constructor_empty_description(): void {
+               $result = $this->registry->register(
+                       'test-invalid',
+                       array(
+                               'label'       => 'Valid Label',
+                               'description' => '',
+                       )
+               );
+
+               $this->assertNull( $result );
+               $this->assertDoingItWrongTriggered( 'WP_Ability_Categories_Registry::register' );
+       }
+
+       /**
+        * Test register_ability_category_args filter.
+        *
+        * @ticket 64098
+        */
+       public function test_register_category_args_filter(): void {
+               add_filter(
+                       'wp_register_ability_category_args',
+                       static function ( $args, $slug ) {
+                               if ( 'test-filtered' === $slug ) {
+                                       $args['label']       = 'Filtered Label';
+                                       $args['description'] = 'Filtered Description';
+                               }
+                               return $args;
+                       },
+                       10,
+                       2
+               );
+
+               $result = $this->registry->register(
+                       'test-filtered',
+                       array(
+                               'label'       => 'Original Label',
+                               'description' => 'Original Description.',
+                       )
+               );
+
+               $this->assertInstanceOf( WP_Ability_Category::class, $result );
+               $this->assertSame( 'Filtered Label', $result->get_label() );
+               $this->assertSame( 'Filtered Description', $result->get_description() );
+       }
+
+       /**
+        * Test that WP_Ability_Category cannot be unserialized.
+        *
+        * @ticket 64098
+        */
+       public function test_category_wakeup_throws_exception(): void {
+               $category = $this->registry->register(
+                       'test-serialize',
+                       array(
+                               'label'       => 'Test',
+                               'description' => 'Test description.',
+                       )
+               );
+
+               $this->expectException( LogicException::class );
+               $serialized = serialize( $category );
+               unserialize( $serialized );
+       }
+
+       /**
+        * Test registering a category with valid meta.
+        *
+        * @ticket 64098
+        */
+       public function test_register_category_with_valid_meta(): void {
+               $meta = array(
+                       'icon'     => 'dashicons-calculator',
+                       'priority' => 10,
+                       'custom'   => array( 'key' => 'value' ),
+               );
+
+               $result = $this->registry->register(
+                       'test-meta',
+                       array(
+                               'label'       => 'Math',
+                               'description' => 'Mathematical operations.',
+                               'meta'        => $meta,
+                       )
+               );
+
+               $this->assertInstanceOf( WP_Ability_Category::class, $result );
+               $this->assertSame( 'test-meta', $result->get_slug() );
+               $this->assertSame( $meta, $result->get_meta() );
+       }
+
+       /**
+        * Test registering a category with empty meta array.
+        *
+        * @ticket 64098
+        */
+       public function test_register_category_with_empty_meta(): void {
+               $result = $this->registry->register(
+                       'test-empty-meta',
+                       array(
+                               'label'       => 'Math',
+                               'description' => 'Mathematical operations.',
+                               'meta'        => array(),
+                       )
+               );
+
+               $this->assertInstanceOf( WP_Ability_Category::class, $result );
+               $this->assertSame( array(), $result->get_meta() );
+       }
+
+       /**
+        * Test registering a category without meta returns empty array.
+        *
+        * @ticket 64098
+        */
+       public function test_register_category_without_meta_returns_empty_array(): void {
+               $result = $this->registry->register(
+                       'test-no-meta',
+                       array(
+                               'label'       => 'Math',
+                               'description' => 'Mathematical operations.',
+                       )
+               );
+
+               $this->assertInstanceOf( WP_Ability_Category::class, $result );
+               $this->assertSame( array(), $result->get_meta() );
+       }
+
+       /**
+        * Test registering a category with invalid meta (non-array).
+        *
+        * @ticket 64098
+        *
+        * @expectedIncorrectUsage WP_Ability_Categories_Registry::register
+        */
+       public function test_register_category_with_invalid_meta(): void {
+               $result = $this->registry->register(
+                       'test-invalid-meta',
+                       array(
+                               'label'       => 'Math',
+                               'description' => 'Mathematical operations.',
+                               'meta'        => 'invalid-string',
+                       )
+               );
+
+               $this->assertNull( $result );
+               $this->assertDoingItWrongTriggered( 'WP_Ability_Categories_Registry::register', 'valid `meta` array' );
+       }
+
+       /**
+        * Test registering a category with unknown property triggers _doing_it_wrong.
+        *
+        * @ticket 64098
+        *
+        * @expectedIncorrectUsage WP_Ability_Category::__construct
+        */
+       public function test_register_category_with_unknown_property(): void {
+               $result = $this->registry->register(
+                       'test-unknown-property',
+                       array(
+                               'label'            => 'Math',
+                               'description'      => 'Mathematical operations.',
+                               'unknown_property' => 'some value',
+                       )
+               );
+
+               // Category should still be created.
+               $this->assertInstanceOf( WP_Ability_Category::class, $result );
+               // But _doing_it_wrong should be triggered.
+               $this->assertDoingItWrongTriggered( 'WP_Ability_Category::__construct', 'not a valid property' );
+       }
+
+       /**
+        * Test category registry singleton.
+        *
+        * @ticket 64098
+        */
+       public function test_category_registry_singleton(): void {
+               $instance1 = WP_Ability_Categories_Registry::get_instance();
+               $instance2 = WP_Ability_Categories_Registry::get_instance();
+
+               $this->assertSame( $instance1, $instance2 );
+       }
+}
</ins><span class="cx" style="display: block; padding: 0 10px">Property changes on: trunk/tests/phpunit/tests/abilities-api/wpAbilityCategoryRegistry.php
</span><span class="cx" style="display: block; padding: 0 10px">___________________________________________________________________
</span></span></pre></div>
<a id="svneolstyle"></a>
<div class="addfile"><h4 style="background-color: #eee; color: inherit; margin: 1em 0; padding: 1.3em; font-size: 115%">Added: svn:eol-style</h4></div>
<ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+native
</ins><span class="cx" style="display: block; padding: 0 10px">\ No newline at end of property
</span><a id="trunktestsphpunittestsabilitiesapiwpRegisterAbilityphp"></a>
<div class="addfile"><h4 style="background-color: #eee; color: inherit; margin: 1em 0; padding: 1.3em; font-size: 115%">Added: trunk/tests/phpunit/tests/abilities-api/wpRegisterAbility.php</h4>
<pre class="diff"><span>
<span class="info" style="display: block; padding: 0 10px; color: #888">--- trunk/tests/phpunit/tests/abilities-api/wpRegisterAbility.php                             (rev 0)
+++ trunk/tests/phpunit/tests/abilities-api/wpRegisterAbility.php       2025-10-21 13:50:11 UTC (rev 61032)
</span><span class="lines" style="display: block; padding: 0 10px; color: #888">@@ -0,0 +1,652 @@
</span><ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+<?php declare( strict_types=1 );
+
+/**
+ * Mock used to test a custom ability class.
+ */
+class Mock_Custom_Ability extends WP_Ability {
+       protected function do_execute( $input = null ) {
+               return 9999;
+       }
+}
+
+/**
+ * Tests for registering, unregistering and retrieving abilities.
+ *
+ * @covers wp_register_ability
+ * @covers wp_unregister_ability
+ * @covers wp_get_ability
+ * @covers wp_has_ability
+ * @covers wp_get_all_abilities
+ *
+ * @group abilities-api
+ */
+class Test_Abilities_API_WpRegisterAbility extends WP_UnitTestCase {
+
+       public static $test_ability_name = 'test/add-numbers';
+       public static $test_ability_args = array();
+
+       /**
+        * Set up before each test.
+        */
+       public function set_up(): void {
+               parent::set_up();
+
+               // Fire the init hook to allow test ability category registration.
+               do_action( 'wp_abilities_api_categories_init' );
+               wp_register_ability_category(
+                       'math',
+                       array(
+                               'label'       => 'Math',
+                               'description' => 'Mathematical operations and calculations.',
+                       )
+               );
+
+               self::$test_ability_args = array(
+                       'label'               => 'Add numbers',
+                       'description'         => 'Calculates the result of adding two numbers.',
+                       'category'            => 'math',
+                       'input_schema'        => array(
+                               'type'                 => 'object',
+                               'properties'           => array(
+                                       'a' => array(
+                                               'type'        => 'number',
+                                               'description' => 'First number.',
+                                               'required'    => true,
+                                       ),
+                                       'b' => array(
+                                               'type'        => 'number',
+                                               'description' => 'Second number.',
+                                               'required'    => true,
+                                       ),
+                               ),
+                               'additionalProperties' => false,
+                       ),
+                       'output_schema'       => array(
+                               'type'        => 'number',
+                               'description' => 'The result of adding the two numbers.',
+                               'required'    => true,
+                       ),
+                       'execute_callback'    => static function ( array $input ): int {
+                               return $input['a'] + $input['b'];
+                       },
+                       'permission_callback' => static function (): bool {
+                               return true;
+                       },
+                       'meta'                => array(
+                               'annotations'  => array(
+                                       'readonly'    => true,
+                                       'destructive' => false,
+                               ),
+                               'show_in_rest' => true,
+                       ),
+               );
+       }
+
+       /**
+        * Tear down after each test.
+        */
+       public function tear_down(): void {
+               foreach ( wp_get_abilities() as $ability ) {
+                       if ( ! str_starts_with( $ability->get_name(), 'test/' ) ) {
+                               continue;
+                       }
+
+                       wp_unregister_ability( $ability->get_name() );
+               }
+
+               // Clean up registered test ability category.
+               wp_unregister_ability_category( 'math' );
+
+               parent::tear_down();
+       }
+
+       /**
+        * Tests registering an ability with invalid name.
+        *
+        * @ticket 64098
+        *
+        * @expectedIncorrectUsage WP_Abilities_Registry::register
+        */
+       public function test_register_ability_invalid_name(): void {
+               do_action( 'wp_abilities_api_init' );
+
+               $result = wp_register_ability( 'invalid_name', array() );
+
+               $this->assertNull( $result );
+       }
+
+       /**
+        * Tests registering an ability when `abilities_api_init` action has not fired.
+        *
+        * @ticket 64098
+        *
+        * @expectedIncorrectUsage wp_register_ability
+        */
+       public function test_register_ability_no_abilities_api_init_action(): void {
+               global $wp_actions;
+
+               // Store the original action count.
+               $original_count = isset( $wp_actions['wp_abilities_api_init'] ) ? $wp_actions['wp_abilities_api_init'] : 0;
+
+               // Reset the action count to simulate it not being fired.
+               unset( $wp_actions['wp_abilities_api_init'] );
+
+               $result = wp_register_ability( self::$test_ability_name, self::$test_ability_args );
+
+               // Restore the original action count.
+               if ( $original_count > 0 ) {
+                       $wp_actions['wp_abilities_api_init'] = $original_count;
+               }
+
+               $this->assertNull( $result );
+       }
+
+       /**
+        * Tests registering an ability when `init` action has not fired.
+        *
+        * @ticket 64098
+        *
+        * @expectedIncorrectUsage WP_Abilities_Registry::get_instance
+        */
+       public function test_register_ability_no_init_action(): void {
+               global $wp_actions;
+
+               do_action( 'wp_abilities_api_init' );
+
+               // Store the original action count.
+               $original_count = isset( $wp_actions['init'] ) ? $wp_actions['init'] : 0;
+
+               // Reset the action count to simulate it not being fired.
+               unset( $wp_actions['init'] );
+
+               $result = wp_register_ability( self::$test_ability_name, self::$test_ability_args );
+
+               // Restore the original action count.
+               if ( $original_count > 0 ) {
+                       $wp_actions['init'] = $original_count;
+               }
+
+               $this->assertNull( $result );
+       }
+
+       /**
+        * Tests registering a valid ability.
+        *
+        * @ticket 64098
+        */
+       public function test_register_valid_ability(): void {
+               do_action( 'wp_abilities_api_init' );
+
+               $result = wp_register_ability( self::$test_ability_name, self::$test_ability_args );
+
+               $expected_annotations = array_merge(
+                       self::$test_ability_args['meta']['annotations'],
+                       array(
+                               'idempotent' => false,
+                       )
+               );
+               $expected_meta        = array_merge(
+                       self::$test_ability_args['meta'],
+                       array(
+                               'annotations'  => $expected_annotations,
+                               'show_in_rest' => true,
+                       )
+               );
+
+               $this->assertInstanceOf( WP_Ability::class, $result );
+               $this->assertSame( self::$test_ability_name, $result->get_name() );
+               $this->assertSame( self::$test_ability_args['label'], $result->get_label() );
+               $this->assertSame( self::$test_ability_args['description'], $result->get_description() );
+               $this->assertSame( self::$test_ability_args['input_schema'], $result->get_input_schema() );
+               $this->assertSame( self::$test_ability_args['output_schema'], $result->get_output_schema() );
+               $this->assertEquals( $expected_meta, $result->get_meta() );
+               $this->assertTrue(
+                       $result->check_permissions(
+                               array(
+                                       'a' => 2,
+                                       'b' => 3,
+                               )
+                       )
+               );
+               $this->assertSame(
+                       5,
+                       $result->execute(
+                               array(
+                                       'a' => 2,
+                                       'b' => 3,
+                               )
+                       )
+               );
+       }
+
+       /**
+        * Tests executing an ability with no permissions.
+        *
+        * @ticket 64098
+        */
+       public function test_register_ability_no_permissions(): void {
+               do_action( 'wp_abilities_api_init' );
+
+               self::$test_ability_args['permission_callback'] = static function (): bool {
+                       return false;
+               };
+               $result = wp_register_ability( self::$test_ability_name, self::$test_ability_args );
+
+               $this->assertFalse(
+                       $result->check_permissions(
+                               array(
+                                       'a' => 2,
+                                       'b' => 3,
+                               )
+                       )
+               );
+
+               $actual = $result->execute(
+                       array(
+                               'a' => 2,
+                               'b' => 3,
+                       )
+               );
+               $this->assertWPError(
+                       $actual,
+                       'Execution should fail due to no permissions'
+               );
+               $this->assertEquals( 'ability_invalid_permissions', $actual->get_error_code() );
+       }
+
+       /**
+        * Tests registering an ability with a custom ability class.
+        *
+        * @ticket 64098
+        */
+       public function test_register_ability_custom_ability_class(): void {
+               do_action( 'wp_abilities_api_init' );
+
+               $result = wp_register_ability(
+                       self::$test_ability_name,
+                       array_merge(
+                               self::$test_ability_args,
+                               array(
+                                       'ability_class' => Mock_Custom_Ability::class,
+                               )
+                       )
+               );
+
+               $this->assertInstanceOf( Mock_Custom_Ability::class, $result );
+               $this->assertSame(
+                       9999,
+                       $result->execute(
+                               array(
+                                       'a' => 2,
+                                       'b' => 3,
+                               )
+                       )
+               );
+
+               // Try again with an invalid class throws a doing it wrong.
+               $this->setExpectedIncorrectUsage( WP_Abilities_Registry::class . '::register' );
+               wp_register_ability(
+                       self::$test_ability_name,
+                       array_merge(
+                               self::$test_ability_args,
+                               array(
+                                       'ability_class' => 'Non_Existent_Class',
+                               )
+                       )
+               );
+       }
+
+       /**
+        * Tests executing an ability with input not matching schema.
+        *
+        * @ticket 64098
+        */
+       public function test_execute_ability_no_input_schema_match(): void {
+               do_action( 'wp_abilities_api_init' );
+
+               $result = wp_register_ability( self::$test_ability_name, self::$test_ability_args );
+
+               $actual = $result->execute(
+                       array(
+                               'a'       => 2,
+                               'b'       => 3,
+                               'unknown' => 1,
+                       )
+               );
+
+               $this->assertWPError(
+                       $actual,
+                       'Execution should fail due to input not matching schema.'
+               );
+               $this->assertSame( 'ability_invalid_input', $actual->get_error_code() );
+               $this->assertSame(
+                       'Ability "test/add-numbers" has invalid input. Reason: unknown is not a valid property of Object.',
+                       $actual->get_error_message()
+               );
+       }
+
+       /**
+        * Tests executing an ability with output not matching schema.
+        *
+        * @ticket 64098
+        */
+       public function test_execute_ability_no_output_schema_match(): void {
+               do_action( 'wp_abilities_api_init' );
+
+               self::$test_ability_args['execute_callback'] = static function (): bool {
+                       return true;
+               };
+
+               $result = wp_register_ability( self::$test_ability_name, self::$test_ability_args );
+
+               $actual = $result->execute(
+                       array(
+                               'a' => 2,
+                               'b' => 3,
+                       )
+               );
+               $this->assertWPError(
+                       $actual,
+                       'Execution should fail due to output not matching schema.'
+               );
+               $this->assertSame( 'ability_invalid_output', $actual->get_error_code() );
+               $this->assertSame(
+                       'Ability "test/add-numbers" has invalid output. Reason: output is not of type number.',
+                       $actual->get_error_message()
+               );
+       }
+
+       /**
+        * Tests input validation failing due to schema mismatch.
+        *
+        * @ticket 64098
+        */
+       public function test_validate_input_no_input_schema_match(): void {
+               do_action( 'wp_abilities_api_init' );
+
+               $result = wp_register_ability( self::$test_ability_name, self::$test_ability_args );
+
+               $actual = $result->validate_input(
+                       array(
+                               'a'       => 2,
+                               'b'       => 3,
+                               'unknown' => 1,
+                       )
+               );
+
+               $this->assertWPError(
+                       $actual,
+                       'Input validation should fail due to input not matching schema.'
+               );
+               $this->assertSame( 'ability_invalid_input', $actual->get_error_code() );
+               $this->assertSame(
+                       'Ability "test/add-numbers" has invalid input. Reason: unknown is not a valid property of Object.',
+                       $actual->get_error_message()
+               );
+       }
+
+       /**
+        * Tests permission callback receiving input for contextual permission checks.
+        *
+        * @ticket 64098
+        */
+       public function test_permission_callback_receives_input(): void {
+               do_action( 'wp_abilities_api_init' );
+
+               $received_input                                 = null;
+               self::$test_ability_args['permission_callback'] = static function ( array $input ) use ( &$received_input ): bool {
+                       $received_input = $input;
+                       // Allow only if 'a' is greater than 'b'
+                       return $input['a'] > $input['b'];
+               };
+
+               $result = wp_register_ability( self::$test_ability_name, self::$test_ability_args );
+
+               // Test with a > b (should be allowed)
+               $this->assertTrue(
+                       $result->check_permissions(
+                               array(
+                                       'a' => 5,
+                                       'b' => 3,
+                               )
+                       )
+               );
+               $this->assertSame(
+                       array(
+                               'a' => 5,
+                               'b' => 3,
+                       ),
+                       $received_input
+               );
+
+               // Test with a < b (should be denied)
+               $this->assertFalse(
+                       $result->check_permissions(
+                               array(
+                                       'a' => 2,
+                                       'b' => 8,
+                               )
+                       )
+               );
+               $this->assertSame(
+                       array(
+                               'a' => 2,
+                               'b' => 8,
+                       ),
+                       $received_input
+               );
+       }
+
+       /**
+        * Tests unregistering an ability when `init` action has not fired.
+        *
+        * @ticket 64098
+        *
+        * @expectedIncorrectUsage WP_Abilities_Registry::get_instance
+        */
+       public function test_unregister_ability_no_init_action(): void {
+               global $wp_actions;
+
+               // Store the original action count.
+               $original_count = isset( $wp_actions['init'] ) ? $wp_actions['init'] : 0;
+
+               // Reset the action count to simulate it not being fired.
+               unset( $wp_actions['init'] );
+
+               $result = wp_unregister_ability( self::$test_ability_name );
+
+               // Restore the original action count.
+               if ( $original_count > 0 ) {
+                       $wp_actions['init'] = $original_count;
+               }
+
+               $this->assertNull( $result );
+       }
+
+       /**
+        * Tests unregistering existing ability.
+        *
+        * @ticket 64098
+        */
+       public function test_unregister_existing_ability() {
+               do_action( 'wp_abilities_api_init' );
+
+               wp_register_ability( self::$test_ability_name, self::$test_ability_args );
+
+               $result = wp_unregister_ability( self::$test_ability_name );
+
+               $this->assertEquals(
+                       new WP_Ability( self::$test_ability_name, self::$test_ability_args ),
+                       $result
+               );
+       }
+
+       /**
+        * Tests retrieving an ability when `init` action has not fired.
+        *
+        * @ticket 64098
+        *
+        * @expectedIncorrectUsage WP_Abilities_Registry::get_instance
+        */
+       public function test_get_ability_no_init_action(): void {
+               global $wp_actions;
+
+               // Store the original action count.
+               $original_count = isset( $wp_actions['init'] ) ? $wp_actions['init'] : 0;
+
+               // Reset the action count to simulate it not being fired.
+               unset( $wp_actions['init'] );
+
+               $result = wp_get_ability( self::$test_ability_name );
+
+               // Restore the original action count.
+               if ( $original_count > 0 ) {
+                       $wp_actions['init'] = $original_count;
+               }
+
+               $this->assertNull( $result );
+       }
+
+       /**
+        * Tests retrieving existing ability registered with the `wp_abilities_api_init` callback.
+        *
+        * @ticket 64098
+        */
+       public function test_get_existing_ability_using_callback() {
+               $name     = self::$test_ability_name;
+               $args     = self::$test_ability_args;
+               $callback = static function ( $instance ) use ( $name, $args ) {
+                       wp_register_ability( $name, $args );
+               };
+
+               add_action( 'wp_abilities_api_init', $callback );
+
+               // Reset the Registry, to ensure it's empty before the test.
+               $registry_reflection = new ReflectionClass( WP_Abilities_Registry::class );
+               $instance_prop       = $registry_reflection->getProperty( 'instance' );
+               if ( PHP_VERSION_ID < 80100 ) {
+                       $instance_prop->setAccessible( true );
+               }
+               $instance_prop->setValue( null, null );
+
+               $result = wp_get_ability( $name );
+
+               remove_action( 'wp_abilities_api_init', $callback );
+
+               $this->assertEquals(
+                       new WP_Ability( $name, $args ),
+                       $result,
+                       'Ability does not share expected properties.'
+               );
+       }
+
+       /**
+        * Tests checking if an ability is registered when `init` action has not fired.
+        *
+        * @ticket 64098
+        *
+        * @expectedIncorrectUsage WP_Abilities_Registry::get_instance
+        */
+       public function test_has_ability_no_init_action(): void {
+               global $wp_actions;
+
+               // Store the original action count.
+               $original_count = isset( $wp_actions['init'] ) ? $wp_actions['init'] : 0;
+
+               // Reset the action count to simulate it not being fired.
+               unset( $wp_actions['init'] );
+
+               $result = wp_has_ability( self::$test_ability_name );
+
+               // Restore the original action count.
+               if ( $original_count > 0 ) {
+                       $wp_actions['init'] = $original_count;
+               }
+
+               $this->assertFalse( $result );
+       }
+
+       /**
+        * Tests checking if an ability is registered.
+        *
+        * @ticket 64098
+        */
+       public function test_has_registered_ability() {
+               do_action( 'wp_abilities_api_init' );
+
+               wp_register_ability( self::$test_ability_name, self::$test_ability_args );
+
+               $result = wp_has_ability( self::$test_ability_name );
+
+               $this->assertTrue( $result );
+       }
+
+       /**
+        * Tests checking if a non-existent ability is registered.
+        *
+        * @ticket 64098
+        */
+       public function test_has_registered_nonexistent_ability() {
+               do_action( 'wp_abilities_api_init' );
+
+               $result = wp_has_ability( 'test/non-existent' );
+
+               $this->assertFalse( $result );
+       }
+
+       /**
+        * Tests retrieving all registered abilities when `init` action has not fired.
+        *
+        * @ticket 64098
+        *
+        * @expectedIncorrectUsage WP_Abilities_Registry::get_instance
+        */
+       public function test_get_abilities_no_init_action(): void {
+               global $wp_actions;
+
+               // Store the original action count.
+               $original_count = isset( $wp_actions['init'] ) ? $wp_actions['init'] : 0;
+
+               // Reset the action count to simulate it not being fired.
+               unset( $wp_actions['init'] );
+
+               $result = wp_get_abilities();
+
+               // Restore the original action count.
+               if ( $original_count > 0 ) {
+                       $wp_actions['init'] = $original_count;
+               }
+
+               $this->assertSame( array(), $result );
+       }
+
+       /**
+        * Tests retrieving all registered abilities.
+        *
+        * @ticket 64098
+        */
+       public function test_get_all_registered_abilities() {
+               do_action( 'wp_abilities_api_init' );
+
+               $ability_one_name = 'test/ability-one';
+               $ability_one_args = self::$test_ability_args;
+               wp_register_ability( $ability_one_name, $ability_one_args );
+
+               $ability_two_name = 'test/ability-two';
+               $ability_two_args = self::$test_ability_args;
+               wp_register_ability( $ability_two_name, $ability_two_args );
+
+               $ability_three_name = 'test/ability-three';
+               $ability_three_args = self::$test_ability_args;
+               wp_register_ability( $ability_three_name, $ability_three_args );
+
+               $expected = array(
+                       $ability_one_name   => new WP_Ability( $ability_one_name, $ability_one_args ),
+                       $ability_two_name   => new WP_Ability( $ability_two_name, $ability_two_args ),
+                       $ability_three_name => new WP_Ability( $ability_three_name, $ability_three_args ),
+               );
+
+               $result = wp_get_abilities();
+               $this->assertEquals( $expected, $result );
+       }
+}
</ins><span class="cx" style="display: block; padding: 0 10px">Property changes on: trunk/tests/phpunit/tests/abilities-api/wpRegisterAbility.php
</span><span class="cx" style="display: block; padding: 0 10px">___________________________________________________________________
</span></span></pre></div>
<a id="svneolstyle"></a>
<div class="addfile"><h4 style="background-color: #eee; color: inherit; margin: 1em 0; padding: 1.3em; font-size: 115%">Added: svn:eol-style</h4></div>
<ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+native
</ins><span class="cx" style="display: block; padding: 0 10px">\ No newline at end of property
</span><a id="trunktestsphpunittestsabilitiesapiwpRegisterAbilityCategoryphp"></a>
<div class="addfile"><h4 style="background-color: #eee; color: inherit; margin: 1em 0; padding: 1.3em; font-size: 115%">Added: trunk/tests/phpunit/tests/abilities-api/wpRegisterAbilityCategory.php</h4>
<pre class="diff"><span>
<span class="info" style="display: block; padding: 0 10px; color: #888">--- trunk/tests/phpunit/tests/abilities-api/wpRegisterAbilityCategory.php                             (rev 0)
+++ trunk/tests/phpunit/tests/abilities-api/wpRegisterAbilityCategory.php       2025-10-21 13:50:11 UTC (rev 61032)
</span><span class="lines" style="display: block; padding: 0 10px; color: #888">@@ -0,0 +1,365 @@
</span><ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+<?php declare( strict_types=1 );
+
+/**
+ * Tests for the ability category registry functionality.
+ *
+ * @covers wp_register_ability_category
+ * @covers wp_unregister_ability_category
+ * @covers wp_has_ability_category
+ * @covers wp_get_ability_category
+ * @covers wp_get_ability_categories
+ *
+ * @group abilities-api
+ */
+class Tests_Abilities_API_WpRegisterAbilityCategory extends WP_UnitTestCase {
+
+       public static $test_ability_category_name = 'test-math';
+       public static $test_ability_category_args = array();
+
+       /**
+        * Set up before each test.
+        */
+       public function set_up(): void {
+               parent::set_up();
+
+               self::$test_ability_category_args = array(
+                       'label'       => 'Math',
+                       'description' => 'Mathematical operations.',
+               );
+       }
+
+       /**
+        * Tear down after each test.
+        */
+       public function tear_down(): void {
+               // Clean up any test ability categories registered during tests.
+               foreach ( wp_get_ability_categories() as $ability_category ) {
+                       if ( ! str_starts_with( $ability_category->get_slug(), 'test-' ) ) {
+                               continue;
+                       }
+
+                       wp_unregister_ability_category( $ability_category->get_slug() );
+               }
+
+               parent::tear_down();
+       }
+
+       /**
+        * Test registering ability category before `abilities_api_categories_init` hook.
+        *
+        * @ticket 64098
+        *
+        * @expectedIncorrectUsage wp_register_ability_category
+        */
+       public function test_register_category_before_init_hook(): void {
+               $result = wp_register_ability_category(
+                       self::$test_ability_category_name,
+                       self::$test_ability_category_args
+               );
+
+               $this->assertNull( $result );
+       }
+
+       /**
+        * Tests registering an ability category when `init` action has not fired.
+        *
+        * @ticket 64098
+        *
+        * @expectedIncorrectUsage WP_Ability_Categories_Registry::get_instance
+        */
+       public function test_register_ability_category_no_init_action(): void {
+               global $wp_actions;
+
+               do_action( 'wp_abilities_api_categories_init' );
+
+               // Store the original action count.
+               $original_count = isset( $wp_actions['init'] ) ? $wp_actions['init'] : 0;
+
+               // Reset the action count to simulate it not being fired.
+               unset( $wp_actions['init'] );
+
+               $result = wp_register_ability_category(
+                       self::$test_ability_category_name,
+                       self::$test_ability_category_args
+               );
+
+               // Restore the original action count.
+               if ( $original_count > 0 ) {
+                       $wp_actions['init'] = $original_count;
+               }
+
+               $this->assertNull( $result );
+       }
+
+       /**
+        * Test registering a valid ability category.
+        *
+        * @ticket 64098
+        */
+       public function test_register_valid_category(): void {
+               do_action( 'wp_abilities_api_categories_init' );
+
+               $result = wp_register_ability_category(
+                       self::$test_ability_category_name,
+                       self::$test_ability_category_args
+               );
+
+               $this->assertInstanceOf( WP_Ability_Category::class, $result );
+               $this->assertSame( self::$test_ability_category_name, $result->get_slug() );
+               $this->assertSame( 'Math', $result->get_label() );
+               $this->assertSame( 'Mathematical operations.', $result->get_description() );
+       }
+
+       /**
+        * Tests unregistering an ability category when `init` action has not fired.
+        *
+        * @ticket 64098
+        *
+        * @expectedIncorrectUsage WP_Ability_Categories_Registry::get_instance
+        */
+       public function test_unregister_ability_category_no_init_action(): void {
+               global $wp_actions;
+
+               do_action( 'wp_abilities_api_categories_init' );
+
+               // Store the original action count.
+               $original_count = isset( $wp_actions['init'] ) ? $wp_actions['init'] : 0;
+
+               // Reset the action count to simulate it not being fired.
+               unset( $wp_actions['init'] );
+
+               $result = wp_unregister_ability_category( self::$test_ability_category_name );
+
+               // Restore the original action count.
+               if ( $original_count > 0 ) {
+                       $wp_actions['init'] = $original_count;
+               }
+
+               $this->assertNull( $result );
+       }
+
+       /**
+        * Test unregistering non-existent ability category.
+        *
+        * @ticket 64098
+        *
+        * @expectedIncorrectUsage WP_Ability_Categories_Registry::unregister
+        */
+       public function test_unregister_nonexistent_category(): void {
+               do_action( 'wp_abilities_api_categories_init' );
+
+               $result = wp_unregister_ability_category( 'test-nonexistent' );
+
+               $this->assertNull( $result );
+       }
+
+       /**
+        * Test unregistering existing ability category.
+        *
+        * @ticket 64098
+        */
+       public function test_unregister_existing_category(): void {
+               do_action( 'wp_abilities_api_categories_init' );
+
+               wp_register_ability_category(
+                       self::$test_ability_category_name,
+                       self::$test_ability_category_args
+               );
+
+               $result = wp_unregister_ability_category( self::$test_ability_category_name );
+
+               $this->assertInstanceOf( WP_Ability_Category::class, $result );
+               $this->assertFalse( wp_has_ability_category( self::$test_ability_category_name ) );
+       }
+
+       /**
+        * Tests checking if an ability category is registered when `init` action has not fired.
+        *
+        * @ticket 64098
+        *
+        * @expectedIncorrectUsage WP_Ability_Categories_Registry::get_instance
+        */
+       public function test_has_ability_category_no_init_action(): void {
+               global $wp_actions;
+
+               do_action( 'wp_abilities_api_categories_init' );
+
+               // Store the original action count.
+               $original_count = isset( $wp_actions['init'] ) ? $wp_actions['init'] : 0;
+
+               // Reset the action count to simulate it not being fired.
+               unset( $wp_actions['init'] );
+
+               $result = wp_has_ability_category( self::$test_ability_category_name );
+
+               // Restore the original action count.
+               if ( $original_count > 0 ) {
+                       $wp_actions['init'] = $original_count;
+               }
+
+               $this->assertFalse( $result );
+       }
+
+       /**
+        * Tests checking if a non-existent ability category is registered.
+        *
+        * @ticket 64098
+        */
+       public function test_has_registered_nonexistent_ability_category(): void {
+               do_action( 'wp_abilities_api_categories_init' );
+
+               $result = wp_has_ability_category( 'test/non-existent' );
+
+               $this->assertFalse( $result );
+       }
+
+       /**
+        * Tests checking if an ability category is registered.
+        *
+        * @ticket 64098
+        */
+       public function test_has_registered_ability_category(): void {
+               do_action( 'wp_abilities_api_categories_init' );
+
+               $category_slug = self::$test_ability_category_name;
+
+               wp_register_ability_category(
+                       $category_slug,
+                       self::$test_ability_category_args
+               );
+
+               $result = wp_has_ability_category( $category_slug );
+
+               $this->assertTrue( $result );
+       }
+
+       /**
+        * Tests retrieving an ability category when `init` action has not fired.
+        *
+        * @ticket 64098
+        *
+        * @expectedIncorrectUsage WP_Ability_Categories_Registry::get_instance
+        */
+       public function test_get_ability_category_no_init_action(): void {
+               global $wp_actions;
+
+               do_action( 'wp_abilities_api_categories_init' );
+
+               // Store the original action count.
+               $original_count = isset( $wp_actions['init'] ) ? $wp_actions['init'] : 0;
+
+               // Reset the action count to simulate it not being fired.
+               unset( $wp_actions['init'] );
+
+               $result = wp_get_ability_category( self::$test_ability_category_name );
+
+               // Restore the original action count.
+               if ( $original_count > 0 ) {
+                       $wp_actions['init'] = $original_count;
+               }
+
+               $this->assertNull( $result );
+       }
+
+       /**
+        * Test retrieving non-existent ability category.
+        *
+        * @ticket 64098
+        *
+        * @expectedIncorrectUsage WP_Ability_Categories_Registry::get_registered
+        */
+       public function test_get_nonexistent_category(): void {
+               do_action( 'wp_abilities_api_categories_init' );
+
+               $result = wp_get_ability_category( 'test-nonexistent' );
+
+               $this->assertNull( $result );
+       }
+
+       /**
+        * Test retrieving existing ability category registered with the `wp_abilities_api_categories_init` callback.
+        *
+        * @ticket 64098
+        */
+       public function test_get_existing_category_using_callback(): void {
+               $name     = self::$test_ability_category_name;
+               $args     = self::$test_ability_category_args;
+               $callback = static function ( $instance ) use ( $name, $args ) {
+                       wp_register_ability_category( $name, $args );
+               };
+
+               add_action( 'wp_abilities_api_categories_init', $callback );
+
+               // Reset the Registry, to ensure it's empty before the test.
+               $registry_reflection = new ReflectionClass( WP_Ability_Categories_Registry::class );
+               $instance_prop       = $registry_reflection->getProperty( 'instance' );
+               if ( PHP_VERSION_ID < 80100 ) {
+                       $instance_prop->setAccessible( true );
+               }
+               $instance_prop->setValue( null, null );
+
+               $result = wp_get_ability_category( $name );
+
+               remove_action( 'wp_abilities_api_categories_init', $callback );
+
+               $this->assertInstanceOf( WP_Ability_Category::class, $result );
+               $this->assertSame( self::$test_ability_category_name, $result->get_slug() );
+       }
+
+       /**
+        * Test retrieving all registered ability categories when `init` action has not fired.
+        *
+        * @ticket 64098
+        *
+        * @expectedIncorrectUsage WP_Ability_Categories_Registry::get_instance
+        */
+       public function test_get_ability_categories_no_init_action(): void {
+               global $wp_actions;
+
+               do_action( 'wp_abilities_api_categories_init' );
+
+               // Store the original action count.
+               $original_count = isset( $wp_actions['init'] ) ? $wp_actions['init'] : 0;
+
+               // Reset the action count to simulate it not being fired.
+               unset( $wp_actions['init'] );
+
+               $result = wp_get_ability_categories( self::$test_ability_category_name );
+
+               // Restore the original action count.
+               if ( $original_count > 0 ) {
+                       $wp_actions['init'] = $original_count;
+               }
+
+               $this->assertSame( array(), $result );
+       }
+
+       /**
+        * Test retrieving all registered ability categories.
+        *
+        * @ticket 64098
+        */
+       public function test_get_all_categories(): void {
+               do_action( 'wp_abilities_api_categories_init' );
+
+               wp_register_ability_category(
+                       self::$test_ability_category_name,
+                       self::$test_ability_category_args
+               );
+
+               wp_register_ability_category(
+                       'test-system',
+                       array(
+                               'label'       => 'System',
+                               'description' => 'System operations.',
+                       )
+               );
+
+               $categories = wp_get_ability_categories();
+
+               $this->assertIsArray( $categories );
+               $this->assertCount( 2, $categories );
+               $this->assertArrayHasKey( self::$test_ability_category_name, $categories );
+               $this->assertArrayHasKey( 'test-system', $categories );
+       }
+}
</ins><span class="cx" style="display: block; padding: 0 10px">Property changes on: trunk/tests/phpunit/tests/abilities-api/wpRegisterAbilityCategory.php
</span><span class="cx" style="display: block; padding: 0 10px">___________________________________________________________________
</span></span></pre></div>
<a id="svneolstyle"></a>
<div class="addfile"><h4 style="background-color: #eee; color: inherit; margin: 1em 0; padding: 1.3em; font-size: 115%">Added: svn:eol-style</h4></div>
<ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+native
</ins><span class="cx" style="display: block; padding: 0 10px">\ No newline at end of property
</span><a id="trunktestsphpunittestsrestapirestschemasetupphp"></a>
<div class="modfile"><h4 style="background-color: #eee; color: inherit; margin: 1em 0; padding: 1.3em; font-size: 115%">Modified: trunk/tests/phpunit/tests/rest-api/rest-schema-setup.php</h4>
<pre class="diff"><span>
<span class="info" style="display: block; padding: 0 10px; color: #888">--- trunk/tests/phpunit/tests/rest-api/rest-schema-setup.php  2025-10-21 13:47:20 UTC (rev 61031)
+++ trunk/tests/phpunit/tests/rest-api/rest-schema-setup.php    2025-10-21 13:50:11 UTC (rev 61032)
</span><span class="lines" style="display: block; padding: 0 10px; color: #888">@@ -203,6 +203,10 @@
</span><span class="cx" style="display: block; padding: 0 10px">                        '/wp/v2/font-families/(?P<font_family_id>[\d]+)/font-faces',
</span><span class="cx" style="display: block; padding: 0 10px">                        '/wp/v2/font-families/(?P<font_family_id>[\d]+)/font-faces/(?P<id>[\d]+)',
</span><span class="cx" style="display: block; padding: 0 10px">                        '/wp/v2/font-families/(?P<id>[\d]+)',
</span><ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+                        '/wp-abilities/v1',
+                       '/wp-abilities/v1/abilities/(?P<name>[a-zA-Z0-9\-\/]+?)/run',
+                       '/wp-abilities/v1/abilities/(?P<name>[a-zA-Z0-9\-\/]+)',
+                       '/wp-abilities/v1/abilities',
</ins><span class="cx" style="display: block; padding: 0 10px">                 );
</span><span class="cx" style="display: block; padding: 0 10px"> 
</span><span class="cx" style="display: block; padding: 0 10px">                $this->assertSameSets( $expected_routes, $routes );
</span><span class="lines" style="display: block; padding: 0 10px; color: #888">@@ -213,7 +217,8 @@
</span><span class="cx" style="display: block; padding: 0 10px">                        '/' === $route ||
</span><span class="cx" style="display: block; padding: 0 10px">                        preg_match( '#^/oembed/1\.0(/.+)?$#', $route ) ||
</span><span class="cx" style="display: block; padding: 0 10px">                        preg_match( '#^/wp/v2(/.+)?$#', $route ) ||
</span><del style="background-color: #fdd; text-decoration:none; display:block; padding: 0 10px">-                        preg_match( '#^/wp-site-health/v1(/.+)?$#', $route )
</del><ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+                 preg_match( '#^/wp-site-health/v1(/.+)?$#', $route ) ||
+                       preg_match( '#^/wp-abilities/v1(/.+)?$#', $route )
</ins><span class="cx" style="display: block; padding: 0 10px">                 );
</span><span class="cx" style="display: block; padding: 0 10px">        }
</span><span class="cx" style="display: block; padding: 0 10px"> 
</span></span></pre></div>
<a id="trunktestsphpunittestsrestapiwpRestAbilitiesV1ListControllerphp"></a>
<div class="addfile"><h4 style="background-color: #eee; color: inherit; margin: 1em 0; padding: 1.3em; font-size: 115%">Added: trunk/tests/phpunit/tests/rest-api/wpRestAbilitiesV1ListController.php</h4>
<pre class="diff"><span>
<span class="info" style="display: block; padding: 0 10px; color: #888">--- trunk/tests/phpunit/tests/rest-api/wpRestAbilitiesV1ListController.php                            (rev 0)
+++ trunk/tests/phpunit/tests/rest-api/wpRestAbilitiesV1ListController.php      2025-10-21 13:50:11 UTC (rev 61032)
</span><span class="lines" style="display: block; padding: 0 10px; color: #888">@@ -0,0 +1,761 @@
</span><ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+<?php declare( strict_types=1 );
+
+/**
+ * Tests for the REST list controller for abilities endpoint.
+ *
+ * @covers WP_REST_Abilities_V1_List_Controller
+ *
+ * @group abilities-api
+ * @group rest-api
+ */
+class Tests_REST_API_WpRestAbilitiesV1ListController extends WP_UnitTestCase {
+
+       /**
+        * REST Server instance.
+        *
+        * @var WP_REST_Server
+        */
+       protected $server;
+
+       /**
+        * Test user ID.
+        *
+        * @var int
+        */
+       protected static $user_id;
+
+       /**
+        * Set up before class.
+        */
+       public static function set_up_before_class(): void {
+               parent::set_up_before_class();
+
+               // Create a test user with read capabilities
+               self::$user_id = self::factory()->user->create(
+                       array(
+                               'role' => 'subscriber',
+                       )
+               );
+
+               // Fire the init hook to allow test ability categories registration.
+               do_action( 'wp_abilities_api_categories_init' );
+               self::register_test_categories();
+       }
+
+       /**
+        * Tear down after class.
+        */
+       public static function tear_down_after_class(): void {
+               // Clean up registered test ability categories.
+               foreach ( array( 'math', 'system', 'general' ) as $slug ) {
+                       wp_unregister_ability_category( $slug );
+               }
+
+               parent::tear_down_after_class();
+       }
+
+       /**
+        * Set up before each test.
+        */
+       public function set_up(): void {
+               parent::set_up();
+
+               // Set up REST server
+               global $wp_rest_server;
+               $wp_rest_server = new WP_REST_Server();
+               $this->server   = $wp_rest_server;
+
+               do_action( 'rest_api_init' );
+
+               // Initialize Abilities API.
+               do_action( 'wp_abilities_api_init' );
+               $this->register_test_abilities();
+
+               // Set default user for tests
+               wp_set_current_user( self::$user_id );
+       }
+
+       /**
+        * Tear down after each test.
+        */
+       public function tear_down(): void {
+               // Clean up test abilities.
+               foreach ( wp_get_abilities() as $ability ) {
+                       if ( ! str_starts_with( $ability->get_name(), 'test/' ) ) {
+                               continue;
+                       }
+
+                       wp_unregister_ability( $ability->get_name() );
+               }
+
+               // Reset REST server
+               global $wp_rest_server;
+               $wp_rest_server = null;
+
+               parent::tear_down();
+       }
+
+       /**
+        * Register test categories for testing.
+        */
+       public static function register_test_categories(): void {
+               wp_register_ability_category(
+                       'math',
+                       array(
+                               'label'       => 'Math',
+                               'description' => 'Mathematical operations and calculations.',
+                       )
+               );
+
+               wp_register_ability_category(
+                       'system',
+                       array(
+                               'label'       => 'System',
+                               'description' => 'System information and operations.',
+                       )
+               );
+
+               wp_register_ability_category(
+                       'general',
+                       array(
+                               'label'       => 'General',
+                               'description' => 'General purpose abilities.',
+                       )
+               );
+       }
+
+       /**
+        * Register test abilities for testing.
+        */
+       private function register_test_abilities(): void {
+               // Register a regular ability.
+               wp_register_ability(
+                       'test/calculator',
+                       array(
+                               'label'               => 'Calculator',
+                               'description'         => 'Performs basic calculations',
+                               'category'            => 'math',
+                               'input_schema'        => array(
+                                       'type'       => 'object',
+                                       'properties' => array(
+                                               'operation' => array(
+                                                       'type' => 'string',
+                                                       'enum' => array( 'add', 'subtract', 'multiply', 'divide' ),
+                                               ),
+                                               'a'         => array( 'type' => 'number' ),
+                                               'b'         => array( 'type' => 'number' ),
+                                       ),
+                               ),
+                               'output_schema'       => array(
+                                       'type' => 'number',
+                               ),
+                               'execute_callback'    => static function ( array $input ) {
+                                       switch ( $input['operation'] ) {
+                                               case 'add':
+                                                       return $input['a'] + $input['b'];
+                                               case 'subtract':
+                                                       return $input['a'] - $input['b'];
+                                               case 'multiply':
+                                                       return $input['a'] * $input['b'];
+                                               case 'divide':
+                                                       return 0 !== $input['b'] ? $input['a'] / $input['b'] : null;
+                                               default:
+                                                       return null;
+                                       }
+                               },
+                               'permission_callback' => static function () {
+                                       return current_user_can( 'read' );
+                               },
+                               'meta'                => array(
+                                       'show_in_rest' => true,
+                               ),
+                       )
+               );
+
+               // Register a read-only ability.
+               wp_register_ability(
+                       'test/system-info',
+                       array(
+                               'label'               => 'System Info',
+                               'description'         => 'Returns system information',
+                               'category'            => 'system',
+                               'input_schema'        => array(
+                                       'type'       => 'object',
+                                       'properties' => array(
+                                               'detail_level' => array(
+                                                       'type'    => 'string',
+                                                       'enum'    => array( 'basic', 'full' ),
+                                                       'default' => 'basic',
+                                               ),
+                                       ),
+                               ),
+                               'output_schema'       => array(
+                                       'type'       => 'object',
+                                       'properties' => array(
+                                               'php_version' => array( 'type' => 'string' ),
+                                               'wp_version'  => array( 'type' => 'string' ),
+                                       ),
+                               ),
+                               'execute_callback'    => static function ( array $input ) {
+                                       $info = array(
+                                               'php_version' => phpversion(),
+                                               'wp_version'  => get_bloginfo( 'version' ),
+                                       );
+                                       if ( 'full' === ( $input['detail_level'] ?? 'basic' ) ) {
+                                               $info['memory_limit'] = ini_get( 'memory_limit' );
+                                       }
+                                       return $info;
+                               },
+                               'permission_callback' => static function () {
+                                       return current_user_can( 'read' );
+                               },
+                               'meta'                => array(
+                                       'annotations'  => array(
+                                               'readonly' => true,
+                                       ),
+                                       'category'     => 'system',
+                                       'show_in_rest' => true,
+                               ),
+                       )
+               );
+
+               // Ability that does not show in REST.
+               wp_register_ability(
+                       'test/not-show-in-rest',
+                       array(
+                               'label'               => 'Hidden from REST',
+                               'description'         => 'It does not show in REST.',
+                               'category'            => 'general',
+                               'execute_callback'    => static function (): int {
+                                       return 0;
+                               },
+                               'permission_callback' => '__return_true',
+                       )
+               );
+
+               // Register multiple abilities for pagination testing
+               for ( $i = 1; $i <= 60; $i++ ) {
+                       wp_register_ability(
+                               "test/ability-{$i}",
+                               array(
+                                       'label'               => "Test Ability {$i}",
+                                       'description'         => "Test ability number {$i}",
+                                       'category'            => 'general',
+                                       'execute_callback'    => static function () use ( $i ) {
+                                               return "Result from ability {$i}";
+                                       },
+                                       'permission_callback' => '__return_true',
+                                       'meta'                => array(
+                                               'show_in_rest' => true,
+                                       ),
+                               )
+                       );
+               }
+       }
+
+       /**
+        * Test listing all abilities.
+        *
+        * @ticket 64098
+        */
+       public function test_get_items(): void {
+               $request  = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities' );
+               $response = $this->server->dispatch( $request );
+
+               $this->assertEquals( 200, $response->get_status() );
+
+               $data = $response->get_data();
+               $this->assertIsArray( $data );
+               $this->assertNotEmpty( $data );
+
+               $this->assertCount( 50, $data, 'First page should return exactly 50 items (default per_page)' );
+
+               $ability_names = wp_list_pluck( $data, 'name' );
+               $this->assertContains( 'test/calculator', $ability_names );
+               $this->assertContains( 'test/system-info', $ability_names );
+               $this->assertNotContains( 'test/not-show-in-rest', $ability_names );
+       }
+
+       /**
+        * Test getting a specific ability.
+        *
+        * @ticket 64098
+        */
+       public function test_get_item(): void {
+               $request  = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/test/calculator' );
+               $response = $this->server->dispatch( $request );
+
+               $this->assertEquals( 200, $response->get_status() );
+
+               $data = $response->get_data();
+               $this->assertCount( 7, $data, 'Response should contain all fields.' );
+               $this->assertEquals( 'test/calculator', $data['name'] );
+               $this->assertEquals( 'Calculator', $data['label'] );
+               $this->assertEquals( 'Performs basic calculations', $data['description'] );
+               $this->assertEquals( 'math', $data['category'] );
+               $this->assertArrayHasKey( 'input_schema', $data );
+               $this->assertArrayHasKey( 'output_schema', $data );
+               $this->assertArrayHasKey( 'meta', $data );
+               $this->assertTrue( $data['meta']['show_in_rest'] );
+       }
+
+       /**
+        * Test getting a specific ability with only selected fields.
+        *
+        * @ticket 64098
+        */
+       public function test_get_item_with_selected_fields(): void {
+               $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/test/calculator' );
+               $request->set_param( '_fields', 'name,label' );
+               $response = $this->server->dispatch( $request );
+               add_filter( 'rest_post_dispatch', 'rest_filter_response_fields', 10, 3 );
+               $response = apply_filters( 'rest_post_dispatch', $response, $this->server, $request );
+               remove_filter( 'rest_post_dispatch', 'rest_filter_response_fields', 10 );
+
+               $this->assertEquals( 200, $response->get_status() );
+
+               $data = $response->get_data();
+               $this->assertCount( 2, $data, 'Response should only contain the requested fields.' );
+               $this->assertEquals( 'test/calculator', $data['name'] );
+               $this->assertEquals( 'Calculator', $data['label'] );
+       }
+
+       /**
+        * Test getting a specific ability with embed context.
+        *
+        * @ticket 64098
+        */
+       public function test_get_item_with_embed_context(): void {
+               $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/test/calculator' );
+               $request->set_param( 'context', 'embed' );
+               $response = $this->server->dispatch( $request );
+               add_filter( 'rest_post_dispatch', 'rest_filter_response_fields', 10, 3 );
+               $response = apply_filters( 'rest_post_dispatch', $response, $this->server, $request );
+               remove_filter( 'rest_post_dispatch', 'rest_filter_response_fields', 10 );
+
+               $this->assertEquals( 200, $response->get_status() );
+
+               $data = $response->get_data();
+               $this->assertCount( 3, $data, 'Response should only contain the fields for embed context.' );
+               $this->assertEquals( 'test/calculator', $data['name'] );
+               $this->assertEquals( 'Calculator', $data['label'] );
+               $this->assertEquals( 'math', $data['category'] );
+       }
+
+       /**
+        * Test getting a non-existent ability returns 404.
+        *
+        * @ticket 64098
+        *
+        * @expectedIncorrectUsage WP_Abilities_Registry::get_registered
+        */
+       public function test_get_item_not_found(): void {
+               $request  = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/non/existent' );
+               $response = $this->server->dispatch( $request );
+
+               $this->assertEquals( 404, $response->get_status() );
+
+               $data = $response->get_data();
+               $this->assertEquals( 'rest_ability_not_found', $data['code'] );
+       }
+
+       /**
+        * Test getting an ability that does not show in REST returns 404.
+        *
+        * @ticket 64098
+        */
+       public function test_get_item_not_show_in_rest(): void {
+               $request  = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/test/not-show-in-rest' );
+               $response = $this->server->dispatch( $request );
+
+               $this->assertEquals( 404, $response->get_status() );
+
+               $data = $response->get_data();
+               $this->assertEquals( 'rest_ability_not_found', $data['code'] );
+       }
+
+       /**
+        * Test permission check for listing abilities.
+        *
+        * @ticket 64098
+        */
+       public function test_get_items_permission_denied(): void {
+               // Test with non-logged-in user
+               wp_set_current_user( 0 );
+
+               $request  = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities' );
+               $response = $this->server->dispatch( $request );
+
+               $this->assertEquals( 401, $response->get_status() );
+       }
+
+       /**
+        * Test pagination headers.
+        *
+        * @ticket 64098
+        */
+       public function test_pagination_headers(): void {
+               $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities' );
+               $request->set_param( 'per_page', 10 );
+               $response = $this->server->dispatch( $request );
+
+               $this->assertEquals( 200, $response->get_status() );
+
+               $headers = $response->get_headers();
+               $this->assertArrayHasKey( 'X-WP-Total', $headers );
+               $this->assertArrayHasKey( 'X-WP-TotalPages', $headers );
+
+               $total_abilities = count( wp_get_abilities() ) - 1; // Exclude the one that doesn't show in REST.
+               $this->assertEquals( $total_abilities, (int) $headers['X-WP-Total'] );
+               $this->assertEquals( ceil( $total_abilities / 10 ), (int) $headers['X-WP-TotalPages'] );
+       }
+
+       /**
+        * Test HEAD method returns empty body with proper headers.
+        *
+        * @ticket 64098
+        */
+       public function test_head_request(): void {
+               $request  = new WP_REST_Request( 'HEAD', '/wp-abilities/v1/abilities' );
+               $response = $this->server->dispatch( $request );
+
+               // Verify empty response body
+               $data = $response->get_data();
+               $this->assertEmpty( $data );
+
+               // Verify pagination headers are present
+               $headers = $response->get_headers();
+               $this->assertArrayHasKey( 'X-WP-Total', $headers );
+               $this->assertArrayHasKey( 'X-WP-TotalPages', $headers );
+       }
+
+       /**
+        * Test pagination links.
+        *
+        * @ticket 64098
+        */
+       public function test_pagination_links(): void {
+               // Test first page (should have 'next' link header but no 'prev')
+               $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities' );
+               $request->set_param( 'per_page', 10 );
+               $request->set_param( 'page', 1 );
+               $response = $this->server->dispatch( $request );
+
+               $headers     = $response->get_headers();
+               $link_header = $headers['Link'] ?? '';
+
+               // Parse Link header for rel="next" and rel="prev"
+               $this->assertStringContainsString( 'rel="next"', $link_header );
+               $this->assertStringNotContainsString( 'rel="prev"', $link_header );
+
+               // Test middle page (should have both 'next' and 'prev' link headers)
+               $request->set_param( 'page', 3 );
+               $response = $this->server->dispatch( $request );
+
+               $headers     = $response->get_headers();
+               $link_header = $headers['Link'] ?? '';
+
+               $this->assertStringContainsString( 'rel="next"', $link_header );
+               $this->assertStringContainsString( 'rel="prev"', $link_header );
+
+               // Test last page (should have 'prev' link header but no 'next')
+               $total_abilities = count( wp_get_abilities() );
+               $last_page       = ceil( $total_abilities / 10 );
+               $request->set_param( 'page', $last_page );
+               $response = $this->server->dispatch( $request );
+
+               $headers     = $response->get_headers();
+               $link_header = $headers['Link'] ?? '';
+
+               $this->assertStringNotContainsString( 'rel="next"', $link_header );
+               $this->assertStringContainsString( 'rel="prev"', $link_header );
+       }
+
+       /**
+        * Test collection parameters.
+        *
+        * @ticket 64098
+        */
+       public function test_collection_params(): void {
+               // Test per_page parameter
+               $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities' );
+               $request->set_param( 'per_page', 5 );
+               $response = $this->server->dispatch( $request );
+
+               $data = $response->get_data();
+               $this->assertCount( 5, $data );
+
+               // Test page parameter
+               $request->set_param( 'page', 2 );
+               $response = $this->server->dispatch( $request );
+
+               $this->assertEquals( 200, $response->get_status() );
+               $data = $response->get_data();
+               $this->assertCount( 5, $data );
+
+               // Verify we got different abilities on page 2
+               $page1_request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities' );
+               $page1_request->set_param( 'per_page', 5 );
+               $page1_request->set_param( 'page', 1 );
+               $page1_response = $this->server->dispatch( $page1_request );
+               $page1_names    = wp_list_pluck( $page1_response->get_data(), 'name' );
+               $page2_names    = wp_list_pluck( $data, 'name' );
+
+               $this->assertNotEquals( $page1_names, $page2_names );
+       }
+
+       /**
+        * Test response links for individual abilities.
+        *
+        * @ticket 64098
+        */
+       public function test_ability_response_links(): void {
+               $request  = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/test/calculator' );
+               $response = $this->server->dispatch( $request );
+
+               $links = $response->get_links();
+               $this->assertArrayHasKey( 'self', $links );
+               $this->assertArrayHasKey( 'collection', $links );
+               $this->assertArrayHasKey( 'wp:action-run', $links );
+
+               // Verify link URLs
+               $self_link = $links['self'][0]['href'];
+               $this->assertStringContainsString( '/wp-abilities/v1/abilities/test/calculator', $self_link );
+
+               $collection_link = $links['collection'][0]['href'];
+               $this->assertStringContainsString( '/wp-abilities/v1/abilities', $collection_link );
+
+               $run_link = $links['wp:action-run'][0]['href'];
+               $this->assertStringContainsString( '/wp-abilities/v1/abilities/test/calculator/run', $run_link );
+       }
+
+       /**
+        * Test context parameter.
+        *
+        * @ticket 64098
+        */
+       public function test_context_parameter(): void {
+               $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/test/calculator' );
+               $request->set_param( 'context', 'view' );
+               $response = $this->server->dispatch( $request );
+
+               $data = $response->get_data();
+               $this->assertArrayHasKey( 'description', $data );
+
+               $request->set_param( 'context', 'embed' );
+               $response = $this->server->dispatch( $request );
+
+               $data = $response->get_data();
+               $this->assertArrayHasKey( 'name', $data );
+               $this->assertArrayHasKey( 'label', $data );
+       }
+
+       /**
+        * Test schema retrieval.
+        *
+        * @ticket 64098
+        */
+       public function test_get_schema(): void {
+               $request  = new WP_REST_Request( 'OPTIONS', '/wp-abilities/v1/abilities' );
+               $response = $this->server->dispatch( $request );
+               $data     = $response->get_data();
+
+               $this->assertArrayHasKey( 'schema', $data );
+               $schema = $data['schema'];
+
+               $this->assertEquals( 'ability', $schema['title'] );
+               $this->assertEquals( 'object', $schema['type'] );
+               $this->assertArrayHasKey( 'properties', $schema );
+
+               $properties = $schema['properties'];
+
+               // Assert the count of properties to catch when new keys are added
+               $this->assertCount( 7, $properties, 'Schema should have exactly 7 properties. If this fails, update this test to include the new property.' );
+
+               // Check all expected properties exist
+               $this->assertArrayHasKey( 'name', $properties );
+               $this->assertArrayHasKey( 'label', $properties );
+               $this->assertArrayHasKey( 'description', $properties );
+               $this->assertArrayHasKey( 'input_schema', $properties );
+               $this->assertArrayHasKey( 'output_schema', $properties );
+               $this->assertArrayHasKey( 'meta', $properties );
+               $this->assertArrayHasKey( 'category', $properties );
+       }
+
+       /**
+        * Test ability name with valid special characters.
+        *
+        * @ticket 64098
+        */
+       public function test_ability_name_with_valid_special_characters(): void {
+               // Register ability with hyphen (valid).
+               wp_register_ability(
+                       'test-hyphen/ability',
+                       array(
+                               'label'               => 'Test Hyphen Ability',
+                               'description'         => 'Test ability with hyphen',
+                               'category'            => 'general',
+                               'execute_callback'    => static function ( $input ) {
+                                       return array( 'success' => true );
+                               },
+                               'permission_callback' => '__return_true',
+                               'meta'                => array(
+                                       'show_in_rest' => true,
+                               ),
+                       )
+               );
+
+               // Test valid special characters (hyphen, forward slash)
+               $request  = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/test-hyphen/ability' );
+               $response = $this->server->dispatch( $request );
+
+               wp_unregister_ability( 'test-hyphen/ability' );
+
+               $this->assertEquals( 200, $response->get_status() );
+       }
+
+       /**
+        * Data provider for invalid ability names.
+        *
+        * @return array<string, array{0: string}>
+        */
+       public function data_invalid_ability_names_provider(): array {
+               return array(
+                       '@ symbol'          => array( 'test@ability' ),
+                       'space'             => array( 'test ability' ),
+                       'dot'               => array( 'test.ability' ),
+                       'hash'              => array( 'test#ability' ),
+                       'URL encoded space' => array( 'test%20ability' ),
+                       'angle brackets'    => array( 'test<ability>' ),
+                       'pipe'              => array( 'test|ability' ),
+                       'backslash'         => array( 'test\\ability' ),
+               );
+       }
+
+       /**
+        * Test ability names with invalid special characters.
+        *
+        * @ticket 64098
+        *
+        * @dataProvider data_invalid_ability_names_provider
+        *
+        * @param string $name Invalid ability name to test.
+        */
+       public function test_ability_name_with_invalid_special_characters( string $name ): void {
+               $request  = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/' . $name );
+               $response = $this->server->dispatch( $request );
+               // Should return 404 as the regex pattern won't match
+               $this->assertEquals( 404, $response->get_status() );
+       }
+
+       /**
+        * Test extremely long ability names.
+        *
+        * @ticket 64098
+        *
+        * @expectedIncorrectUsage WP_Abilities_Registry::get_registered
+        */
+       public function test_extremely_long_ability_names(): void {
+               // Create a very long but valid ability name
+               $long_name = 'test/' . str_repeat( 'a', 1000 );
+
+               $request  = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/' . $long_name );
+               $response = $this->server->dispatch( $request );
+
+               // Should return 404 as ability doesn't exist
+               $this->assertEquals( 404, $response->get_status() );
+       }
+
+       /**
+        * Data provider for invalid pagination parameters.
+        *
+        * @return array<string, array{0: array<string, mixed>}>
+        */
+       public function data_invalid_pagination_params_provider(): array {
+               return array(
+                       'Zero page'            => array( array( 'page' => 0 ) ),
+                       'Negative page'        => array( array( 'page' => -1 ) ),
+                       'Non-numeric page'     => array( array( 'page' => 'abc' ) ),
+                       'Zero per page'        => array( array( 'per_page' => 0 ) ),
+                       'Negative per page'    => array( array( 'per_page' => -10 ) ),
+                       'Exceeds maximum'      => array( array( 'per_page' => 1000 ) ),
+                       'Non-numeric per page' => array( array( 'per_page' => 'all' ) ),
+               );
+       }
+
+       /**
+        * Test pagination parameters with invalid values.
+        *
+        * @ticket 64098
+        *
+        * @dataProvider data_invalid_pagination_params_provider
+        *
+        * @param array<string, mixed> $params Invalid pagination parameters.
+        */
+       public function test_invalid_pagination_parameters( array $params ): void {
+               $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities' );
+               $request->set_query_params( $params );
+
+               $response = $this->server->dispatch( $request );
+
+               // Should either use defaults or return error
+               $this->assertContains( $response->get_status(), array( 200, 400 ) );
+
+               if ( $response->get_status() !== 200 ) {
+                       return;
+               }
+
+               // Check that reasonable defaults were used
+               $data = $response->get_data();
+               $this->assertIsArray( $data );
+       }
+
+       /**
+        * Test filtering abilities by category.
+        *
+        * @ticket 64098
+        */
+       public function test_filter_by_category(): void {
+               $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities' );
+               $request->set_param( 'category', 'math' );
+               $response = $this->server->dispatch( $request );
+
+               $this->assertEquals( 200, $response->get_status() );
+
+               $data = $response->get_data();
+               $this->assertIsArray( $data );
+
+               // Should only have math category abilities
+               foreach ( $data as $ability ) {
+                       $this->assertEquals( 'math', $ability['category'], 'All abilities should be in math category' );
+               }
+
+               // Should at least contain the calculator
+               $ability_names = wp_list_pluck( $data, 'name' );
+               $this->assertContains( 'test/calculator', $ability_names );
+               $this->assertNotContains( 'test/system-info', $ability_names, 'System info should not be in math category' );
+       }
+
+       /**
+        * Test filtering by non-existent category returns empty results.
+        *
+        * @ticket 64098
+        */
+       public function test_filter_by_nonexistent_category(): void {
+               // Ensure category doesn't exist - test should fail if it does.
+               $this->assertFalse(
+                       wp_has_ability_category( 'nonexistent' ),
+                       'The nonexistent category should not be registered - test isolation may be broken'
+               );
+
+               $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities' );
+               $request->set_param( 'category', 'nonexistent' );
+               $response = $this->server->dispatch( $request );
+
+               $this->assertEquals( 200, $response->get_status() );
+
+               $data = $response->get_data();
+               $this->assertIsArray( $data );
+               $this->assertEmpty( $data, 'Should return empty array for non-existent category' );
+       }
+}
</ins><span class="cx" style="display: block; padding: 0 10px">Property changes on: trunk/tests/phpunit/tests/rest-api/wpRestAbilitiesV1ListController.php
</span><span class="cx" style="display: block; padding: 0 10px">___________________________________________________________________
</span></span></pre></div>
<a id="svneolstyle"></a>
<div class="addfile"><h4 style="background-color: #eee; color: inherit; margin: 1em 0; padding: 1.3em; font-size: 115%">Added: svn:eol-style</h4></div>
<ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+native
</ins><span class="cx" style="display: block; padding: 0 10px">\ No newline at end of property
</span><a id="trunktestsphpunittestsrestapiwpRestAbilitiesV1RunControllerphp"></a>
<div class="addfile"><h4 style="background-color: #eee; color: inherit; margin: 1em 0; padding: 1.3em; font-size: 115%">Added: trunk/tests/phpunit/tests/rest-api/wpRestAbilitiesV1RunController.php</h4>
<pre class="diff"><span>
<span class="info" style="display: block; padding: 0 10px; color: #888">--- trunk/tests/phpunit/tests/rest-api/wpRestAbilitiesV1RunController.php                             (rev 0)
+++ trunk/tests/phpunit/tests/rest-api/wpRestAbilitiesV1RunController.php       2025-10-21 13:50:11 UTC (rev 61032)
</span><span class="lines" style="display: block; padding: 0 10px; color: #888">@@ -0,0 +1,1181 @@
</span><ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+<?php declare( strict_types=1 );
+
+/**
+ * Tests for the REST run controller for abilities endpoint.
+ *
+ * @covers WP_REST_Abilities_V1_Run_Controller
+ *
+ * @group abilities-api
+ * @group rest-api
+ */
+class Tests_REST_API_WpRestAbilitiesV1RunController extends WP_UnitTestCase {
+
+       /**
+        * REST Server instance.
+        *
+        * @var WP_REST_Server
+        */
+       protected $server;
+
+       /**
+        * Test user ID with permissions.
+        *
+        * @var int
+        */
+       protected static $user_id;
+
+       /**
+        * Test user ID without permissions.
+        *
+        * @var int
+        */
+       protected static $no_permission_user_id;
+
+       /**
+        * Set up before class.
+        */
+       public static function set_up_before_class(): void {
+               parent::set_up_before_class();
+
+               self::$user_id = self::factory()->user->create(
+                       array(
+                               'role' => 'editor',
+                       )
+               );
+
+               self::$no_permission_user_id = self::factory()->user->create(
+                       array(
+                               'role' => 'subscriber',
+                       )
+               );
+
+               // Fire the init hook to allow test ability categories registration.
+               do_action( 'wp_abilities_api_categories_init' );
+               self::register_test_categories();
+       }
+
+       /**
+        * Tear down after class.
+        */
+       public static function tear_down_after_class(): void {
+               // Clean up registered test ability categories.
+               foreach ( array( 'math', 'system', 'general' ) as $slug ) {
+                       wp_unregister_ability_category( $slug );
+               }
+
+               parent::tear_down_after_class();
+       }
+
+       /**
+        * Set up before each test.
+        */
+       public function set_up(): void {
+               parent::set_up();
+
+               global $wp_rest_server;
+               $wp_rest_server = new WP_REST_Server();
+               $this->server   = $wp_rest_server;
+
+               do_action( 'rest_api_init' );
+
+               // Initialize Abilities API.
+               do_action( 'wp_abilities_api_init' );
+               $this->register_test_abilities();
+
+               // Set default user for tests
+               wp_set_current_user( self::$user_id );
+       }
+
+       /**
+        * Tear down after each test.
+        */
+       public function tear_down(): void {
+               // Clean up test abilities.
+               foreach ( wp_get_abilities() as $ability ) {
+                       if ( ! str_starts_with( $ability->get_name(), 'test/' ) ) {
+                               continue;
+                       }
+
+                       wp_unregister_ability( $ability->get_name() );
+               }
+
+               global $wp_rest_server;
+               $wp_rest_server = null;
+
+               parent::tear_down();
+       }
+
+       /**
+        * Register test categories for testing.
+        */
+       public static function register_test_categories(): void {
+               wp_register_ability_category(
+                       'math',
+                       array(
+                               'label'       => 'Math',
+                               'description' => 'Mathematical operations and calculations.',
+                       )
+               );
+
+               wp_register_ability_category(
+                       'system',
+                       array(
+                               'label'       => 'System',
+                               'description' => 'System information and operations.',
+                       )
+               );
+
+               wp_register_ability_category(
+                       'general',
+                       array(
+                               'label'       => 'General',
+                               'description' => 'General purpose abilities.',
+                       )
+               );
+       }
+
+       /**
+        * Register test abilities for testing.
+        */
+       private function register_test_abilities(): void {
+               // Regular ability (POST only).
+               wp_register_ability(
+                       'test/calculator',
+                       array(
+                               'label'               => 'Calculator',
+                               'description'         => 'Performs calculations',
+                               'category'            => 'math',
+                               'input_schema'        => array(
+                                       'type'                 => 'object',
+                                       'properties'           => array(
+                                               'a' => array(
+                                                       'type'        => 'number',
+                                                       'description' => 'First number',
+                                               ),
+                                               'b' => array(
+                                                       'type'        => 'number',
+                                                       'description' => 'Second number',
+                                               ),
+                                       ),
+                                       'required'             => array( 'a', 'b' ),
+                                       'additionalProperties' => false,
+                               ),
+                               'output_schema'       => array(
+                                       'type' => 'number',
+                               ),
+                               'execute_callback'    => static function ( array $input ) {
+                                       return $input['a'] + $input['b'];
+                               },
+                               'permission_callback' => static function () {
+                                       return current_user_can( 'edit_posts' );
+                               },
+                               'meta'                => array(
+                                       'show_in_rest' => true,
+                               ),
+                       )
+               );
+
+               // Read-only ability (GET method).
+               wp_register_ability(
+                       'test/user-info',
+                       array(
+                               'label'               => 'User Info',
+                               'description'         => 'Gets user information',
+                               'category'            => 'system',
+                               'input_schema'        => array(
+                                       'type'       => 'object',
+                                       'properties' => array(
+                                               'user_id' => array(
+                                                       'type'    => 'integer',
+                                                       'default' => 0,
+                                               ),
+                                       ),
+                               ),
+                               'output_schema'       => array(
+                                       'type'       => 'object',
+                                       'properties' => array(
+                                               'id'    => array( 'type' => 'integer' ),
+                                               'login' => array( 'type' => 'string' ),
+                                       ),
+                               ),
+                               'execute_callback'    => static function ( array $input ) {
+                                       $user_id = $input['user_id'] ?? get_current_user_id();
+                                       $user    = get_user_by( 'id', $user_id );
+                                       if ( ! $user ) {
+                                               return new WP_Error( 'user_not_found', 'User not found' );
+                                       }
+                                       return array(
+                                               'id'    => $user->ID,
+                                               'login' => $user->user_login,
+                                       );
+                               },
+                               'permission_callback' => static function () {
+                                       return is_user_logged_in();
+                               },
+                               'meta'                => array(
+                                       'annotations'  => array(
+                                               'readonly' => true,
+                                       ),
+                                       'show_in_rest' => true,
+                               ),
+                       )
+               );
+
+               // Destructive ability (DELETE method).
+               wp_register_ability(
+                       'test/delete-user',
+                       array(
+                               'label'               => 'Delete User',
+                               'description'         => 'Deletes a user',
+                               'category'            => 'system',
+                               'input_schema'        => array(
+                                       'type'       => 'object',
+                                       'properties' => array(
+                                               'user_id' => array(
+                                                       'type'    => 'integer',
+                                                       'default' => 0,
+                                               ),
+                                       ),
+                               ),
+                               'output_schema'       => array(
+                                       'type'     => 'string',
+                                       'required' => true,
+                               ),
+                               'execute_callback'    => static function ( array $input ) {
+                                       $user_id = $input['user_id'] ?? get_current_user_id();
+                                       $user    = get_user_by( 'id', $user_id );
+                                       if ( ! $user ) {
+                                               return new WP_Error( 'user_not_found', 'User not found' );
+                                       }
+                                       return 'User successfully deleted!';
+                               },
+                               'permission_callback' => static function () {
+                                       return is_user_logged_in();
+                               },
+                               'meta'                => array(
+                                       'annotations'  => array(
+                                               'destructive' => true,
+                                               'idempotent'  => true,
+                                       ),
+                                       'show_in_rest' => true,
+                               ),
+                       )
+               );
+
+               // Ability with contextual permissions
+               wp_register_ability(
+                       'test/restricted',
+                       array(
+                               'label'               => 'Restricted Action',
+                               'description'         => 'Requires specific input for permission',
+                               'category'            => 'general',
+                               'input_schema'        => array(
+                                       'type'       => 'object',
+                                       'properties' => array(
+                                               'secret' => array( 'type' => 'string' ),
+                                               'data'   => array( 'type' => 'string' ),
+                                       ),
+                                       'required'   => array( 'secret', 'data' ),
+                               ),
+                               'output_schema'       => array(
+                                       'type' => 'string',
+                               ),
+                               'execute_callback'    => static function ( array $input ) {
+                                       return 'Success: ' . $input['data'];
+                               },
+                               'permission_callback' => static function ( array $input ) {
+                                       // Only allow if secret matches
+                                       return isset( $input['secret'] ) && 'valid_secret' === $input['secret'];
+                               },
+                               'meta'                => array(
+                                       'show_in_rest' => true,
+                               ),
+                       )
+               );
+
+               // Ability that does not show in REST.
+               wp_register_ability(
+                       'test/not-show-in-rest',
+                       array(
+                               'label'               => 'Hidden from REST',
+                               'description'         => 'It does not show in REST.',
+                               'category'            => 'general',
+                               'execute_callback'    => static function (): int {
+                                       return 0;
+                               },
+                               'permission_callback' => '__return_true',
+                       )
+               );
+
+               // Ability that returns null
+               wp_register_ability(
+                       'test/null-return',
+                       array(
+                               'label'               => 'Null Return',
+                               'description'         => 'Returns null',
+                               'category'            => 'general',
+                               'execute_callback'    => static function () {
+                                       return null;
+                               },
+                               'permission_callback' => '__return_true',
+                               'meta'                => array(
+                                       'show_in_rest' => true,
+                               ),
+                       )
+               );
+
+               // Ability that returns WP_Error
+               wp_register_ability(
+                       'test/error-return',
+                       array(
+                               'label'               => 'Error Return',
+                               'description'         => 'Returns error',
+                               'category'            => 'general',
+                               'execute_callback'    => static function () {
+                                       return new WP_Error( 'test_error', 'This is a test error' );
+                               },
+                               'permission_callback' => '__return_true',
+                               'meta'                => array(
+                                       'show_in_rest' => true,
+                               ),
+                       )
+               );
+
+               // Ability with invalid output
+               wp_register_ability(
+                       'test/invalid-output',
+                       array(
+                               'label'               => 'Invalid Output',
+                               'description'         => 'Returns invalid output',
+                               'category'            => 'general',
+                               'output_schema'       => array(
+                                       'type' => 'number',
+                               ),
+                               'execute_callback'    => static function () {
+                                       return 'not a number'; // Invalid - schema expects number
+                               },
+                               'permission_callback' => '__return_true',
+                               'meta'                => array(
+                                       'show_in_rest' => true,
+                               ),
+                       )
+               );
+
+               // Read-only ability for query params testing.
+               wp_register_ability(
+                       'test/query-params',
+                       array(
+                               'label'               => 'Query Params Test',
+                               'description'         => 'Tests query parameter handling',
+                               'category'            => 'general',
+                               'input_schema'        => array(
+                                       'type'       => 'object',
+                                       'properties' => array(
+                                               'param1' => array( 'type' => 'string' ),
+                                               'param2' => array( 'type' => 'integer' ),
+                                       ),
+                               ),
+                               'execute_callback'    => static function ( $input ) {
+                                       return $input;
+                               },
+                               'permission_callback' => '__return_true',
+                               'meta'                => array(
+                                       'annotations'  => array(
+                                               'readonly' => true,
+                                       ),
+                                       'show_in_rest' => true,
+                               ),
+                       )
+               );
+       }
+
+       /**
+        * Test executing a regular ability with POST.
+        *
+        * @ticket 64098
+        */
+       public function test_execute_regular_ability_post(): void {
+               $request = new WP_REST_Request( 'POST', '/wp-abilities/v1/abilities/test/calculator/run' );
+               $request->set_header( 'Content-Type', 'application/json' );
+               $request->set_body(
+                       wp_json_encode(
+                               array(
+                                       'input' => array(
+                                               'a' => 5,
+                                               'b' => 3,
+                                       ),
+                               )
+                       )
+               );
+
+               $response = $this->server->dispatch( $request );
+
+               $this->assertEquals( 200, $response->get_status() );
+               $this->assertEquals( 8, $response->get_data() );
+       }
+
+       /**
+        * Test executing a read-only ability with GET.
+        *
+        * @ticket 64098
+        */
+       public function test_execute_readonly_ability_get(): void {
+               $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/test/user-info/run' );
+               $request->set_query_params(
+                       array(
+                               'input' => array(
+                                       'user_id' => self::$user_id,
+                               ),
+                       )
+               );
+
+               $response = $this->server->dispatch( $request );
+
+               $this->assertEquals( 200, $response->get_status() );
+               $data = $response->get_data();
+               $this->assertEquals( self::$user_id, $data['id'] );
+       }
+
+       /**
+        * Test executing a destructive ability with GET.
+        *
+        * @ticket 64098
+        */
+       public function test_execute_destructive_ability_delete(): void {
+               $request = new WP_REST_Request( 'DELETE', '/wp-abilities/v1/abilities/test/delete-user/run' );
+               $request->set_query_params(
+                       array(
+                               'input' => array(
+                                       'user_id' => self::$user_id,
+                               ),
+                       )
+               );
+
+               $response = $this->server->dispatch( $request );
+
+               $this->assertEquals( 200, $response->get_status() );
+               $this->assertEquals( 'User successfully deleted!', $response->get_data() );
+       }
+
+       /**
+        * Test HTTP method validation for regular abilities.
+        *
+        * @ticket 64098
+        */
+       public function test_regular_ability_requires_post(): void {
+               wp_register_ability(
+                       'test/open-tool',
+                       array(
+                               'label'               => 'Open Tool',
+                               'description'         => 'Tool with no permission requirements',
+                               'category'            => 'general',
+                               'execute_callback'    => static function () {
+                                       return 'success';
+                               },
+                               'permission_callback' => '__return_true',
+                               'meta'                => array(
+                                       'show_in_rest' => true,
+                               ),
+                       )
+               );
+
+               $request  = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/test/open-tool/run' );
+               $response = $this->server->dispatch( $request );
+
+               $this->assertSame( 405, $response->get_status() );
+               $data = $response->get_data();
+               $this->assertSame( 'rest_ability_invalid_method', $data['code'] );
+               $this->assertSame( 'Abilities that perform updates require POST method.', $data['message'] );
+       }
+
+       /**
+        * Test HTTP method validation for read-only abilities.
+        *
+        * @ticket 64098
+        */
+       public function test_readonly_ability_requires_get(): void {
+               // Try POST on a read-only ability (should fail).
+               $request = new WP_REST_Request( 'POST', '/wp-abilities/v1/abilities/test/user-info/run' );
+               $request->set_header( 'Content-Type', 'application/json' );
+               $request->set_body( wp_json_encode( array( 'user_id' => 1 ) ) );
+
+               $response = $this->server->dispatch( $request );
+
+               $this->assertSame( 405, $response->get_status() );
+               $data = $response->get_data();
+               $this->assertSame( 'rest_ability_invalid_method', $data['code'] );
+               $this->assertSame( 'Read-only abilities require GET method.', $data['message'] );
+       }
+
+       /**
+        * Test HTTP method validation for destructive abilities.
+        *
+        * @ticket 64098
+        */
+       public function test_destructive_ability_requires_delete(): void {
+               // Try POST on a destructive ability (should fail).
+               $request = new WP_REST_Request( 'POST', '/wp-abilities/v1/abilities/test/delete-user/run' );
+               $request->set_header( 'Content-Type', 'application/json' );
+               $request->set_body( wp_json_encode( array( 'user_id' => 1 ) ) );
+
+               $response = $this->server->dispatch( $request );
+
+               $this->assertSame( 405, $response->get_status() );
+               $data = $response->get_data();
+               $this->assertSame( 'rest_ability_invalid_method', $data['code'] );
+               $this->assertSame( 'Abilities that perform destructive actions require DELETE method.', $data['message'] );
+       }
+
+       /**
+        * Test output validation against schema.
+        * Note: When output validation fails in WP_Ability::execute(), it returns null,
+        * which causes the REST controller to return 'ability_invalid_output'.
+        *
+        * @ticket 64098
+        */
+       public function test_output_validation(): void {
+               $request = new WP_REST_Request( 'POST', '/wp-abilities/v1/abilities/test/invalid-output/run' );
+               $request->set_header( 'Content-Type', 'application/json' );
+
+               $response = $this->server->dispatch( $request );
+
+               $this->assertSame( 500, $response->get_status() );
+               $data = $response->get_data();
+               $this->assertSame( 'ability_invalid_output', $data['code'] );
+               $this->assertSame(
+                       'Ability "test/invalid-output" has invalid output. Reason: output is not of type number.',
+                       $data['message']
+               );
+       }
+
+       /**
+        * Test permission check for execution.
+        *
+        * @ticket 64098
+        */
+       public function test_execution_permission_denied(): void {
+               wp_set_current_user( self::$no_permission_user_id );
+
+               $request = new WP_REST_Request( 'POST', '/wp-abilities/v1/abilities/test/calculator/run' );
+               $request->set_header( 'Content-Type', 'application/json' );
+               $request->set_body(
+                       wp_json_encode(
+                               array(
+                                       'input' => array(
+                                               'a' => 5,
+                                               'b' => 3,
+                                       ),
+                               )
+                       )
+               );
+
+               $response = $this->server->dispatch( $request );
+
+               $this->assertSame( 403, $response->get_status() );
+               $data = $response->get_data();
+               $this->assertSame( 'rest_ability_cannot_execute', $data['code'] );
+               $this->assertSame( 'Sorry, you are not allowed to execute this ability.', $data['message'] );
+       }
+
+       /**
+        * Test contextual permission check.
+        *
+        * @ticket 64098
+        */
+       public function test_contextual_permission_check(): void {
+               $request = new WP_REST_Request( 'POST', '/wp-abilities/v1/abilities/test/restricted/run' );
+               $request->set_header( 'Content-Type', 'application/json' );
+               $request->set_body(
+                       wp_json_encode(
+                               array(
+                                       'input' => array(
+                                               'secret' => 'wrong_secret',
+                                               'data'   => 'test data',
+                                       ),
+                               )
+                       )
+               );
+
+               $response = $this->server->dispatch( $request );
+               $this->assertEquals( 403, $response->get_status() );
+
+               $request->set_body(
+                       wp_json_encode(
+                               array(
+                                       'input' => array(
+                                               'secret' => 'valid_secret',
+                                               'data'   => 'test data',
+                                       ),
+                               )
+                       )
+               );
+
+               $response = $this->server->dispatch( $request );
+               $this->assertEquals( 200, $response->get_status() );
+               $this->assertEquals( 'Success: test data', $response->get_data() );
+       }
+
+       /**
+        * Test handling an ability that does not show in REST.
+        *
+        * @ticket 64098
+        */
+       public function test_do_not_show_in_rest(): void {
+               $request = new WP_REST_Request( 'POST', '/wp-abilities/v1/abilities/test/not-show-in-rest/run' );
+               $request->set_header( 'Content-Type', 'application/json' );
+
+               $response = $this->server->dispatch( $request );
+
+               $this->assertEquals( 404, $response->get_status() );
+               $data = $response->get_data();
+               $this->assertEquals( 'rest_ability_not_found', $data['code'] );
+               $this->assertEquals( 'Ability not found.', $data['message'] );
+       }
+
+       /**
+        * Test handling of null is a valid return value.
+        *
+        * @ticket 64098
+        */
+       public function test_null_return_handling(): void {
+               $request = new WP_REST_Request( 'POST', '/wp-abilities/v1/abilities/test/null-return/run' );
+               $request->set_header( 'Content-Type', 'application/json' );
+
+               $response = $this->server->dispatch( $request );
+
+               $this->assertEquals( 200, $response->get_status() );
+               $data = $response->get_data();
+               $this->assertNull( $data );
+       }
+
+       /**
+        * Test handling of WP_Error return from ability.
+        *
+        * @ticket 64098
+        */
+       public function test_wp_error_return_handling(): void {
+               $request = new WP_REST_Request( 'POST', '/wp-abilities/v1/abilities/test/error-return/run' );
+               $request->set_header( 'Content-Type', 'application/json' );
+
+               $response = $this->server->dispatch( $request );
+
+               $this->assertEquals( 500, $response->get_status() );
+               $data = $response->get_data();
+               $this->assertEquals( 'test_error', $data['code'] );
+               $this->assertEquals( 'This is a test error', $data['message'] );
+       }
+
+       /**
+        * Test non-existent ability returns 404.
+        *
+        * @ticket 64098
+        *
+        * @expectedIncorrectUsage WP_Abilities_Registry::get_registered
+        */
+       public function test_execute_non_existent_ability(): void {
+               $request = new WP_REST_Request( 'POST', '/wp-abilities/v1/abilities/non/existent/run' );
+               $request->set_header( 'Content-Type', 'application/json' );
+
+               $response = $this->server->dispatch( $request );
+
+               $this->assertEquals( 404, $response->get_status() );
+               $data = $response->get_data();
+               $this->assertEquals( 'rest_ability_not_found', $data['code'] );
+       }
+
+       /**
+        * Test schema retrieval for run endpoint.
+        *
+        * @ticket 64098
+        */
+       public function test_run_endpoint_schema(): void {
+               $request  = new WP_REST_Request( 'OPTIONS', '/wp-abilities/v1/abilities/test/calculator/run' );
+               $response = $this->server->dispatch( $request );
+               $data     = $response->get_data();
+
+               $this->assertArrayHasKey( 'schema', $data );
+               $schema = $data['schema'];
+
+               $this->assertEquals( 'ability-execution', $schema['title'] );
+               $this->assertEquals( 'object', $schema['type'] );
+               $this->assertArrayHasKey( 'properties', $schema );
+               $this->assertArrayHasKey( 'result', $schema['properties'] );
+       }
+
+       /**
+        * Test that invalid JSON in POST body is handled correctly.
+        *
+        * @ticket 64098
+        */
+       public function test_invalid_json_in_post_body(): void {
+               $request = new WP_REST_Request( 'POST', '/wp-abilities/v1/abilities/test/calculator/run' );
+               $request->set_header( 'Content-Type', 'application/json' );
+               // Set raw body with invalid JSON
+               $request->set_body( '{"input": {invalid json}' );
+
+               $response = $this->server->dispatch( $request );
+
+               // When JSON is invalid, WordPress returns 400 Bad Request
+               $this->assertEquals( 400, $response->get_status() );
+       }
+
+       /**
+        * Test GET request with complex nested input array.
+        *
+        * @ticket 64098
+        */
+       public function test_get_request_with_nested_input_array(): void {
+               $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/test/query-params/run' );
+               $request->set_query_params(
+                       array(
+                               'input' => array(
+                                       'level1' => array(
+                                               'level2' => array(
+                                                       'value' => 'nested',
+                                               ),
+                                       ),
+                                       'array'  => array( 1, 2, 3 ),
+                               ),
+                       )
+               );
+
+               $response = $this->server->dispatch( $request );
+               $this->assertEquals( 200, $response->get_status() );
+
+               $data = $response->get_data();
+               $this->assertEquals( 'nested', $data['level1']['level2']['value'] );
+               $this->assertEquals( array( 1, 2, 3 ), $data['array'] );
+       }
+
+       /**
+        * Test GET request with non-array input parameter.
+        *
+        * @ticket 64098
+        */
+       public function test_get_request_with_non_array_input(): void {
+               $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/test/query-params/run' );
+               $request->set_query_params(
+                       array(
+                               'input' => 'not-an-array', // String instead of array
+                       )
+               );
+
+               $response = $this->server->dispatch( $request );
+               // When input is not an array, WordPress returns 400 Bad Request
+               $this->assertEquals( 400, $response->get_status() );
+       }
+
+       /**
+        * Test POST request with non-array input in JSON body.
+        *
+        * @ticket 64098
+        */
+       public function test_post_request_with_non_array_input(): void {
+               $request = new WP_REST_Request( 'POST', '/wp-abilities/v1/abilities/test/calculator/run' );
+               $request->set_header( 'Content-Type', 'application/json' );
+               $request->set_body(
+                       wp_json_encode(
+                               array(
+                                       'input' => 'string-value', // String instead of array
+                               )
+                       )
+               );
+
+               $response = $this->server->dispatch( $request );
+               // When input is not an array, WordPress returns 400 Bad Request
+               $this->assertEquals( 400, $response->get_status() );
+       }
+
+       /**
+        * Test ability with invalid output that fails validation.
+        *
+        * @ticket 64098
+        */
+       public function test_output_validation_failure_returns_error(): void {
+               // Register ability with strict output schema.
+               wp_register_ability(
+                       'test/strict-output',
+                       array(
+                               'label'               => 'Strict Output',
+                               'description'         => 'Ability with strict output schema',
+                               'category'            => 'general',
+                               'output_schema'       => array(
+                                       'type'       => 'object',
+                                       'properties' => array(
+                                               'status' => array(
+                                                       'type' => 'string',
+                                                       'enum' => array( 'success', 'failure' ),
+                                               ),
+                                       ),
+                                       'required'   => array( 'status' ),
+                               ),
+                               'execute_callback'    => static function () {
+                                       // Return invalid output that doesn't match schema
+                                       return array( 'wrong_field' => 'value' );
+                               },
+                               'permission_callback' => '__return_true',
+                               'meta'                => array(
+                                       'show_in_rest' => true,
+                               ),
+                       )
+               );
+
+               $request = new WP_REST_Request( 'POST', '/wp-abilities/v1/abilities/test/strict-output/run' );
+               $request->set_header( 'Content-Type', 'application/json' );
+
+               $response = $this->server->dispatch( $request );
+
+               // Should return error when output validation fails.
+               $this->assertSame( 500, $response->get_status() );
+               $data = $response->get_data();
+               $this->assertSame( 'ability_invalid_output', $data['code'] );
+               $this->assertSame(
+                       'Ability "test/strict-output" has invalid output. Reason: status is a required property of output.',
+                       $data['message']
+               );
+       }
+
+       /**
+        * Test ability with invalid input that fails validation.
+        *
+        * @ticket 64098
+        */
+       public function test_input_validation_failure_returns_error(): void {
+               // Register ability with strict input schema.
+               wp_register_ability(
+                       'test/strict-input',
+                       array(
+                               'label'               => 'Strict Input',
+                               'description'         => 'Ability with strict input schema',
+                               'category'            => 'general',
+                               'input_schema'        => array(
+                                       'type'       => 'object',
+                                       'properties' => array(
+                                               'required_field' => array(
+                                                       'type' => 'string',
+                                               ),
+                                       ),
+                                       'required'   => array( 'required_field' ),
+                               ),
+                               'execute_callback'    => static function () {
+                                       return array( 'status' => 'success' );
+                               },
+                               'permission_callback' => '__return_true',
+                               'meta'                => array(
+                                       'show_in_rest' => true,
+                               ),
+                       )
+               );
+
+               $request = new WP_REST_Request( 'POST', '/wp-abilities/v1/abilities/test/strict-input/run' );
+               $request->set_header( 'Content-Type', 'application/json' );
+               // Missing required field
+               $request->set_body( wp_json_encode( array( 'input' => array( 'other_field' => 'value' ) ) ) );
+
+               $response = $this->server->dispatch( $request );
+
+               // Should return error when input validation fails.
+               $this->assertSame( 400, $response->get_status() );
+               $data = $response->get_data();
+               $this->assertSame( 'ability_invalid_input', $data['code'] );
+               $this->assertSame(
+                       'Ability "test/strict-input" has invalid input. Reason: required_field is a required property of input.',
+                       $data['message']
+               );
+       }
+
+       /**
+        * Test ability without annotations defaults to POST method.
+        *
+        * @ticket 64098
+        */
+       public function test_ability_without_annotations_defaults_to_post_method(): void {
+               // Register ability without annotations.
+               wp_register_ability(
+                       'test/no-annotations',
+                       array(
+                               'label'               => 'No Annotations',
+                               'description'         => 'Ability without annotations.',
+                               'category'            => 'general',
+                               'execute_callback'    => static function () {
+                                       return array( 'executed' => true );
+                               },
+                               'permission_callback' => '__return_true',
+                               'meta'                => array(
+                                       'show_in_rest' => true,
+                               ),
+                       )
+               );
+
+               // Should require POST (default behavior).
+               $get_request  = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/test/no-annotations/run' );
+               $get_response = $this->server->dispatch( $get_request );
+               $this->assertEquals( 405, $get_response->get_status() );
+
+               // Should work with POST.
+               $post_request = new WP_REST_Request( 'POST', '/wp-abilities/v1/abilities/test/no-annotations/run' );
+               $post_request->set_header( 'Content-Type', 'application/json' );
+
+               $post_response = $this->server->dispatch( $post_request );
+               $this->assertEquals( 200, $post_response->get_status() );
+       }
+
+       /**
+        * Test edge case with empty input for both GET and POST methods.
+        *
+        * @ticket 64098
+        */
+       public function test_empty_input_handling(): void {
+               // Registers abilities for empty input testing.
+               wp_register_ability(
+                       'test/read-only-empty',
+                       array(
+                               'label'               => 'Read-only Empty',
+                               'description'         => 'Read-only with empty input.',
+                               'category'            => 'general',
+                               'execute_callback'    => static function () {
+                                       return array( 'input_was_empty' => 0 === func_num_args() );
+                               },
+                               'permission_callback' => '__return_true',
+                               'meta'                => array(
+                                       'annotations'  => array(
+                                               'readonly' => true,
+                                       ),
+                                       'show_in_rest' => true,
+                               ),
+                       )
+               );
+
+               wp_register_ability(
+                       'test/regular-empty',
+                       array(
+                               'label'               => 'Regular Empty',
+                               'description'         => 'Regular with empty input.',
+                               'category'            => 'general',
+                               'execute_callback'    => static function () {
+                                       return array( 'input_was_empty' => 0 === func_num_args() );
+                               },
+                               'permission_callback' => '__return_true',
+                               'meta'                => array(
+                                       'show_in_rest' => true,
+                               ),
+                       )
+               );
+
+               // Tests GET with no input parameter.
+               $get_request  = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/test/read-only-empty/run' );
+               $get_response = $this->server->dispatch( $get_request );
+               $this->assertEquals( 200, $get_response->get_status() );
+               $this->assertTrue( $get_response->get_data()['input_was_empty'] );
+
+               // Tests POST with no body.
+               $post_request = new WP_REST_Request( 'POST', '/wp-abilities/v1/abilities/test/regular-empty/run' );
+               $post_request->set_header( 'Content-Type', 'application/json' );
+               $post_request->set_body( '{}' ); // Empty JSON object
+
+               $post_response = $this->server->dispatch( $post_request );
+               $this->assertEquals( 200, $post_response->get_status() );
+               $this->assertTrue( $post_response->get_data()['input_was_empty'] );
+       }
+
+       /**
+        * Data provider for malformed JSON tests.
+        *
+        * @return array<string, array{0: string}>
+        */
+       public function data_malformed_json_provider(): array {
+               return array(
+                       'Missing value'              => array( '{"input": }' ),
+                       'Trailing comma in array'    => array( '{"input": [1, 2, }' ),
+                       'Missing quotes on key'      => array( '{input: {}}' ),
+                       'JavaScript undefined'       => array( '{"input": undefined}' ),
+                       'JavaScript NaN'             => array( '{"input": NaN}' ),
+                       'Missing quotes nested keys' => array( '{"input": {a: 1, b: 2}}' ),
+                       'Single quotes'              => array( '\'{"input": {}}\'' ),
+                       'Unclosed object'            => array( '{"input": {"key": "value"' ),
+               );
+       }
+
+       /**
+        * Test malformed JSON in POST body.
+        *
+        * @ticket 64098
+        *
+        * @dataProvider data_malformed_json_provider
+        *
+        * @param string $json Malformed JSON to test.
+        */
+       public function test_malformed_json_post_body( string $json ): void {
+               $request = new WP_REST_Request( 'POST', '/wp-abilities/v1/abilities/test/calculator/run' );
+               $request->set_header( 'Content-Type', 'application/json' );
+               $request->set_body( $json );
+
+               $response = $this->server->dispatch( $request );
+
+               // Malformed JSON should result in 400 Bad Request
+               $this->assertEquals( 400, $response->get_status() );
+       }
+
+       /**
+        * Test input with various PHP types as strings.
+        *
+        * @ticket 64098
+        */
+       public function test_php_type_strings_in_input(): void {
+               // Register ability that accepts any input
+               wp_register_ability(
+                       'test/echo',
+                       array(
+                               'label'               => 'Echo',
+                               'description'         => 'Echoes input',
+                               'category'            => 'general',
+                               'input_schema'        => array(
+                                       'type' => 'object',
+                               ),
+                               'execute_callback'    => static function ( $input ) {
+                                       return array( 'echo' => $input );
+                               },
+                               'permission_callback' => '__return_true',
+                               'meta'                => array(
+                                       'show_in_rest' => true,
+                               ),
+                       )
+               );
+
+               $inputs = array(
+                       'null'     => null,
+                       'true'     => true,
+                       'false'    => false,
+                       'int'      => 123,
+                       'float'    => 123.456,
+                       'string'   => 'test',
+                       'empty'    => '',
+                       'zero'     => 0,
+                       'negative' => -1,
+               );
+
+               $request = new WP_REST_Request( 'POST', '/wp-abilities/v1/abilities/test/echo/run' );
+               $request->set_header( 'Content-Type', 'application/json' );
+               $request->set_body( wp_json_encode( array( 'input' => $inputs ) ) );
+
+               $response = $this->server->dispatch( $request );
+
+               $this->assertEquals( 200, $response->get_status() );
+               $data = $response->get_data();
+               $this->assertEquals( $inputs, $data['echo'] );
+       }
+
+       /**
+        * Test input with mixed encoding.
+        *
+        * @ticket 64098
+        */
+       public function test_mixed_encoding_in_input(): void {
+               // Register ability that accepts any input
+               wp_register_ability(
+                       'test/echo-encoding',
+                       array(
+                               'label'               => 'Echo Encoding',
+                               'description'         => 'Echoes input with encoding',
+                               'category'            => 'general',
+                               'input_schema'        => array(
+                                       'type' => 'object',
+                               ),
+                               'execute_callback'    => static function ( $input ) {
+                                       return array( 'echo' => $input );
+                               },
+                               'permission_callback' => '__return_true',
+                               'meta'                => array(
+                                       'show_in_rest' => true,
+                               ),
+                       )
+               );
+
+               $input = array(
+                       'utf8'     => 'Hello δΈ–η•Œ',
+                       'emoji'    => 'πŸŽ‰πŸŽŠπŸŽˆ',
+                       'html'     => '<script>alert("xss")</script>',
+                       'encoded'  => '&lt;test&gt;',
+                       'newlines' => "line1\nline2\rline3\r\nline4",
+                       'tabs'     => "col1\tcol2\tcol3",
+                       'quotes'   => "It's \"quoted\"",
+               );
+
+               $request = new WP_REST_Request( 'POST', '/wp-abilities/v1/abilities/test/echo-encoding/run' );
+               $request->set_header( 'Content-Type', 'application/json' );
+               $request->set_body( wp_json_encode( array( 'input' => $input ) ) );
+
+               $response = $this->server->dispatch( $request );
+
+               $this->assertEquals( 200, $response->get_status() );
+               $data = $response->get_data();
+
+               // Input should be preserved exactly
+               $this->assertEquals( $input['utf8'], $data['echo']['utf8'] );
+               $this->assertEquals( $input['emoji'], $data['echo']['emoji'] );
+               $this->assertEquals( $input['html'], $data['echo']['html'] );
+       }
+
+       /**
+        * Data provider for invalid HTTP methods.
+        *
+        * @return array<string, array{0: string}>
+        */
+       public function data_invalid_http_methods_provider(): array {
+               return array(
+                       'PATCH'  => array( 'PATCH' ),
+                       'PUT'    => array( 'PUT' ),
+                       'DELETE' => array( 'DELETE' ),
+                       'HEAD'   => array( 'HEAD' ),
+               );
+       }
+
+       /**
+        * Test request with invalid HTTP methods.
+        *
+        * @ticket 64098
+        *
+        * @dataProvider data_invalid_http_methods_provider
+        *
+        * @param string $method HTTP method to test.
+        */
+       public function test_invalid_http_methods( string $method ): void {
+               // Register an ability with no permission requirements for this test
+               wp_register_ability(
+                       'test/method-test',
+                       array(
+                               'label'               => 'Method Test',
+                               'description'         => 'Test ability for HTTP method validation',
+                               'category'            => 'general',
+                               'execute_callback'    => static function () {
+                                       return array( 'success' => true );
+                               },
+                               'permission_callback' => '__return_true', // No permission requirements
+                               'meta'                => array(
+                                       'show_in_rest' => true,
+                               ),
+                       )
+               );
+
+               $request  = new WP_REST_Request( $method, '/wp-abilities/v1/abilities/test/method-test/run' );
+               $response = $this->server->dispatch( $request );
+
+               // Regular abilities should only accept POST, so these should return 405.
+               $this->assertSame( 405, $response->get_status() );
+               $data = $response->get_data();
+               $this->assertSame( 'rest_ability_invalid_method', $data['code'] );
+               $this->assertSame( 'Abilities that perform updates require POST method.', $data['message'] );
+       }
+
+       /**
+        * Test OPTIONS method handling.
+        *
+        * @ticket 64098
+        */
+       public function test_options_method_handling(): void {
+               $request  = new WP_REST_Request( 'OPTIONS', '/wp-abilities/v1/abilities/test/calculator/run' );
+               $response = $this->server->dispatch( $request );
+               // OPTIONS requests return 200 with allowed methods
+               $this->assertEquals( 200, $response->get_status() );
+       }
+}
</ins><span class="cx" style="display: block; padding: 0 10px">Property changes on: trunk/tests/phpunit/tests/rest-api/wpRestAbilitiesV1RunController.php
</span><span class="cx" style="display: block; padding: 0 10px">___________________________________________________________________
</span></span></pre></div>
<a id="svneolstyle"></a>
<div class="addfile"><h4 style="background-color: #eee; color: inherit; margin: 1em 0; padding: 1.3em; font-size: 115%">Added: svn:eol-style</h4></div>
<ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+native
</ins><span class="cx" style="display: block; padding: 0 10px">\ No newline at end of property
</span><a id="trunktestsqunitfixtureswpapigeneratedjs"></a>
<div class="modfile"><h4 style="background-color: #eee; color: inherit; margin: 1em 0; padding: 1.3em; font-size: 115%">Modified: trunk/tests/qunit/fixtures/wp-api-generated.js</h4>
<pre class="diff"><span>
<span class="info" style="display: block; padding: 0 10px; color: #888">--- trunk/tests/qunit/fixtures/wp-api-generated.js    2025-10-21 13:47:20 UTC (rev 61031)
+++ trunk/tests/qunit/fixtures/wp-api-generated.js      2025-10-21 13:50:11 UTC (rev 61032)
</span><span class="lines" style="display: block; padding: 0 10px; color: #888">@@ -19,7 +19,8 @@
</span><span class="cx" style="display: block; padding: 0 10px">         "oembed/1.0",
</span><span class="cx" style="display: block; padding: 0 10px">         "wp/v2",
</span><span class="cx" style="display: block; padding: 0 10px">         "wp-site-health/v1",
</span><del style="background-color: #fdd; text-decoration:none; display:block; padding: 0 10px">-        "wp-block-editor/v1"
</del><ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+        "wp-block-editor/v1",
+        "wp-abilities/v1"
</ins><span class="cx" style="display: block; padding: 0 10px">     ],
</span><span class="cx" style="display: block; padding: 0 10px">     "authentication": {
</span><span class="cx" style="display: block; padding: 0 10px">         "application-passwords": {
</span><span class="lines" style="display: block; padding: 0 10px; color: #888">@@ -13481,6 +13482,153 @@
</span><span class="cx" style="display: block; padding: 0 10px">                     }
</span><span class="cx" style="display: block; padding: 0 10px">                 }
</span><span class="cx" style="display: block; padding: 0 10px">             ]
</span><ins style="background-color: #dfd; text-decoration:none; display:block; padding: 0 10px">+        },
+        "/wp-abilities/v1": {
+            "namespace": "wp-abilities/v1",
+            "methods": [
+                "GET"
+            ],
+            "endpoints": [
+                {
+                    "methods": [
+                        "GET"
+                    ],
+                    "args": {
+                        "namespace": {
+                            "default": "wp-abilities/v1",
+                            "required": false
+                        },
+                        "context": {
+                            "default": "view",
+                            "required": false
+                        }
+                    }
+                }
+            ],
+            "_links": {
+                "self": [
+                    {
+                        "href": "http://example.org/index.php?rest_route=/wp-abilities/v1"
+                    }
+                ]
+            }
+        },
+        "/wp-abilities/v1/abilities/(?P<name>[a-zA-Z0-9\\-\\/]+?)/run": {
+            "namespace": "wp-abilities/v1",
+            "methods": [
+                "GET",
+                "POST",
+                "PUT",
+                "PATCH",
+                "DELETE"
+            ],
+            "endpoints": [
+                {
+                    "methods": [
+                        "GET",
+                        "POST",
+                        "PUT",
+                        "PATCH",
+                        "DELETE"
+                    ],
+                    "args": {
+                        "name": {
+                            "description": "Unique identifier for the ability.",
+                            "type": "string",
+                            "pattern": "^[a-zA-Z0-9\\-\\/]+$",
+                            "required": false
+                        },
+                        "input": {
+                            "description": "Input parameters for the ability execution.",
+                            "type": [
+                                "integer",
+                                "number",
+                                "boolean",
+                                "string",
+                                "array",
+                                "object",
+                                "null"
+                            ],
+                            "default": null,
+                            "required": false
+                        }
+                    }
+                }
+            ]
+        },
+        "/wp-abilities/v1/abilities": {
+            "namespace": "wp-abilities/v1",
+            "methods": [
+                "GET"
+            ],
+            "endpoints": [
+                {
+                    "methods": [
+                        "GET"
+                    ],
+                    "args": {
+                        "context": {
+                            "description": "Scope under which the request is made; determines fields present in response.",
+                            "type": "string",
+                            "enum": [
+                                "view",
+                                "embed",
+                                "edit"
+                            ],
+                            "default": "view",
+                            "required": false
+                        },
+                        "page": {
+                            "description": "Current page of the collection.",
+                            "type": "integer",
+                            "default": 1,
+                            "minimum": 1,
+                            "required": false
+                        },
+                        "per_page": {
+                            "description": "Maximum number of items to be returned in result set.",
+                            "type": "integer",
+                            "default": 50,
+                            "minimum": 1,
+                            "maximum": 100,
+                            "required": false
+                        },
+                        "category": {
+                            "description": "Limit results to abilities in specific ability category.",
+                            "type": "string",
+                            "required": false
+                        }
+                    }
+                }
+            ],
+            "_links": {
+                "self": [
+                    {
+                        "href": "http://example.org/index.php?rest_route=/wp-abilities/v1/abilities"
+                    }
+                ]
+            }
+        },
+        "/wp-abilities/v1/abilities/(?P<name>[a-zA-Z0-9\\-\\/]+)": {
+            "namespace": "wp-abilities/v1",
+            "methods": [
+                "GET"
+            ],
+            "endpoints": [
+                {
+                    "methods": [
+                        "GET"
+                    ],
+                    "args": {
+                        "name": {
+                            "description": "Unique identifier for the ability.",
+                            "type": "string",
+                            "pattern": "^[a-zA-Z0-9\\-\\/]+$",
+                            "required": false
+                        }
+                    }
+                }
+            ]
</ins><span class="cx" style="display: block; padding: 0 10px">         }
</span><span class="cx" style="display: block; padding: 0 10px">     },
</span><span class="cx" style="display: block; padding: 0 10px">     "site_logo": 0,
</span></span></pre>
</div>
</div>

</body>
</html>