Commit 1d6153cd106 for woocommerce

commit 1d6153cd106b81da1779100644db4a79805c50bb
Author: Wesley Rosa <wesleyjrosa@gmail.com>
Date:   Wed Jun 24 12:19:22 2026 -0300

    Extract delivery-agnostic ProductShapeMapperInterface in ProductFeed (#65720)

    * Extract delivery-agnostic ProductShapeMapperInterface in ProductFeed

    * Add changelog entry for ProductShapeMapperInterface extraction

    * Document future direction for product-export abstractions in README

    * Deprecate ProductMapperInterface in favor of ProductShapeMapperInterface

    * Shorten changelog entry

    * Add reflection-based signature guard for ProductShapeMapperInterface

diff --git a/plugins/woocommerce/changelog/65389-dev-product-shape-mapper-interface b/plugins/woocommerce/changelog/65389-dev-product-shape-mapper-interface
new file mode 100644
index 00000000000..f272d4fafbe
--- /dev/null
+++ b/plugins/woocommerce/changelog/65389-dev-product-shape-mapper-interface
@@ -0,0 +1,4 @@
+Significance: patch
+Type: dev
+
+Add delivery-agnostic ProductShapeMapperInterface to the ProductFeed framework, deprecating ProductMapperInterface in its favor.
diff --git a/plugins/woocommerce/src/Internal/ProductFeed/Feed/ProductMapperInterface.php b/plugins/woocommerce/src/Internal/ProductFeed/Feed/ProductMapperInterface.php
index 8a07a1721be..ca94d2d6320 100644
--- a/plugins/woocommerce/src/Internal/ProductFeed/Feed/ProductMapperInterface.php
+++ b/plugins/woocommerce/src/Internal/ProductFeed/Feed/ProductMapperInterface.php
@@ -9,17 +9,23 @@ declare(strict_types=1);

 namespace Automattic\WooCommerce\Internal\ProductFeed\Feed;

+use Automattic\WooCommerce\Internal\ProductFeed\Mapping\ProductShapeMapperInterface;
+
 /**
  * Product Mapper Interface.
  *
+ * Push-feed flavor of the product-shape mapping contract: implementations map a
+ * product to a feed row that is validated by a FeedValidatorInterface and written
+ * to a FeedInterface. The mapping contract itself (map_product()) is inherited
+ * from ProductShapeMapperInterface.
+ *
+ * Existing implementations keep working unchanged and automatically satisfy
+ * ProductShapeMapperInterface; they should migrate to implementing that
+ * interface directly before this one is removed.
+ *
  * @since 10.5.0
+ * @since 11.0.0 Extends ProductShapeMapperInterface; the map_product() contract is inherited unchanged.
+ * @deprecated 11.0.0 Implement Mapping\ProductShapeMapperInterface instead.
  */
-interface ProductMapperInterface {
-	/**
-	 * Map a product to a feed row.
-	 *
-	 * @param \WC_Product $product The product to map.
-	 * @return array The feed row.
-	 */
-	public function map_product( \WC_Product $product ): array;
+interface ProductMapperInterface extends ProductShapeMapperInterface {
 }
diff --git a/plugins/woocommerce/src/Internal/ProductFeed/Feed/ProductWalker.php b/plugins/woocommerce/src/Internal/ProductFeed/Feed/ProductWalker.php
index cfc80bd6d49..9d2351e8bfd 100644
--- a/plugins/woocommerce/src/Internal/ProductFeed/Feed/ProductWalker.php
+++ b/plugins/woocommerce/src/Internal/ProductFeed/Feed/ProductWalker.php
@@ -10,6 +10,7 @@ declare(strict_types=1);
 namespace Automattic\WooCommerce\Internal\ProductFeed\Feed;

 use Automattic\WooCommerce\Internal\ProductFeed\Integrations\IntegrationInterface;
+use Automattic\WooCommerce\Internal\ProductFeed\Mapping\ProductShapeMapperInterface;
 use Automattic\WooCommerce\Internal\ProductFeed\Utils\MemoryManager;

 if ( ! defined( 'ABSPATH' ) ) {
@@ -32,9 +33,9 @@ class ProductWalker {
 	/**
 	 * The product mapper.
 	 *
-	 * @var ProductMapperInterface
+	 * @var ProductShapeMapperInterface
 	 */
-	private ProductMapperInterface $mapper;
+	private ProductShapeMapperInterface $mapper;

 	/**
 	 * The feed.
@@ -83,15 +84,15 @@ class ProductWalker {
 	 *
 	 * This class will not be available through DI. Instead, it needs to be instantiated directly.
 	 *
-	 * @param ProductMapperInterface $mapper The product mapper.
-	 * @param FeedValidatorInterface $validator The feed validator.
-	 * @param FeedInterface          $feed The feed.
-	 * @param ProductLoader          $product_loader The product loader.
-	 * @param MemoryManager          $memory_manager The memory manager.
-	 * @param array                  $query_args The query arguments.
+	 * @param ProductShapeMapperInterface $mapper The product mapper.
+	 * @param FeedValidatorInterface      $validator The feed validator.
+	 * @param FeedInterface               $feed The feed.
+	 * @param ProductLoader               $product_loader The product loader.
+	 * @param MemoryManager               $memory_manager The memory manager.
+	 * @param array                       $query_args The query arguments.
 	 */
 	private function __construct(
-		ProductMapperInterface $mapper,
+		ProductShapeMapperInterface $mapper,
 		FeedValidatorInterface $validator,
 		FeedInterface $feed,
 		ProductLoader $product_loader,
diff --git a/plugins/woocommerce/src/Internal/ProductFeed/Integrations/IntegrationInterface.php b/plugins/woocommerce/src/Internal/ProductFeed/Integrations/IntegrationInterface.php
index 32e97d1978d..008a2df2fc9 100644
--- a/plugins/woocommerce/src/Internal/ProductFeed/Integrations/IntegrationInterface.php
+++ b/plugins/woocommerce/src/Internal/ProductFeed/Integrations/IntegrationInterface.php
@@ -11,7 +11,7 @@ namespace Automattic\WooCommerce\Internal\ProductFeed\Integrations;

 use Automattic\WooCommerce\Internal\ProductFeed\Feed\FeedInterface;
 use Automattic\WooCommerce\Internal\ProductFeed\Feed\FeedValidatorInterface;
-use Automattic\WooCommerce\Internal\ProductFeed\Feed\ProductMapperInterface;
+use Automattic\WooCommerce\Internal\ProductFeed\Mapping\ProductShapeMapperInterface;

 if ( ! defined( 'ABSPATH' ) ) {
 	exit;
@@ -77,9 +77,14 @@ interface IntegrationInterface {
 	/**
 	 * Get the product mapper for the provider.
 	 *
-	 * @return ProductMapperInterface The product mapper.
+	 * Implementations may narrow the return type to a concrete mapper class
+	 * (or, during its deprecation window, the deprecated ProductMapperInterface)
+	 * thanks to return type covariance.
+	 *
+	 * @since 11.0.0 Return type widened from ProductMapperInterface to ProductShapeMapperInterface.
+	 * @return ProductShapeMapperInterface The product mapper.
 	 */
-	public function get_product_mapper(): ProductMapperInterface;
+	public function get_product_mapper(): ProductShapeMapperInterface;

 	/**
 	 * Get the feed validator for the provider.
diff --git a/plugins/woocommerce/src/Internal/ProductFeed/Integrations/POSCatalog/ProductMapper.php b/plugins/woocommerce/src/Internal/ProductFeed/Integrations/POSCatalog/ProductMapper.php
index 52a706750e9..fcb82f4a195 100644
--- a/plugins/woocommerce/src/Internal/ProductFeed/Integrations/POSCatalog/ProductMapper.php
+++ b/plugins/woocommerce/src/Internal/ProductFeed/Integrations/POSCatalog/ProductMapper.php
@@ -9,7 +9,7 @@ declare(strict_types=1);

 namespace Automattic\WooCommerce\Internal\ProductFeed\Integrations\POSCatalog;

-use Automattic\WooCommerce\Internal\ProductFeed\Feed\ProductMapperInterface;
+use Automattic\WooCommerce\Internal\ProductFeed\Mapping\ProductShapeMapperInterface;
 use WC_Product;
 use WC_REST_Products_Controller;
 use WC_REST_Product_Variations_Controller;
@@ -26,7 +26,7 @@ if ( ! defined( 'ABSPATH' ) ) {
  *
  * @since 10.5.0
  */
-class ProductMapper implements ProductMapperInterface {
+class ProductMapper implements ProductShapeMapperInterface {
 	/**
 	 * Fields to include in the product mapping.
 	 *
diff --git a/plugins/woocommerce/src/Internal/ProductFeed/Mapping/ProductShapeMapperInterface.php b/plugins/woocommerce/src/Internal/ProductFeed/Mapping/ProductShapeMapperInterface.php
new file mode 100644
index 00000000000..e2317dff5a8
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/ProductFeed/Mapping/ProductShapeMapperInterface.php
@@ -0,0 +1,35 @@
+<?php
+/**
+ * Product Shape Mapper Interface.
+ *
+ * @package Automattic\WooCommerce\Internal\ProductFeed
+ */
+
+declare(strict_types=1);
+
+namespace Automattic\WooCommerce\Internal\ProductFeed\Mapping;
+
+/**
+ * Minimal contract for mapping a WooCommerce product to an arbitrary array shape.
+ *
+ * This interface carries no delivery semantics: implementations may produce feed
+ * rows, REST payloads, live query results, or any other shape. It can be consumed
+ * by push-feed integrations (via the ProductWalker / FeedInterface machinery) and
+ * by pull/live-query integrations alike, without taking a dependency on file or
+ * CSV delivery.
+ *
+ * This interface supersedes the Feed namespace's ProductMapperInterface, which
+ * extends it and is deprecated: all integrations, push-feed ones included, should
+ * implement this interface directly.
+ *
+ * @since 11.0.0
+ */
+interface ProductShapeMapperInterface {
+	/**
+	 * Map a product to an array shape.
+	 *
+	 * @param \WC_Product $product The product to map.
+	 * @return array The mapped product data.
+	 */
+	public function map_product( \WC_Product $product ): array;
+}
diff --git a/plugins/woocommerce/src/Internal/ProductFeed/README.md b/plugins/woocommerce/src/Internal/ProductFeed/README.md
new file mode 100644
index 00000000000..c42097072b1
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/ProductFeed/README.md
@@ -0,0 +1,119 @@
+# Product Feed framework
+
+The Product Feed framework provides reusable building blocks for exposing the product catalog to external systems. It separates two concerns that integrations frequently need:
+
+1. **Product-shape mapping** — turning a `WC_Product` into an array shape that a consumer understands.
+2. **Feed delivery** — assembling the mapped shapes into a file (JSON, CSV, …) and handing it off to a destination.
+
+Push-feed integrations (for example, the built-in POS Catalog integration or the Stripe Agentic Commerce feed) use both. Pull/live-query integrations (for example, UCP catalog endpoints that run a fresh query on every request) only need the mapping concern and can use it without taking any dependency on file or CSV delivery.
+
+## Directory layout
+
+```text
+ProductFeed/
+├── ProductFeed.php                  # Entry point; integration registration
+├── Mapping/
+│   └── ProductShapeMapperInterface.php  # Delivery-agnostic mapping contract
+├── Feed/
+│   ├── ProductMapperInterface.php   # Deprecated alias of the mapping contract
+│   ├── FeedInterface.php            # Feed assembly (start / add_entry / end)
+│   ├── FeedValidatorInterface.php   # Per-entry validation for feeds
+│   ├── ProductWalker.php            # Batched iteration of the catalog into a feed
+│   ├── ProductLoader.php            # Thin wrapper around wc_get_products()
+│   └── WalkerProgress.php           # Progress data for walker callbacks
+├── Integrations/
+│   ├── IntegrationInterface.php     # Contract for push-feed integrations
+│   ├── IntegrationRegistry.php      # Holds registered integrations
+│   └── POSCatalog/                  # Built-in POS catalog integration
+├── Storage/
+│   └── JsonFileFeed.php             # File-backed JSON feed implementation
+└── Utils/                           # Memory and string helpers
+```
+
+## Product-shape mapping
+
+`Mapping\ProductShapeMapperInterface` is the minimal contract shared by all consumers:
+
+```php
+use Automattic\WooCommerce\Internal\ProductFeed\Mapping\ProductShapeMapperInterface;
+
+class MyCatalogMapper implements ProductShapeMapperInterface {
+	public function map_product( \WC_Product $product ): array {
+		return array(
+			'id'    => (string) $product->get_id(),
+			'title' => $product->get_name(),
+			// ... whatever shape your consumer needs.
+		);
+	}
+}
+```
+
+The interface carries no delivery semantics. The returned array can be a feed row, a REST payload, a live query result, or anything else. This makes a mapper implementation reusable across delivery models:
+
+- **Pull/live-query consumers** (REST controllers that query products per request) type against `ProductShapeMapperInterface` directly and call `map_product()` on each result of their own query. They never touch `FeedInterface`, validators, or files.
+- **Push-feed integrations** also implement `ProductShapeMapperInterface`; the framework's walker and feed machinery consume the mapper through this contract. The older `Feed\ProductMapperInterface` (which extends `ProductShapeMapperInterface` without adding methods) is deprecated: existing implementations keep working during the transition window, but new code should implement `ProductShapeMapperInterface` directly.
+
+## Push-feed integrations
+
+A push-feed integration implements `Integrations\IntegrationInterface`, which composes the mapping contract with feed-specific collaborators:
+
+- `get_product_mapper(): ProductShapeMapperInterface` — the mapper producing one row per product.
+- `get_feed_validator(): FeedValidatorInterface` — validates each mapped row; rows with issues are skipped.
+- `create_feed(): FeedInterface` — the feed being assembled (`Storage\JsonFileFeed` is a ready-made file-backed implementation).
+- `get_product_feed_query_args(): array` — extra `wc_get_products()` arguments scoping which products are included.
+
+`Feed\ProductWalker` ties these together: it iterates the catalog in batches (with memory management and progress reporting), runs every product through the mapper, drops rows the validator rejects, and writes the rest to the feed:
+
+```php
+use Automattic\WooCommerce\Internal\ProductFeed\Feed\ProductWalker;
+
+$feed   = $integration->create_feed();
+$walker = ProductWalker::from_integration( $integration, $feed );
+$walker->set_batch_size( 100 )->walk();
+
+$file_path = $feed->get_file_path();
+```
+
+Integrations register themselves through `ProductFeed::register_integration()`:
+
+```php
+use Automattic\WooCommerce\Internal\ProductFeed\ProductFeed;
+
+wc_get_container()->get( ProductFeed::class )->register_integration( new MyIntegration() );
+```
+
+## Pull/live-query consumers
+
+A pull consumer keeps its own querying and transport, and reuses only the mapping abstraction:
+
+```php
+use Automattic\WooCommerce\Internal\ProductFeed\Mapping\ProductShapeMapperInterface;
+
+class MyCatalogController {
+	private ProductShapeMapperInterface $mapper;
+
+	public function __construct( ProductShapeMapperInterface $mapper ) {
+		$this->mapper = $mapper;
+	}
+
+	public function search( \WP_REST_Request $request ): \WP_REST_Response {
+		$products = wc_get_products( array( /* request-derived args */ ) );
+
+		return new \WP_REST_Response(
+			array( 'products' => array_map( array( $this->mapper, 'map_product' ), $products ) )
+		);
+	}
+}
+```
+
+Because the mapper is delivery-agnostic, the same implementation can also back a push feed for the same catalog shape later, by plugging it into an `IntegrationInterface`.
+
+## Scope and future direction
+
+The framework currently models two consumption patterns: push feeds (file assembly and delivery) and pull/live-query mapping. A third pattern exists in the WooCommerce ecosystem — batched API push with change-trigger hooks, as implemented by Google Listings & Ads (`WCProductAdapter` → `ProductSyncer` → `BatchProductHelper` → `SyncerHooks`). That model is the most mature product-export abstraction in the ecosystem and is a candidate blueprint for a future, richer export framework that push-feed, pull/query, and API-push integrations could all share. Migrating existing API-push integrations onto this framework is intentionally out of scope for now; `ProductShapeMapperInterface` is the shared mapping kernel any such evolution would build on.
+
+## Backwards compatibility notes
+
+- `Feed\ProductMapperInterface` is deprecated, not removed: it extends `Mapping\ProductShapeMapperInterface` without redeclaring methods, so existing implementations keep working unchanged and automatically satisfy the new interface. They should migrate to implementing `ProductShapeMapperInterface` directly before the deprecated interface is removed in a future release.
+- `IntegrationInterface::get_product_mapper()` declares `ProductShapeMapperInterface` as its return type. Implementations may keep narrowing it to `ProductMapperInterface` or a concrete mapper class (return type covariance).
+- These classes live under `Internal` and are not a public API; interfaces may evolve between minor versions.
diff --git a/plugins/woocommerce/tests/php/src/Internal/ProductFeed/Mapping/ProductShapeMapperInterfaceTest.php b/plugins/woocommerce/tests/php/src/Internal/ProductFeed/Mapping/ProductShapeMapperInterfaceTest.php
new file mode 100644
index 00000000000..c995a3c5f26
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/ProductFeed/Mapping/ProductShapeMapperInterfaceTest.php
@@ -0,0 +1,155 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\ProductFeed\Mapping;
+
+use Automattic\WooCommerce\Internal\ProductFeed\Feed\FeedInterface;
+use Automattic\WooCommerce\Internal\ProductFeed\Feed\FeedValidatorInterface;
+use Automattic\WooCommerce\Internal\ProductFeed\Feed\ProductLoader;
+use Automattic\WooCommerce\Internal\ProductFeed\Feed\ProductMapperInterface;
+use Automattic\WooCommerce\Internal\ProductFeed\Feed\ProductWalker;
+use Automattic\WooCommerce\Internal\ProductFeed\Integrations\IntegrationInterface;
+use Automattic\WooCommerce\Internal\ProductFeed\Integrations\POSCatalog\ProductMapper;
+use Automattic\WooCommerce\Internal\ProductFeed\Mapping\ProductShapeMapperInterface;
+use Automattic\WooCommerce\Internal\ProductFeed\Utils\MemoryManager;
+use WC_Helper_Product;
+use WC_Product;
+
+/**
+ * Tests for the ProductShapeMapperInterface contract.
+ */
+class ProductShapeMapperInterfaceTest extends \WC_Unit_Test_Case {
+	/**
+	 * Test container.
+	 *
+	 * @var TestContainer
+	 */
+	private $test_container;
+
+	/**
+	 * Set up test fixtures.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+		$this->test_container = wc_get_container();
+	}
+
+	/**
+	 * Clean up test fixtures.
+	 */
+	public function tearDown(): void {
+		parent::tearDown();
+		$this->test_container->reset_all_replacements();
+	}
+
+	/**
+	 * @testdox The deprecated push-feed ProductMapperInterface should remain a subtype of ProductShapeMapperInterface, so existing feed mappers keep working during the deprecation window.
+	 */
+	public function test_feed_product_mapper_interface_is_a_product_shape_mapper(): void {
+		$this->assertTrue(
+			is_subclass_of( ProductMapperInterface::class, ProductShapeMapperInterface::class ),
+			'The deprecated ProductMapperInterface should extend ProductShapeMapperInterface'
+		);
+	}
+
+	/**
+	 * @testdox The built-in POS catalog mapper should satisfy ProductShapeMapperInterface.
+	 */
+	public function test_pos_catalog_mapper_is_a_product_shape_mapper(): void {
+		$mapper = wc_get_container()->get( ProductMapper::class );
+
+		$this->assertInstanceOf(
+			ProductShapeMapperInterface::class,
+			$mapper,
+			'The POS catalog mapper should be consumable through the delivery-agnostic interface'
+		);
+	}
+
+	/**
+	 * @testdox The ProductShapeMapperInterface contract (param + return type) should be stable; changing it is a conscious BC decision.
+	 */
+	public function test_product_shape_mapper_interface_signature_is_stable(): void {
+		$method = new \ReflectionMethod( ProductShapeMapperInterface::class, 'map_product' );
+		$params = $method->getParameters();
+
+		$this->assertCount( 1, $params, 'map_product() should take exactly one parameter.' );
+		$this->assertSame( WC_Product::class, (string) $params[0]->getType(), 'map_product() should accept a WC_Product.' );
+		$this->assertSame( 'array', (string) $method->getReturnType(), 'map_product() should return an array.' );
+	}
+
+	/**
+	 * Demonstrates the decoupling-from-feed-machinery path: an implementation can be
+	 * instantiated and invoked with no container, validator, or feed present. This is a
+	 * structural/usability demonstration, not a signature guard — see
+	 * test_product_shape_mapper_interface_signature_is_stable() for the contract tripwire.
+	 *
+	 * @testdox A pull/query consumer should be able to use a shape mapper standalone, without any feed machinery.
+	 */
+	public function test_shape_mapper_is_usable_without_feed_machinery(): void {
+		$mapper = new class() implements ProductShapeMapperInterface {
+			/**
+			 * Map a product to an array shape.
+			 *
+			 * @param WC_Product $product The product to map.
+			 * @return array The mapped product data.
+			 */
+			public function map_product( WC_Product $product ): array {
+				return array(
+					'id'    => (string) $product->get_id(),
+					'title' => $product->get_name(),
+				);
+			}
+		};
+
+		$product = WC_Helper_Product::create_simple_product();
+
+		$mapped = $mapper->map_product( $product );
+
+		$this->assertSame( (string) $product->get_id(), $mapped['id'] );
+		$this->assertSame( $product->get_name(), $mapped['title'] );
+	}
+
+	/**
+	 * @testdox The ProductWalker should accept a mapper that only implements ProductShapeMapperInterface, not the push-feed flavored interface.
+	 */
+	public function test_product_walker_accepts_plain_shape_mapper(): void {
+		$product = WC_Helper_Product::create_simple_product();
+
+		$mock_loader = $this->createMock( ProductLoader::class );
+		$mock_loader->method( 'get_products' )->willReturn(
+			(object) array(
+				'products'      => array( $product ),
+				'total'         => 1,
+				'max_num_pages' => 1,
+			)
+		);
+		$this->test_container->replace( ProductLoader::class, $mock_loader );
+
+		$mock_memory_manager = $this->createMock( MemoryManager::class );
+		$mock_memory_manager->method( 'get_available_memory' )->willReturn( 100 );
+		$this->test_container->replace( MemoryManager::class, $mock_memory_manager );
+
+		$mock_mapper = $this->createMock( ProductShapeMapperInterface::class );
+		$mock_mapper->expects( $this->once() )
+			->method( 'map_product' )
+			->with( $this->isInstanceOf( WC_Product::class ) )
+			->willReturn( array( 'id' => $product->get_id() ) );
+
+		$mock_validator = $this->createMock( FeedValidatorInterface::class );
+		$mock_validator->method( 'validate_entry' )->willReturn( array() );
+
+		$mock_integration = $this->createMock( IntegrationInterface::class );
+		$mock_integration->method( 'get_product_mapper' )->willReturn( $mock_mapper );
+		$mock_integration->method( 'get_feed_validator' )->willReturn( $mock_validator );
+		$mock_integration->method( 'get_product_feed_query_args' )->willReturn( array() );
+
+		$mock_feed = $this->createMock( FeedInterface::class );
+		$mock_feed->expects( $this->once() )
+			->method( 'add_entry' )
+			->with( array( 'id' => $product->get_id() ) );
+
+		$processed = ProductWalker::from_integration( $mock_integration, $mock_feed )->walk();
+
+		$this->assertSame( 1, $processed, 'The walker should process the single product through the plain shape mapper' );
+	}
+}