Commit 71822a51074 for woocommerce

commit 71822a51074b9c3d1de9226f312e195afa82b03c
Author: Francesco <frosso@users.noreply.github.com>
Date:   Wed Nov 11 10:55:10 2026 +0100

    Track extension abilities registered through WooCommerce (#70478)

diff --git a/plugins/woocommerce/changelog/track-extension-ability-usage b/plugins/woocommerce/changelog/track-extension-ability-usage
new file mode 100644
index 00000000000..f741c860188
--- /dev/null
+++ b/plugins/woocommerce/changelog/track-extension-ability-usage
@@ -0,0 +1,4 @@
+Significance: patch
+Type: update
+
+Record calls to abilities that extensions register through WooCommerce in usage tracking, when the store allows it.
diff --git a/plugins/woocommerce/src/Internal/Abilities/AbilitiesLoader.php b/plugins/woocommerce/src/Internal/Abilities/AbilitiesLoader.php
index fe4e7666ece..0e7573d7779 100644
--- a/plugins/woocommerce/src/Internal/Abilities/AbilitiesLoader.php
+++ b/plugins/woocommerce/src/Internal/Abilities/AbilitiesLoader.php
@@ -75,11 +75,11 @@ class AbilitiesLoader {
 	private const LOG_SOURCE = 'woocommerce-abilities';

 	/**
-	 * Core ability instances registered by this loader in the current request.
+	 * Ability instances registered by this loader in the current request.
 	 *
 	 * @var array<string, object>
 	 */
-	private static array $registered_core_abilities = array();
+	private static array $registered_abilities = array();

 	/**
 	 * Query var that holds the extension filter clauses of a list ability.
@@ -214,8 +214,8 @@ class AbilitiesLoader {

 				if (
 					$is_core_ability
-					&& isset( self::$registered_core_abilities[ $ability_name ] )
-					&& $existing_ability === self::$registered_core_abilities[ $ability_name ]
+					&& isset( self::$registered_abilities[ $ability_name ] )
+					&& $existing_ability === self::$registered_abilities[ $ability_name ]
 				) {
 					continue;
 				}
@@ -225,7 +225,7 @@ class AbilitiesLoader {
 				}

 				// Drop stale instance tracking before replacing a shadowed registration.
-				unset( self::$registered_core_abilities[ $ability_name ] );
+				unset( self::$registered_abilities[ $ability_name ] );
 				wp_unregister_ability( $ability_name );
 				self::log_replaced_reserved_ability( $ability_name, $class_name );
 			}
@@ -239,12 +239,32 @@ class AbilitiesLoader {

 			$registered_ability = wp_register_ability( $ability_name, $args );

-			if ( $is_core_ability && null !== $registered_ability ) {
-				self::$registered_core_abilities[ $ability_name ] = $registered_ability;
+			if ( null !== $registered_ability ) {
+				self::$registered_abilities[ $ability_name ] = $registered_ability;
 			}
 		}
 	}

+	/**
+	 * Whether the current ability instance was registered by this loader.
+	 *
+	 * @internal
+	 * @since 11.4.0
+	 *
+	 * @param string $ability_name Ability name.
+	 * @return bool
+	 */
+	public static function is_registered_ability( string $ability_name ): bool {
+		if ( ! isset( self::$registered_abilities[ $ability_name ] ) ) {
+			return false;
+		}
+		if ( ! wp_has_ability( $ability_name ) ) {
+			return false;
+		}
+
+		return wp_get_ability( $ability_name ) === self::$registered_abilities[ $ability_name ];
+	}
+
 	/**
 	 * Log when WooCommerce replaces a pre-existing registration in its reserved namespace.
 	 *
@@ -303,6 +323,7 @@ class AbilitiesLoader {
 		 * core classes are always retained.
 		 *
 		 * @since 10.9.0
+		 * @since 11.4.0 Calls to abilities registered through this filter are recorded in usage tracking when the store allows it.
 		 *
 		 * @param array<int, class-string> $classes Ability definition class names.
 		 */
diff --git a/plugins/woocommerce/src/Internal/Abilities/AbilitiesTracking.php b/plugins/woocommerce/src/Internal/Abilities/AbilitiesTracking.php
index 3498200caec..2f222df7957 100644
--- a/plugins/woocommerce/src/Internal/Abilities/AbilitiesTracking.php
+++ b/plugins/woocommerce/src/Internal/Abilities/AbilitiesTracking.php
@@ -14,7 +14,7 @@ use WP_REST_Request;
 defined( 'ABSPATH' ) || exit;

 /**
- * Records a Tracks event for each WooCommerce ability invocation.
+ * Records Tracks events for WooCommerce abilities, including extension abilities registered through its loader.
  *
  * Hooks only collect data in memory. Events are sent once per request on shutdown through the batched
  * Tracks client. Ability input and output are never recorded, except the name of a registered ability an agent
@@ -50,7 +50,7 @@ class AbilitiesTracking {
 	private const TRACKED_CHANNELS = array( 'mcp', 'rest', 'cli' );

 	/**
-	 * Only abilities in this namespace are tracked.
+	 * Abilities in this namespace are always tracked.
 	 *
 	 * @var string
 	 */
@@ -922,13 +922,21 @@ class AbilitiesTracking {
 	}

 	/**
-	 * Whether an ability is in the tracked namespace.
+	 * Whether an ability uses WooCommerce's namespace or was registered through its loader.
 	 *
 	 * @param mixed $ability_name The ability name.
 	 * @return bool
 	 */
 	private static function is_tracked_ability( $ability_name ): bool {
-		return is_string( $ability_name ) && 0 === strpos( $ability_name, self::ABILITY_PREFIX );
+		if ( ! is_string( $ability_name ) ) {
+			return false;
+		}
+
+		if ( 0 === strpos( $ability_name, self::ABILITY_PREFIX ) ) {
+			return true;
+		}
+
+		return AbilitiesLoader::is_registered_ability( $ability_name );
 	}

 	/**
diff --git a/plugins/woocommerce/tests/php/src/Internal/Abilities/AbilitiesTrackingTest.php b/plugins/woocommerce/tests/php/src/Internal/Abilities/AbilitiesTrackingTest.php
index c4b4f423e9d..d77f8366211 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Abilities/AbilitiesTrackingTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Abilities/AbilitiesTrackingTest.php
@@ -3,6 +3,7 @@ declare( strict_types = 1 );

 namespace Automattic\WooCommerce\Tests\Internal\Abilities;

+use Automattic\WooCommerce\Internal\Abilities\AbilitiesLoader;
 use Automattic\WooCommerce\Internal\Abilities\AbilitiesTracking;
 use WC_Unit_Test_Case;
 use WP_Error;
@@ -136,7 +137,7 @@ class AbilitiesTrackingTest extends WC_Unit_Test_Case {
 			}

 			// The abilities registry is a singleton that outlives the test.
-			foreach ( array_merge( array( self::ABILITY_ID, self::INNER_ABILITY_ID, self::UNTRACKED_ABILITY_ID, self::LONG_ABILITY_ID, self::NO_SCHEMA_ABILITY_ID ), $this->registered_adapter_ids ) as $ability_id ) {
+			foreach ( array_merge( array( self::ABILITY_ID, self::INNER_ABILITY_ID, self::UNTRACKED_ABILITY_ID, self::LONG_ABILITY_ID, self::NO_SCHEMA_ABILITY_ID, TestExtensionAbilityDefinition::ABILITY_ID ), $this->registered_adapter_ids ) as $ability_id ) {
 				if ( wp_has_ability( $ability_id ) ) {
 					wp_unregister_ability( $ability_id );
 				}
@@ -175,6 +176,123 @@ class AbilitiesTrackingTest extends WC_Unit_Test_Case {
 		$this->assertArrayNotHasKey( 'error', $event, 'A property named error makes Tracks drop the event' );
 	}

+	/**
+	 * @testdox Should record calls to extension abilities registered through the loader.
+	 */
+	public function test_records_loader_registered_extension_calls(): void {
+		$this->register_extension_ability();
+		$this->start_mcp_tool_call();
+
+		wp_get_ability( TestExtensionAbilityDefinition::ABILITY_ID )->execute();
+		$this->sut->handle_shutdown();
+
+		$this->assertCount( 1, $this->sut->recorded, 'The extension call should be tracked once' );
+		$event = $this->sut->recorded[0];
+		$this->assertSame( 'wcadmin_ability_called', $event['_en'] );
+		$this->assertSame( 'test-extension__test-extension-ability', $event['ability_name'] );
+		$this->assertSame( 'success', $event['outcome'] );
+		$this->assertSame( 'none', $event['error_code'] );
+		$this->assertSame( 'mcp', $event['channel'] );
+	}
+
+	/**
+	 * @testdox Should not track extensions registered directly with WordPress.
+	 */
+	public function test_does_not_track_extensions_registered_directly(): void {
+		$this->register_extension_ability( false );
+		$this->start_mcp_tool_call();
+
+		$result = wp_get_ability( TestExtensionAbilityDefinition::ABILITY_ID )->execute();
+		wp_get_ability( 'mcp-adapter/get-ability-info' )->execute( array( 'ability_name' => TestExtensionAbilityDefinition::ABILITY_ID ) );
+		$subscriber_id = self::factory()->user->create( array( 'role' => 'subscriber' ) );
+		wp_set_current_user( $subscriber_id );
+		$response = rest_do_request( new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/' . TestExtensionAbilityDefinition::ABILITY_ID . '/run' ) );
+		$this->sut->handle_shutdown();
+
+		$this->assertNotWPError( $result, 'The extension remains usable outside the WooCommerce loader' );
+		$this->assertSame( 403, $response->get_status() );
+		$this->assertCount( 0, $this->sut->recorded, 'Public discovery and a matching category do not make an ability loader-owned' );
+	}
+
+	/**
+	 * @testdox Should record a loader-registered extension's REST permission rejection before execution.
+	 */
+	public function test_records_extension_rest_rejection_before_execution(): void {
+		$this->register_extension_ability();
+		$subscriber_id = self::factory()->user->create( array( 'role' => 'subscriber' ) );
+		wp_set_current_user( $subscriber_id );
+
+		$response = rest_do_request( new WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/' . TestExtensionAbilityDefinition::ABILITY_ID . '/run' ) );
+		$this->sut->handle_shutdown();
+
+		$this->assertSame( 403, $response->get_status() );
+		$this->assertCount( 1, $this->sut->recorded );
+		$this->assertSame( 'test-extension__test-extension-ability', $this->sut->recorded[0]['ability_name'] );
+		$this->assertSame( 'rest', $this->sut->recorded[0]['channel'] );
+		$this->assertSame( 'rest_ability_cannot_execute', $this->sut->recorded[0]['error_code'] );
+		$this->assertArrayNotHasKey( 'duration_ms', $this->sut->recorded[0] );
+	}
+
+	/**
+	 * @testdox Should not track an extension ability replaced outside the loader.
+	 */
+	public function test_does_not_track_replaced_extension_ability(): void {
+		$this->register_extension_ability();
+		wp_unregister_ability( TestExtensionAbilityDefinition::ABILITY_ID );
+		$this->register_extension_ability( false );
+		$this->start_mcp_tool_call();
+
+		wp_get_ability( TestExtensionAbilityDefinition::ABILITY_ID )->execute();
+		wp_get_ability( 'mcp-adapter/get-ability-info' )->execute( array( 'ability_name' => TestExtensionAbilityDefinition::ABILITY_ID ) );
+		$this->sut->handle_shutdown();
+
+		$this->assertCount( 0, $this->sut->recorded, 'Only the ability instance registered by the loader belongs to WooCommerce tracking' );
+	}
+
+	/**
+	 * @testdox Should not adopt a pre-existing extension ability when its definition is added to the loader.
+	 */
+	public function test_does_not_track_preexisting_extension_ability(): void {
+		$this->register_extension_ability( false );
+		$this->register_extension_ability();
+		$this->start_mcp_tool_call();
+
+		wp_get_ability( TestExtensionAbilityDefinition::ABILITY_ID )->execute();
+		wp_get_ability( 'mcp-adapter/get-ability-info' )->execute( array( 'ability_name' => TestExtensionAbilityDefinition::ABILITY_ID ) );
+		$this->sut->handle_shutdown();
+
+		$this->assertCount( 0, $this->sut->recorded, 'A definition in the loader does not make an existing registration loader-owned' );
+	}
+
+	/**
+	 * @testdox Should preserve extension tracking when the loader runs more than once.
+	 */
+	public function test_tracks_extension_after_repeated_loader_registration(): void {
+		$this->register_extension_ability();
+		$this->register_extension_ability();
+		$this->start_mcp_tool_call();
+
+		wp_get_ability( TestExtensionAbilityDefinition::ABILITY_ID )->execute();
+		$this->sut->handle_shutdown();
+
+		$this->assertCount( 1, $this->sut->recorded );
+		$this->assertSame( 'test-extension__test-extension-ability', $this->sut->recorded[0]['ability_name'] );
+	}
+
+	/**
+	 * @testdox Should ignore discovery of a removed extension ability without looking up a missing registration.
+	 */
+	public function test_does_not_track_unregistered_extension_ability(): void {
+		$this->register_extension_ability();
+		wp_unregister_ability( TestExtensionAbilityDefinition::ABILITY_ID );
+		$this->start_mcp_tool_call();
+
+		wp_get_ability( 'mcp-adapter/get-ability-info' )->execute( array( 'ability_name' => TestExtensionAbilityDefinition::ABILITY_ID ) );
+		$this->sut->handle_shutdown();
+
+		$this->assertSame( array(), $this->sut->recorded );
+	}
+
 	/**
 	 * @testdox Should not send ability input, including input carried in the request URL or referrer.
 	 */
@@ -396,6 +514,7 @@ class AbilitiesTrackingTest extends WC_Unit_Test_Case {
 	/**
 	 * @testdox Should record MCP Adapter discovery of WooCommerce abilities as a separate event.
 	 * @testWith ["mcp-adapter/get-ability-info", "woocommerce/tracking-test", true, "info", "woocommerce__tracking-test"]
+	 *           ["mcp-adapter/get-ability-info", "test-extension/test-extension-ability", true, "info", "test-extension__test-extension-ability"]
 	 *           ["mcp-adapter/discover-abilities", "", true, "list", "none"]
 	 *           ["mcp-adapter/get-ability-info", "tracking-test/other", true, "", ""]
 	 *           ["mcp-adapter/get-ability-info", "woocommerce/not-registered", true, "", ""]
@@ -408,6 +527,9 @@ class AbilitiesTrackingTest extends WC_Unit_Test_Case {
 	 * @param string $recorded_ability  Expected ability_name.
 	 */
 	public function test_records_adapter_discovery( string $adapter_ability, string $looked_up, bool $over_mcp, string $discovery, string $recorded_ability ): void {
+		if ( TestExtensionAbilityDefinition::ABILITY_ID === $looked_up ) {
+			$this->register_extension_ability();
+		}
 		if ( $over_mcp ) {
 			$this->start_mcp_tool_call();
 		}
@@ -794,7 +916,7 @@ class AbilitiesTrackingTest extends WC_Unit_Test_Case {
 	}

 	/**
-	 * @testdox Should not record abilities outside the woocommerce namespace.
+	 * @testdox Should not record abilities outside the woocommerce namespace that the loader did not register.
 	 */
 	public function test_ignores_abilities_outside_woocommerce_namespace(): void {
 		$this->start_mcp_tool_call();
@@ -1135,6 +1257,32 @@ class AbilitiesTrackingTest extends WC_Unit_Test_Case {
 		apply_filters( 'mcp_adapter_pre_tool_call', array(), 'mcp-adapter/execute-ability', null, null );
 	}

+	/**
+	 * Register the extension fixture through WooCommerce or directly with WordPress.
+	 *
+	 * @param bool $through_loader Whether to use the WooCommerce loader.
+	 */
+	private function register_extension_ability( bool $through_loader = true ): void {
+		$definitions = static function ( $classes ) {
+			$classes[] = TestExtensionAbilityDefinition::class;
+			return $classes;
+		};
+		$register    = $through_loader ? array( AbilitiesLoader::class, 'register_abilities' ) : static function (): void {
+			wp_register_ability( TestExtensionAbilityDefinition::ABILITY_ID, TestExtensionAbilityDefinition::get_registration_args() );
+		};
+
+		if ( $through_loader ) {
+			add_filter( 'woocommerce_ability_definition_classes', $definitions );
+		}
+		add_action( 'wp_abilities_api_init', $register );
+		try {
+			do_action( 'wp_abilities_api_init' );
+		} finally {
+			remove_action( 'wp_abilities_api_init', $register );
+			remove_filter( 'woocommerce_ability_definition_classes', $definitions );
+		}
+	}
+
 	/**
 	 * Skip the test on WordPress versions without hooks for every ability outcome (before 7.1).
 	 */
diff --git a/plugins/woocommerce/tests/php/src/Internal/Abilities/TestExtensionAbilityDefinition.php b/plugins/woocommerce/tests/php/src/Internal/Abilities/TestExtensionAbilityDefinition.php
index b3a19084c5b..c7c523ec7c3 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Abilities/TestExtensionAbilityDefinition.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Abilities/TestExtensionAbilityDefinition.php
@@ -47,7 +47,7 @@ class TestExtensionAbilityDefinition implements AbilityDefinition {
 				),
 				'additionalProperties' => false,
 			),
-			'permission_callback' => '__return_true',
+			'permission_callback' => static fn() => current_user_can( 'manage_woocommerce' ),
 			'meta'                => array(
 				'show_in_rest' => true,
 				'mcp'          => array(