From 309055af9be01290d8a795823c764c912e811190 Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Wed, 19 Aug 2026 09:16:57 +0200 Subject: [PATCH 1/4] Abilities API: Make the ability category optional --- src/wp-includes/abilities-api.php | 24 ++++++++---- .../class-wp-abilities-registry.php | 16 ++++++-- src/wp-includes/abilities.php | 9 +++++ .../abilities-api/wpAbilitiesRegistry.php | 16 ++++++++ .../tests/abilities-api/wpRegisterAbility.php | 39 +++++++++++++++++++ .../abilities-api/wpRegisterCoreAbilities.php | 13 +++++++ 6 files changed, 106 insertions(+), 11 deletions(-) diff --git a/src/wp-includes/abilities-api.php b/src/wp-includes/abilities-api.php index 37cb1a28072f2..92e13f1b489fc 100644 --- a/src/wp-includes/abilities-api.php +++ b/src/wp-includes/abilities-api.php @@ -128,6 +128,9 @@ * } * add_action( 'wp_abilities_api_init', 'my_plugin_register_abilities' ); * + * On failure, this function returns `null` and calls `_doing_it_wrong()` with the reason. + * By default, the resulting notice is displayed when `WP_DEBUG` is enabled. + * * ### Naming Conventions * * Ability names must follow these rules: @@ -138,8 +141,9 @@ * * ### Categories * - * Abilities must be organized into categories. Ability categories provide better - * discoverability and must be registered before the abilities that reference them: + * Abilities can be organized into categories. If no category is provided, the ability is + * assigned to the built-in `uncategorized` category. Custom categories must be registered + * before the abilities that reference them: * * function my_plugin_register_categories(): void { * wp_register_ability_category( @@ -228,6 +232,7 @@ * ), * * @since 6.9.0 + * @since 7.2.0 The `category` argument is now optional and defaults to `uncategorized`. * * @see WP_Abilities_Registry::register() * @see wp_register_ability_category() @@ -242,9 +247,9 @@ * @type string $label Required. The human-readable label for the ability. * @type string $description Required. A detailed description of what the ability does * and when it should be used. - * @type string $category Required. The ability category slug this ability belongs to. - * The ability category must be registered via `wp_register_ability_category()` - * before registering the ability. + * @type string $category Optional. The ability category slug this ability belongs to. + * Defaults to `uncategorized`. Custom categories must be registered + * via `wp_register_ability_category()` before registering the ability. * @type callable $execute_callback Required. A callback function to execute when the ability is invoked. * Receives optional mixed input data and must return either a result * value (any type) or a `WP_Error` object on failure. @@ -607,8 +612,10 @@ function _wp_get_abilities_match_meta( array $meta, array $conditions ): bool { * Registers a new ability category. * * Ability categories provide a way to organize and group related abilities for better - * discoverability and management. Ability categories must be registered before abilities - * that reference them. + * discoverability and management. Custom categories must be registered before abilities + * that reference them. Abilities that omit a category are assigned to the built-in + * `uncategorized` category, which is intended as an escape hatch for simple or transitional + * registrations. * * Ability categories must be registered on the `wp_abilities_api_categories_init` action hook. * @@ -625,6 +632,9 @@ function _wp_get_abilities_match_meta( array $meta, array $conditions ): bool { * } * add_action( 'wp_abilities_api_categories_init', 'my_plugin_register_categories' ); * + * On failure, this function returns `null` and calls `_doing_it_wrong()` with the reason. + * By default, the resulting notice is displayed when `WP_DEBUG` is enabled. + * * @since 6.9.0 * * @see WP_Ability_Categories_Registry::register() diff --git a/src/wp-includes/abilities-api/class-wp-abilities-registry.php b/src/wp-includes/abilities-api/class-wp-abilities-registry.php index 7d1f26d7690ca..527734b97d6cc 100644 --- a/src/wp-includes/abilities-api/class-wp-abilities-registry.php +++ b/src/wp-includes/abilities-api/class-wp-abilities-registry.php @@ -40,6 +40,7 @@ final class WP_Abilities_Registry { * Do not use this method directly. Instead, use the `wp_register_ability()` function. * * @since 6.9.0 + * @since 7.2.0 The `category` argument is now optional and defaults to `uncategorized`. * * @see wp_register_ability() * @@ -51,7 +52,8 @@ final class WP_Abilities_Registry { * * @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 string $category Optional. The ability category slug this ability belongs to. + * Defaults to `uncategorized`. * @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. @@ -108,13 +110,15 @@ public function register( string $name, array $args ): ?WP_Ability { * Filters the ability arguments before they are validated and used to instantiate the ability. * * @since 6.9.0 + * @since 7.2.0 The `category` argument is now optional and defaults to `uncategorized`. * * @param array $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 string $category Optional. The ability category slug this ability belongs to. + * Defaults to `uncategorized`. * @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. @@ -138,8 +142,12 @@ public function register( string $name, array $args ): ?WP_Ability { */ $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'] ) ) { + if ( ! isset( $args['category'] ) || '' === $args['category'] ) { + $args['category'] = 'uncategorized'; + } + + // Validate ability category exists after applying the default. + if ( is_string( $args['category'] ) ) { if ( ! wp_has_ability_category( $args['category'] ) ) { _doing_it_wrong( __METHOD__, diff --git a/src/wp-includes/abilities.php b/src/wp-includes/abilities.php index 236b99836a3b9..1386c0deb7741 100644 --- a/src/wp-includes/abilities.php +++ b/src/wp-includes/abilities.php @@ -13,6 +13,7 @@ * Registers the core ability categories. * * @since 6.9.0 + * @since 7.2.0 Added the `uncategorized` category. */ function wp_register_core_ability_categories(): void { wp_register_ability_category( @@ -30,6 +31,14 @@ function wp_register_core_ability_categories(): void { 'description' => __( 'Abilities that retrieve or modify user information and settings.' ), ) ); + + wp_register_ability_category( + 'uncategorized', + array( + 'label' => __( 'Uncategorized' ), + 'description' => __( 'Abilities that have not been assigned to a specific category.' ), + ) + ); } /** diff --git a/tests/phpunit/tests/abilities-api/wpAbilitiesRegistry.php b/tests/phpunit/tests/abilities-api/wpAbilitiesRegistry.php index f796ca668acc1..4ecf847ba0069 100644 --- a/tests/phpunit/tests/abilities-api/wpAbilitiesRegistry.php +++ b/tests/phpunit/tests/abilities-api/wpAbilitiesRegistry.php @@ -224,6 +224,22 @@ public function test_register_ability_nonexistent_category(): void { $this->assertNull( $result, 'Should return null when category does not exist.' ); } + /** + * Tests that an invalid category type is not replaced by the default. + * + * @ticket 65569 + * + * @expectedIncorrectUsage WP_Abilities_Registry::register + */ + public function test_register_ability_rejects_invalid_category_type(): void { + $args = self::$test_ability_args; + $args['category'] = false; + + $result = $this->registry->register( self::$test_ability_name, $args ); + + $this->assertNull( $result ); + } + /** * Should reject ability registration without an execute callback. * diff --git a/tests/phpunit/tests/abilities-api/wpRegisterAbility.php b/tests/phpunit/tests/abilities-api/wpRegisterAbility.php index 348b1306ba61e..7243171da1da4 100644 --- a/tests/phpunit/tests/abilities-api/wpRegisterAbility.php +++ b/tests/phpunit/tests/abilities-api/wpRegisterAbility.php @@ -100,6 +100,9 @@ public function tear_down(): void { // Clean up registered test ability category. wp_unregister_ability_category( 'math' ); + if ( wp_has_ability_category( 'uncategorized' ) ) { + wp_unregister_ability_category( 'uncategorized' ); + } parent::tear_down(); } @@ -222,6 +225,42 @@ public function test_register_valid_ability(): void { ); } + /** + * @ticket 65569 + */ + public function test_register_ability_without_category_uses_uncategorized_category(): void { + global $wp_current_filter; + + $this->simulate_doing_wp_abilities_init_action(); + + $wp_current_filter[] = 'wp_abilities_api_categories_init'; + wp_register_ability_category( + 'uncategorized', + array( + 'label' => 'Uncategorized', + 'description' => 'Abilities that have not been assigned to a specific category.', + ) + ); + array_pop( $wp_current_filter ); + + $ability = wp_register_ability( + 'test/without-category', + array( + 'label' => 'Test ability without category', + 'description' => 'Test ability description.', + 'input_schema' => array(), + 'output_schema' => array(), + 'permission_callback' => '__return_true', + 'execute_callback' => static function (): array { + return array( 'success' => true ); + }, + ) + ); + + $this->assertInstanceOf( WP_Ability::class, $ability ); + $this->assertSame( 'uncategorized', $ability->get_category() ); + } + /** * Tests executing an ability with no permissions. * diff --git a/tests/phpunit/tests/abilities-api/wpRegisterCoreAbilities.php b/tests/phpunit/tests/abilities-api/wpRegisterCoreAbilities.php index 9f15250022c7a..85a4e5c1e82e4 100644 --- a/tests/phpunit/tests/abilities-api/wpRegisterCoreAbilities.php +++ b/tests/phpunit/tests/abilities-api/wpRegisterCoreAbilities.php @@ -53,6 +53,19 @@ public static function tear_down_after_class(): void { parent::tear_down_after_class(); } + /** + * Tests that the uncategorized fallback category is registered by core. + * + * @ticket 65569 + */ + public function test_uncategorized_category_is_registered(): void { + $category = wp_get_ability_category( 'uncategorized' ); + + $this->assertInstanceOf( WP_Ability_Category::class, $category ); + $this->assertSame( 'Uncategorized', $category->get_label() ); + $this->assertSame( 'Abilities that have not been assigned to a specific category.', $category->get_description() ); + } + /** * Tests that the `core/get-site-info` ability is registered with the expected schema. * @ticket 64146 From 7fdf5a18cdab220d6e626ef8f9fcd7693421376f Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Wed, 19 Aug 2026 09:30:16 +0200 Subject: [PATCH 2/4] Abilities API: Correct category validation --- .../class-wp-abilities-registry.php | 38 ++++++++------- .../abilities-api/wpAbilitiesRegistry.php | 48 +++++++++++++++++-- 2 files changed, 67 insertions(+), 19 deletions(-) diff --git a/src/wp-includes/abilities-api/class-wp-abilities-registry.php b/src/wp-includes/abilities-api/class-wp-abilities-registry.php index 527734b97d6cc..cc795a1a8a430 100644 --- a/src/wp-includes/abilities-api/class-wp-abilities-registry.php +++ b/src/wp-includes/abilities-api/class-wp-abilities-registry.php @@ -142,25 +142,31 @@ public function register( string $name, array $args ): ?WP_Ability { */ $args = apply_filters( 'wp_register_ability_args', $args, $name ); - if ( ! isset( $args['category'] ) || '' === $args['category'] ) { + if ( ! array_key_exists( 'category', $args ) ) { $args['category'] = 'uncategorized'; } - // Validate ability category exists after applying the default. - if ( is_string( $args['category'] ) ) { - if ( ! wp_has_ability_category( $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; - } + if ( ! is_string( $args['category'] ) ) { + _doing_it_wrong( + __METHOD__, + __( 'Ability category must be a string.' ), + '6.9.0' + ); + return null; + } + + if ( ! wp_has_ability_category( $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. diff --git a/tests/phpunit/tests/abilities-api/wpAbilitiesRegistry.php b/tests/phpunit/tests/abilities-api/wpAbilitiesRegistry.php index 4ecf847ba0069..25c65553e10a9 100644 --- a/tests/phpunit/tests/abilities-api/wpAbilitiesRegistry.php +++ b/tests/phpunit/tests/abilities-api/wpAbilitiesRegistry.php @@ -225,19 +225,61 @@ public function test_register_ability_nonexistent_category(): void { } /** - * Tests that an invalid category type is not replaced by the default. + * Tests that an invalid category type is rejected before the category lookup. * * @ticket 65569 * + * @dataProvider data_invalid_category_types + * * @expectedIncorrectUsage WP_Abilities_Registry::register + * + * @param mixed $category Invalid category value. */ - public function test_register_ability_rejects_invalid_category_type(): void { + public function test_register_ability_rejects_invalid_category_type( $category ): void { $args = self::$test_ability_args; - $args['category'] = false; + $args['category'] = $category; $result = $this->registry->register( self::$test_ability_name, $args ); $this->assertNull( $result ); + $this->assertStringContainsString( + 'Ability category must be a string.', + $this->caught_doing_it_wrong['WP_Abilities_Registry::register'] + ); + } + + /** + * Data provider for invalid category types. + * + * @return array> Test cases. + */ + public static function data_invalid_category_types(): array { + return array( + 'null' => array( null ), + 'boolean' => array( false ), + 'integer' => array( 1 ), + 'array' => array( array() ), + ); + } + + /** + * Tests that an empty category is rejected rather than replaced by the default. + * + * @ticket 65569 + * + * @expectedIncorrectUsage WP_Abilities_Registry::register + */ + public function test_register_ability_rejects_empty_category(): void { + $args = self::$test_ability_args; + $args['category'] = ''; + + $result = $this->registry->register( self::$test_ability_name, $args ); + + $this->assertNull( $result ); + $this->assertStringContainsString( + 'Ability category "" is not registered.', + $this->caught_doing_it_wrong['WP_Abilities_Registry::register'] + ); } /** From fafb5faf6ad1502bbcc3f3ea1e4e5879584ccd4d Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Wed, 19 Aug 2026 09:40:50 +0200 Subject: [PATCH 3/4] Abilities API: Rely on category lookup type validation --- .../abilities-api/class-wp-abilities-registry.php | 9 --------- .../tests/abilities-api/wpAbilitiesRegistry.php | 12 +++--------- 2 files changed, 3 insertions(+), 18 deletions(-) diff --git a/src/wp-includes/abilities-api/class-wp-abilities-registry.php b/src/wp-includes/abilities-api/class-wp-abilities-registry.php index cc795a1a8a430..bb739ee23e227 100644 --- a/src/wp-includes/abilities-api/class-wp-abilities-registry.php +++ b/src/wp-includes/abilities-api/class-wp-abilities-registry.php @@ -146,15 +146,6 @@ public function register( string $name, array $args ): ?WP_Ability { $args['category'] = 'uncategorized'; } - if ( ! is_string( $args['category'] ) ) { - _doing_it_wrong( - __METHOD__, - __( 'Ability category must be a string.' ), - '6.9.0' - ); - return null; - } - if ( ! wp_has_ability_category( $args['category'] ) ) { _doing_it_wrong( __METHOD__, diff --git a/tests/phpunit/tests/abilities-api/wpAbilitiesRegistry.php b/tests/phpunit/tests/abilities-api/wpAbilitiesRegistry.php index 25c65553e10a9..89e7365b69c4d 100644 --- a/tests/phpunit/tests/abilities-api/wpAbilitiesRegistry.php +++ b/tests/phpunit/tests/abilities-api/wpAbilitiesRegistry.php @@ -225,27 +225,21 @@ public function test_register_ability_nonexistent_category(): void { } /** - * Tests that an invalid category type is rejected before the category lookup. + * Tests that an invalid category type is rejected by the category lookup. * * @ticket 65569 * * @dataProvider data_invalid_category_types * - * @expectedIncorrectUsage WP_Abilities_Registry::register - * * @param mixed $category Invalid category value. */ public function test_register_ability_rejects_invalid_category_type( $category ): void { $args = self::$test_ability_args; $args['category'] = $category; - $result = $this->registry->register( self::$test_ability_name, $args ); + $this->expectException( TypeError::class ); - $this->assertNull( $result ); - $this->assertStringContainsString( - 'Ability category must be a string.', - $this->caught_doing_it_wrong['WP_Abilities_Registry::register'] - ); + $this->registry->register( self::$test_ability_name, $args ); } /** From cffee71ebaf526e97093e675e3684727dc628fc9 Mon Sep 17 00:00:00 2001 From: Grzegorz Ziolkowski Date: Fri, 21 Aug 2026 09:51:07 +0200 Subject: [PATCH 4/4] Abilities API: Restore graceful handling of invalid category types Passing a non-string category (for example null) reached wp_has_ability_category() and caused a fatal TypeError. Restore the is_string() guard so registration fails with _doing_it_wrong() instead, matching how it behaved before. Also add the missing @covers annotations to the new category tests. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01CLQBu9338SWzFzhD2rwoeq --- .../class-wp-abilities-registry.php | 9 +++++++++ .../tests/abilities-api/wpAbilitiesRegistry.php | 16 +++++++++++++--- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/src/wp-includes/abilities-api/class-wp-abilities-registry.php b/src/wp-includes/abilities-api/class-wp-abilities-registry.php index bb739ee23e227..cc795a1a8a430 100644 --- a/src/wp-includes/abilities-api/class-wp-abilities-registry.php +++ b/src/wp-includes/abilities-api/class-wp-abilities-registry.php @@ -146,6 +146,15 @@ public function register( string $name, array $args ): ?WP_Ability { $args['category'] = 'uncategorized'; } + if ( ! is_string( $args['category'] ) ) { + _doing_it_wrong( + __METHOD__, + __( 'Ability category must be a string.' ), + '6.9.0' + ); + return null; + } + if ( ! wp_has_ability_category( $args['category'] ) ) { _doing_it_wrong( __METHOD__, diff --git a/tests/phpunit/tests/abilities-api/wpAbilitiesRegistry.php b/tests/phpunit/tests/abilities-api/wpAbilitiesRegistry.php index 89e7365b69c4d..613fb7afb0474 100644 --- a/tests/phpunit/tests/abilities-api/wpAbilitiesRegistry.php +++ b/tests/phpunit/tests/abilities-api/wpAbilitiesRegistry.php @@ -225,21 +225,29 @@ public function test_register_ability_nonexistent_category(): void { } /** - * Tests that an invalid category type is rejected by the category lookup. + * Tests that an invalid category type is rejected before the category lookup. * * @ticket 65569 * * @dataProvider data_invalid_category_types * + * @covers WP_Abilities_Registry::register + * + * @expectedIncorrectUsage WP_Abilities_Registry::register + * * @param mixed $category Invalid category value. */ public function test_register_ability_rejects_invalid_category_type( $category ): void { $args = self::$test_ability_args; $args['category'] = $category; - $this->expectException( TypeError::class ); + $result = $this->registry->register( self::$test_ability_name, $args ); - $this->registry->register( self::$test_ability_name, $args ); + $this->assertNull( $result ); + $this->assertStringContainsString( + 'Ability category must be a string.', + $this->caught_doing_it_wrong['WP_Abilities_Registry::register'] + ); } /** @@ -261,6 +269,8 @@ public static function data_invalid_category_types(): array { * * @ticket 65569 * + * @covers WP_Abilities_Registry::register + * * @expectedIncorrectUsage WP_Abilities_Registry::register */ public function test_register_ability_rejects_empty_category(): void {