Skip to content
192 changes: 180 additions & 12 deletions src/wp-includes/abilities-api.php
Original file line number Diff line number Diff line change
Expand Up @@ -396,33 +396,201 @@ function wp_get_ability( string $name ): ?WP_Ability {
}

/**
* Retrieves all registered abilities.
* Retrieves registered abilities, optionally filtered by the given arguments.
*
* Returns an array of all ability instances currently registered in the system.
* Use this for discovery, debugging, or building administrative interfaces.
* When called without arguments, returns all registered abilities. When called
* with an $args array, returns only abilities that match every specified condition.
*
* Example:
* Filtering pipeline (executed in order):
*
* 1. Declarative filters (`category`, `namespace`, `meta`) — per-item, AND logic between
* arg types, OR logic within multi-value `category` arrays.
* 2. `match_callback` — per-item, caller-scoped. Return true to include, false to exclude.
* 3. `wp_get_abilities_match` filter — per-item, ecosystem-scoped. Plugins can enforce
* universal inclusion rules regardless of what the caller passed.
* 4. `result_callback` — on the full matched array, caller-scoped. Sort, slice, or reshape.
* 5. `wp_get_abilities_result` filter — on the full array, ecosystem-scoped.
*
* Steps 1–3 run inside a single loop over the registry — no extra iteration.
*
* Examples:
*
* // Prints information about all available abilities.
* // All abilities (unchanged behaviour).
* $abilities = wp_get_abilities();
* foreach ( $abilities as $ability ) {
* echo $ability->get_label() . ': ' . $ability->get_description() . "\n";
* }
*
* // Filter by category.
* $abilities = wp_get_abilities( array( 'category' => 'content' ) );
*
* // Filter by multiple categories (OR logic).
* $abilities = wp_get_abilities( array( 'category' => array( 'content', 'settings' ) ) );
*
* // Filter by namespace.
* $abilities = wp_get_abilities( array( 'namespace' => 'woocommerce' ) );
*
* // Filter by meta.
* $abilities = wp_get_abilities( array( 'meta' => array( 'show_in_rest' => true ) ) );
*
* // Combine filters (AND logic between arg types).
* $abilities = wp_get_abilities( array(
* 'category' => 'content',
* 'namespace' => 'core',
* 'meta' => array( 'show_in_rest' => true ),
* ) );
*
* // Caller-scoped per-item callback.
* $abilities = wp_get_abilities( array(
* 'match_callback' => function ( WP_Ability $ability ) {
* return current_user_can( 'manage_options' );
* },
* ) );
*
* // Caller-scoped result callback (sort + paginate).
* $abilities = wp_get_abilities( array(
* 'result_callback' => function ( array $abilities ) {
* usort( $abilities, fn( $a, $b ) => strcasecmp( $a->get_label(), $b->get_label() ) );
* return array_slice( $abilities, 0, 10 );
* },
* ) );
*
* @since 6.9.0
* @since 7.1.0 Added the `$args` parameter for filtering support.
*
* @see WP_Abilities_Registry::get_all_registered()
*
* @return WP_Ability[] An array of registered WP_Ability instances. Returns an empty
* array if no abilities are registered or if the registry is unavailable.
* @param array $args {
* Optional. Arguments to filter the returned abilities. Default empty array (returns all).
*
* @type string|string[] $category Filter by category slug. A single string or an array of
* slugs — abilities matching any of the given slugs are
* included (OR logic within this arg type).
* @type string $namespace Filter by ability namespace prefix. Pass the namespace
* without a trailing slash, e.g. `'woocommerce'` matches
* `'woocommerce/create-order'`.
* @type array $meta Filter by meta key/value pairs. All conditions must
* match (AND logic). Supports nested arrays for structured
* meta, e.g. `array( 'mcp' => array( 'public' => true ) )`.
* @type callable $match_callback Optional. A callback invoked per ability after declarative
* filters. Receives a WP_Ability instance, returns bool.
* Return true to include, false to exclude.
* @type callable $result_callback Optional. A callback invoked once on the full matched
* array. Receives WP_Ability[], must return WP_Ability[].
* Use for sorting, slicing, or reshaping the result.
* }
* @return WP_Ability[] An array of registered WP_Ability instances matching the given args.
* Returns an empty array if no abilities are registered, the registry is
* unavailable, or no abilities match the given args.
*/
function wp_get_abilities(): array {
function wp_get_abilities( array $args = array() ): array {
$registry = WP_Abilities_Registry::get_instance();
if ( null === $registry ) {
return array();
}

return $registry->get_all_registered();
$abilities = $registry->get_all_registered();

// Bail early when no filtering is requested.
if ( empty( $args ) ) {
return $abilities;
Comment on lines +491 to +493
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider dropping this early return so the full pipeline always runs.

The Trac ticket frames the two new filter hooks as the mechanism that lets plugins enforce universal rules — "A security plugin cannot enforce capability checks, the MCP adapter cannot gate visibility, and core cannot apply default scoping" — and concludes "Ecosystem hooks fire last at each level, so plugins always get the final say." The early bail prevents wp_get_abilities_match and wp_get_abilities_result from firing on the most common call path (wp_get_abilities() with no args), which is the path security/visibility plugins most need to participate in.

The perf cost of removing the bail is one foreach over an in-memory array — negligible next to anything else wp_get_abilities() callers do, and the filter is brand new so there is no BC concern with hooks firing more often.

For callers who genuinely want raw, unfiltered registry data, WP_Abilities_Registry::get_all_registered() is the right escape hatch (and is already @see'd in the docblock). Worth strengthening that pointer alongside this change.

Would also add a couple of tests asserting both filters fire when wp_get_abilities() is called with no args, so the contract is pinned.

}

$category = isset( $args['category'] ) ? (array) $args['category'] : array();
$namespace = isset( $args['namespace'] ) && is_string( $args['namespace'] ) ? rtrim( $args['namespace'], '/' ) . '/' : '';
$meta = isset( $args['meta'] ) && is_array( $args['meta'] ) ? $args['meta'] : array();
$match_callback = isset( $args['match_callback'] ) && is_callable( $args['match_callback'] ) ? $args['match_callback'] : null;
$result_callback = isset( $args['result_callback'] ) && is_callable( $args['result_callback'] ) ? $args['result_callback'] : null;
Comment on lines +496 to +500
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is worth double-checking with other similar functions to see whether silently dropping args with incorrect types is the established best practice.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I checked against similar WordPress query functions — get_posts(), get_terms(), and WP_Query all silently cast or ignore args with incorrect types without triggering warnings. For example, get_posts() accepts 'category' as either a string or an integer and handles both without complaint. Our implementation follows the same established pattern: category is cast via (array), which safely handles both string and array inputs, while namespace, match_callback, and result_callback are guarded with is_string()/is_callable() checks and silently fall back to their defaults when the type is wrong.

If stricter developer feedback is desired, _doing_it_wrong() calls could be added in a follow-up, but silent dropping/casting is consistent with how WordPress core query functions have always behaved.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for providing the clarification and references, some code examples for documentation purposes:


$matched = array();

foreach ( $abilities as $ability ) {
// Step 1a: Filter by category (OR logic within the arg).
if ( ! empty( $category ) && ! in_array( $ability->get_category(), $category, true ) ) {
continue;
}

// Step 1b: Filter by namespace prefix.
if ( '' !== $namespace && ! str_starts_with( $ability->get_name(), $namespace ) ) {
continue;
}

// Step 1c: Filter by meta key/value pairs (AND logic, supports nested arrays).
if ( ! empty( $meta ) && ! _wp_get_abilities_match_meta( $ability->get_meta(), $meta ) ) {
continue;
}

// Step 2: Caller-scoped per-item callback.
$include = true;
if ( null !== $match_callback ) {
$include = (bool) call_user_func( $match_callback, $ability );
}

/**
* Filters whether an individual ability should be included in the result set.
*
* Fires after the declarative filters and the caller-scoped match_callback.
* Plugins can use this to enforce universal inclusion rules regardless of
* what the caller passed in $args.
*
* @since 7.1.0
*
* @param bool $include Whether to include the ability. Default true (after declarative filters pass).
* @param WP_Ability $ability The ability instance being evaluated.
* @param array $args The full $args array passed to wp_get_abilities().
*/
$include = (bool) apply_filters( 'wp_get_abilities_match', $include, $ability, $args );
Copy link
Copy Markdown
Member

@gziolo gziolo May 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Naming nit worth resolving while the API is still pre-merge: this filter is wp_get_abilities_match but the variable it filters is $include, and the docblock describes the value as "whether to include the ability." One concept, two names — readers have to translate.

I'd vote we pick include and align everything to it. The action ("should this be included?") reads more naturally than the predicate ("did it match?"), and it avoids the cognitive collision with PHP 8's match expression.

Concrete proposal:

Current Proposed
match_callback (arg key) item_include_callback
wp_get_abilities_match (filter) wp_get_abilities_item_include
$include (variable) unchanged

The item_* / result_* pairing also makes the pipeline self-documenting: item_include_callback + wp_get_abilities_item_include (per-item scope) vs result_callback + wp_get_abilities_result (whole-array scope). When both keys appear in one $args array, the symmetry is genuinely clearer than match_callback + result_callback.

If we go this route, the rename cascades to:

  • The $args key, filter hook name, @type entry in the hash docblock, pipeline summary list (steps 2 & 3), and the inline // Step 2: comment.
  • All test_match_callback_* / test_wp_get_abilities_match_* test methods and the section dividers in the test file.


if ( $include ) {
$matched[] = $ability;
}
}

// Step 4: Caller-scoped result callback.
if ( null !== $result_callback ) {
$matched = (array) call_user_func( $result_callback, $matched );
}

/**
* Filters the full list of matched abilities after all per-item filtering is complete.
*
* Fires after the caller-scoped result_callback. Plugins can use this to sort,
* paginate, or reshape the final result set universally.
*
* @since 7.1.0
*
* @param WP_Ability[] $matched The matched abilities after all filtering.
* @param array $args The full $args array passed to wp_get_abilities().
*/
return (array) apply_filters( 'wp_get_abilities_result', $matched, $args );
}

/**
* Checks whether an ability's meta array matches a set of required key/value conditions.
*
* All conditions must match (AND logic). Supports nested arrays for structured meta,
* e.g. `array( 'mcp' => array( 'public' => true ) )`.
*
* @since 7.1.0
* @access private
*
* @param array $meta The ability's meta array.
* @param array $conditions The required key/value conditions to match against.
* @return bool True if all conditions match, false otherwise.
*/
function _wp_get_abilities_match_meta( array $meta, array $conditions ): bool {
foreach ( $conditions as $key => $value ) {
if ( ! array_key_exists( $key, $meta ) ) {
return false;
}

if ( is_array( $value ) ) {
if ( ! is_array( $meta[ $key ] ) || ! _wp_get_abilities_match_meta( $meta[ $key ], $value ) ) {
return false;
}
} elseif ( $meta[ $key ] !== $value ) {
return false;
}
}

return true;
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,26 +86,20 @@ public function register_routes(): void {
* @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' );
}
$query_args = array(
'meta' => array( 'show_in_rest' => true ),
);

// 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 );
if ( ! empty( $request['category'] ) ) {
$query_args['category'] = $request['category'];
}

if ( ! empty( $request['namespace'] ) ) {
$query_args['namespace'] = $request['namespace'];
Copy link
Copy Markdown
Member

@gziolo gziolo May 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The refactor itself preserves existing behavior — test_filter_by_category, test_filter_by_nonexistent_category, and the implicit show_in_rest count in test_pagination_headers all pass after the change.

The gap is the new ?namespace= query parameter has no controller-layer tests. The PHP-layer filtering is well covered in wpGetAbilities.php, but the controller path adds behavior that should be pinned:

  • sanitize_key strips /, so ?namespace=foo/bar collapses to foobar. That's safe (REST callers can only filter by base namespace, no path traversal), but the constraint isn't asserted.
  • The arg is only added to $query_args when non-empty — easy to regress.
  • ?namespace= must compose with the implicit show_in_rest=true meta filter. A namespace-matching ability with show_in_rest=false must still be excluded — this isn't asserted anywhere.

Suggest adding three tests in wpRestAbilitiesV1ListController.php:

public function test_filter_by_namespace(): void {
    $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities' );
    $request->set_param( 'namespace', 'test' );
    $response = $this->server->dispatch( $request );

    $this->assertSame( 200, $response->get_status() );
    $names = wp_list_pluck( $response->get_data(), 'name' );

    $this->assertNotEmpty( $names, 'Expected at least one ability in the test namespace.' );
    foreach ( $names as $name ) {
        $this->assertStringStartsWith( 'test/', $name );
    }
}

public function test_filter_by_nonexistent_namespace(): void {
    $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities' );
    $request->set_param( 'namespace', 'nonexistent' );
    $response = $this->server->dispatch( $request );

    $this->assertSame( 200, $response->get_status() );
    $this->assertEmpty( $response->get_data() );
}

public function test_filter_by_namespace_still_respects_show_in_rest(): void {
    // 'test/not-show-in-rest' is registered in the fixture with show_in_rest => false.
    $request = new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities' );
    $request->set_param( 'namespace', 'test' );
    $response = $this->server->dispatch( $request );

    $names = wp_list_pluck( $response->get_data(), 'name' );
    $this->assertNotContains( 'test/not-show-in-rest', $names );
}

}

$abilities = wp_get_abilities( $query_args );

$page = $request['page'];
$per_page = $request['per_page'];
$offset = ( $page - 1 ) * $per_page;
Expand Down Expand Up @@ -432,12 +426,18 @@ public function get_collection_params(): array {
'minimum' => 1,
'maximum' => 100,
),
'category' => array(
'category' => array(
'description' => __( 'Limit results to abilities in specific ability category.' ),
'type' => 'string',
'sanitize_callback' => 'sanitize_key',
'validate_callback' => 'rest_validate_request_arg',
),
'namespace' => array(
'description' => __( 'Limit results to abilities in a specific namespace.' ),
'type' => 'string',
'sanitize_callback' => 'sanitize_key',
'validate_callback' => 'rest_validate_request_arg',
),
);
}
}
Loading
Loading