Commit b3b1115c862 for woocommerce

commit b3b1115c8625735c37ddda8407839610e21f7be6
Author: Luis Felipe Zaguini <26530524+zaguiini@users.noreply.github.com>
Date:   Wed Nov 4 08:24:39 2026 -0300

    Add PHPUnit tests for the staged write PHPStan rule (#70596)

    * Add PHPUnit tests for the staged write PHPStan rule

    StagedWriteRule is the first custom PHPStan rule in the plugin, and
    nothing tested it. The tests run the rule over small fixture classes
    with PHPStan's RuleTestCase. They run with plain PHPUnit, outside
    wp-env, through composer test:phpstan-rules, and the PHPStan CI job
    now runs them before the analysis.

    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

    * Type the staged write rule for PHPStan level 8

    processNode() now declares InClassMethodNode in its docblock, and the
    call loop narrows each call to MethodCall before it reads the method
    name. PHPStan reports no errors in the rule file.

    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

    * Keep the rule test fixtures out of the classmap and the public changelog

    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

    ---------

    Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

diff --git a/.github/workflows/phpstan.yml b/.github/workflows/phpstan.yml
index d29fa9538c8..5fa04b6b6b7 100644
--- a/.github/workflows/phpstan.yml
+++ b/.github/workflows/phpstan.yml
@@ -29,6 +29,10 @@ jobs:
                   pull-package-deps: '@woocommerce/plugin-woocommerce'
                   php-version: '8.4'

+            - name: 'Run PHPStan rule tests'
+              working-directory: plugins/woocommerce
+              run: composer test:phpstan-rules
+
             - name: 'Create tmp directory'
               working-directory: plugins/woocommerce
               run: mkdir -p tmp
diff --git a/plugins/woocommerce/changelog/add-staged-write-rule-tests b/plugins/woocommerce/changelog/add-staged-write-rule-tests
new file mode 100644
index 00000000000..51069c1def5
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-staged-write-rule-tests
@@ -0,0 +1,4 @@
+Significance: patch
+Type: dev
+Comment: Add PHPUnit tests for the staged write PHPStan rule and run them in CI; no production change.
+
diff --git a/plugins/woocommerce/composer.json b/plugins/woocommerce/composer.json
index b2d976d02bc..810ae17b1d6 100644
--- a/plugins/woocommerce/composer.json
+++ b/plugins/woocommerce/composer.json
@@ -114,6 +114,9 @@
 		},
 		"classmap": [
 			"tests/legacy/unit-tests/rest-api/Helpers"
+		],
+		"exclude-from-classmap": [
+			"tests/Tools/phpstan/data/"
 		]
 	},
 	"scripts": {
@@ -162,6 +165,9 @@
 		],
 		"phpstan:baseline": [
 			"vendor/bin/phpstan analyse --configuration=phpstan.neon --memory-limit=4G --generate-baseline=phpstan-baseline.neon"
+		],
+		"test:phpstan-rules": [
+			"phpunit -c tests/Tools/phpstan/phpunit.xml"
 		]
 	},
 	"extra": {
diff --git a/plugins/woocommerce/phpcs.xml b/plugins/woocommerce/phpcs.xml
index 6398838d27e..4a3cd5752dd 100644
--- a/plugins/woocommerce/phpcs.xml
+++ b/plugins/woocommerce/phpcs.xml
@@ -26,6 +26,8 @@
 	<exclude-pattern>tests/e2e/bin/</exclude-pattern>
 	<!-- Custom PHPCS sniffs and their fixtures follow PHPCS naming/structure, not the WooCommerce ruleset. -->
 	<exclude-pattern>tests/Tools/phpcs/</exclude-pattern>
+	<!-- Fixture classes that the PHPStan rule tests analyse. -->
+	<exclude-pattern>tests/Tools/phpstan/data/</exclude-pattern>

 	<!-- Only check PHP files. -->
 	<arg name="extensions" value="php" />
diff --git a/plugins/woocommerce/tests/Tools/phpstan/StagedWriteRule.php b/plugins/woocommerce/tests/Tools/phpstan/StagedWriteRule.php
index 97f45aa0d7b..48a505e12cf 100644
--- a/plugins/woocommerce/tests/Tools/phpstan/StagedWriteRule.php
+++ b/plugins/woocommerce/tests/Tools/phpstan/StagedWriteRule.php
@@ -59,9 +59,9 @@ final class StagedWriteRule implements Rule {
 	/**
 	 * Report the method when it writes and calls no stage method.
 	 *
-	 * @param Node  $node  Method node.
-	 * @param Scope $scope Scope of the method.
-	 * @return array
+	 * @param InClassMethodNode $node  Method node.
+	 * @param Scope             $scope Scope of the method.
+	 * @return list<\PHPStan\Rules\IdentifierRuleError>
 	 */
 	public function processNode( Node $node, Scope $scope ): array {
 		$class  = $node->getClassReflection();
@@ -98,7 +98,7 @@ final class StagedWriteRule implements Rule {
 				continue;
 			}

-			if ( ! $call->name instanceof Identifier ) {
+			if ( ! $call instanceof MethodCall || ! $call->name instanceof Identifier ) {
 				continue;
 			}

diff --git a/plugins/woocommerce/tests/Tools/phpstan/StagedWriteRuleTest.php b/plugins/woocommerce/tests/Tools/phpstan/StagedWriteRuleTest.php
new file mode 100644
index 00000000000..e9f2c084f82
--- /dev/null
+++ b/plugins/woocommerce/tests/Tools/phpstan/StagedWriteRuleTest.php
@@ -0,0 +1,105 @@
+<?php
+/**
+ * StagedWriteRuleTest class file.
+ *
+ * @package WooCommerce\Tests
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Testing\Tools\phpstan;
+
+use PHPStan\Rules\Rule;
+use PHPStan\Testing\RuleTestCase;
+
+/**
+ * Tests for StagedWriteRule.
+ *
+ * @extends RuleTestCase<StagedWriteRule>
+ */
+final class StagedWriteRuleTest extends RuleTestCase {
+
+	/**
+	 * Rule under test.
+	 *
+	 * @return StagedWriteRule
+	 */
+	protected function getRule(): Rule {
+		return new StagedWriteRule();
+	}
+
+	/**
+	 * @testdox A stage method that does not start with refuse_unstaged_override() is reported.
+	 */
+	public function test_stage_method_without_leading_refusal_is_reported(): void {
+		$this->assert_errors(
+			'stage-refusal.php',
+			array(
+				array( 'woocommerce.stagedTwinRefusal', 'Method Stage_Refusal_Fixture::stage_late() must start with $this->refuse_unstaged_override().', 9 ),
+				array( 'woocommerce.stagedTwinRefusal', 'Method Stage_Refusal_Fixture::stage_missing() must start with $this->refuse_unstaged_override().', 14 ),
+			)
+		);
+	}
+
+	/**
+	 * @testdox Each kind of write outside save() is reported, and a method that only reads is not.
+	 */
+	public function test_writes_outside_save_are_reported(): void {
+		$this->assert_errors(
+			'writes.php',
+			array(
+				array( 'woocommerce.stagedWrite', self::write_message( 'Writes_Fixture', 'through_data_store_property' ), 4 ),
+				array( 'woocommerce.stagedWrite', self::write_message( 'Writes_Fixture', 'through_loaded_data_store' ), 8 ),
+				array( 'woocommerce.stagedWrite', self::write_message( 'Writes_Fixture', 'through_get_data_store' ), 13 ),
+				array( 'woocommerce.stagedWrite', self::write_message( 'Writes_Fixture', 'through_wpdb' ), 17 ),
+				array( 'woocommerce.stagedWrite', self::write_message( 'Writes_Fixture', 'through_function' ), 22 ),
+				array( 'woocommerce.stagedWrite', self::write_message( 'Writes_Fixture', 'through_save' ), 26 ),
+			)
+		);
+	}
+
+	/**
+	 * @testdox A saving method that calls its own stage twin is not reported, and one that calls another twin is.
+	 */
+	public function test_only_the_named_twin_exempts_a_saving_method(): void {
+		$this->assert_errors(
+			'twins.php',
+			array(
+				array( 'woocommerce.stagedWrite', self::write_message( 'Twins_Fixture', 'add_note' ), 28 ),
+			)
+		);
+	}
+
+	/**
+	 * @testdox Save pipeline methods, deletes, lock methods, static methods and classes outside WC_Data are not reported.
+	 */
+	public function test_exempt_methods_and_classes_are_not_reported(): void {
+		$this->assert_errors( 'exempt.php', array() );
+	}
+
+	/**
+	 * Analyse a fixture and compare identifier, message and line of every error.
+	 *
+	 * @param string                          $fixture  Fixture file name in the data directory.
+	 * @param array<array{string,string,int}> $expected Expected errors in line order.
+	 */
+	private function assert_errors( string $fixture, array $expected ): void {
+		$errors = $this->gatherAnalyserErrors( array( __DIR__ . '/data/' . $fixture ) );
+
+		$actual = array_map( fn( $error ) => array( $error->getIdentifier(), $error->getMessage(), $error->getLine() ), $errors );
+		usort( $actual, fn( $a, $b ) => $a[2] <=> $b[2] );
+
+		$this->assertSame( $expected, $actual );
+	}
+
+	/**
+	 * Message of a woocommerce.stagedWrite error.
+	 *
+	 * @param string $class_name  Class name.
+	 * @param string $method_name Method name.
+	 * @return string
+	 */
+	private static function write_message( string $class_name, string $method_name ): string {
+		return sprintf( 'Method %s::%s() writes outside save(). Move the in-memory change to a stage_*() twin, call it here, then save. See WC_Order::stage_status().', $class_name, $method_name );
+	}
+}
diff --git a/plugins/woocommerce/tests/Tools/phpstan/bootstrap.php b/plugins/woocommerce/tests/Tools/phpstan/bootstrap.php
new file mode 100644
index 00000000000..86b3eb82a2c
--- /dev/null
+++ b/plugins/woocommerce/tests/Tools/phpstan/bootstrap.php
@@ -0,0 +1,28 @@
+<?php
+/**
+ * Bootstrap for the PHPStan rule tests. PHPStan reflects test fixtures only through registered autoloaders.
+ *
+ * @package WooCommerce\Tests
+ */
+
+declare( strict_types=1 );
+
+require_once __DIR__ . '/../../../vendor/autoload.php';
+
+spl_autoload_register(
+	function ( string $class_name ): void {
+		$files = array(
+			'WC_Data'               => 'stubs.php',
+			'WC_Data_Store'         => 'stubs.php',
+			'Stage_Refusal_Fixture' => 'stage-refusal.php',
+			'Writes_Fixture'        => 'writes.php',
+			'Twins_Fixture'         => 'twins.php',
+			'Exempt_Fixture'        => 'exempt.php',
+			'Not_WC_Data_Fixture'   => 'exempt.php',
+		);
+
+		if ( isset( $files[ $class_name ] ) ) {
+			require_once __DIR__ . '/data/' . $files[ $class_name ];
+		}
+	}
+);
diff --git a/plugins/woocommerce/tests/Tools/phpstan/data/exempt.php b/plugins/woocommerce/tests/Tools/phpstan/data/exempt.php
new file mode 100644
index 00000000000..b1935281059
--- /dev/null
+++ b/plugins/woocommerce/tests/Tools/phpstan/data/exempt.php
@@ -0,0 +1,56 @@
+<?php
+
+class Exempt_Fixture extends WC_Data {
+	public function save() {
+		$this->data_store->update( $this );
+	}
+
+	public function create() {
+		$this->data_store->create( $this );
+	}
+
+	public function update() {
+		$this->data_store->update( $this );
+	}
+
+	public function save_items() {
+		$this->data_store->save_items( $this );
+	}
+
+	public function save_meta_data() {
+		$this->data_store->update_meta( $this );
+	}
+
+	public function apply_changes() {
+		$this->save();
+	}
+
+	public function delete( $force = false ) {
+		$this->data_store->delete( $this );
+	}
+
+	public function delete_meta_data_value( $key ) {
+		$this->data_store->delete_meta( $this, $key );
+	}
+
+	public function hold_applied_coupons() {
+		$this->data_store->update_usage( $this );
+	}
+
+	public static function save_static() {
+		WC_Data_Store::load( 'order' )->update_all();
+	}
+}
+
+class Not_WC_Data_Fixture {
+	private $data_store;
+
+	public function update_status() {
+		$this->data_store->update_status( $this );
+		wp_update_post( array( 'ID' => 1 ) );
+	}
+
+	public function stage_status() {
+		$this->status = 'done';
+	}
+}
diff --git a/plugins/woocommerce/tests/Tools/phpstan/data/stage-refusal.php b/plugins/woocommerce/tests/Tools/phpstan/data/stage-refusal.php
new file mode 100644
index 00000000000..22e0ddb44e1
--- /dev/null
+++ b/plugins/woocommerce/tests/Tools/phpstan/data/stage-refusal.php
@@ -0,0 +1,17 @@
+<?php
+
+class Stage_Refusal_Fixture extends WC_Data {
+	public function stage_good() {
+		$this->refuse_unstaged_override( 'good', 'stage_good' );
+		$this->data['good'] = true;
+	}
+
+	public function stage_late() {
+		$this->data['late'] = true;
+		$this->refuse_unstaged_override( 'late', 'stage_late' );
+	}
+
+	public function stage_missing() {
+		$this->data['missing'] = true;
+	}
+}
diff --git a/plugins/woocommerce/tests/Tools/phpstan/data/stubs.php b/plugins/woocommerce/tests/Tools/phpstan/data/stubs.php
new file mode 100644
index 00000000000..d2ca4b948d2
--- /dev/null
+++ b/plugins/woocommerce/tests/Tools/phpstan/data/stubs.php
@@ -0,0 +1,19 @@
+<?php
+
+class WC_Data {
+	protected $data_store;
+
+	public function save() {}
+
+	public function get_data_store() {
+		return $this->data_store;
+	}
+
+	protected function refuse_unstaged_override( string $method, string $stage_method ): void {}
+}
+
+class WC_Data_Store {
+	public static function load( string $type ) {
+		return new self();
+	}
+}
diff --git a/plugins/woocommerce/tests/Tools/phpstan/data/twins.php b/plugins/woocommerce/tests/Tools/phpstan/data/twins.php
new file mode 100644
index 00000000000..9a2e4370ba9
--- /dev/null
+++ b/plugins/woocommerce/tests/Tools/phpstan/data/twins.php
@@ -0,0 +1,32 @@
+<?php
+
+class Twins_Fixture extends WC_Data {
+	public function stage_status( $status ) {
+		$this->refuse_unstaged_override( 'update_status', 'stage_status' );
+		$this->data['status'] = $status;
+	}
+
+	public function stage_paid() {
+		$this->refuse_unstaged_override( 'mark_paid', 'stage_paid' );
+		$this->data['paid'] = true;
+	}
+
+	public function update_status( $status ) {
+		$this->write_through(
+			function () use ( $status ) {
+				return $this->stage_status( $status );
+			}
+		);
+		$this->save();
+	}
+
+	public function mark_paid() {
+		$this->stage_paid();
+		$this->save();
+	}
+
+	public function add_note( $note ) {
+		$this->stage_status( 'noted' );
+		$this->save();
+	}
+}
diff --git a/plugins/woocommerce/tests/Tools/phpstan/data/writes.php b/plugins/woocommerce/tests/Tools/phpstan/data/writes.php
new file mode 100644
index 00000000000..86a85001188
--- /dev/null
+++ b/plugins/woocommerce/tests/Tools/phpstan/data/writes.php
@@ -0,0 +1,35 @@
+<?php
+
+class Writes_Fixture extends WC_Data {
+	public function through_data_store_property() {
+		$this->data_store->update_status( $this );
+	}
+
+	public function through_loaded_data_store() {
+		$store = WC_Data_Store::load( 'order' );
+		$store->increase_count( $this );
+	}
+
+	public function through_get_data_store() {
+		$this->get_data_store()->set_flag( $this, true );
+	}
+
+	public function through_wpdb() {
+		global $wpdb;
+		$wpdb->query( 'UPDATE wp_posts SET post_status = 1' );
+	}
+
+	public function through_function() {
+		wp_update_post( array( 'ID' => 1 ) );
+	}
+
+	public function through_save() {
+		$this->set_prop( 'flag', true );
+		$this->save();
+	}
+
+	public function reads_only() {
+		$this->data_store->read_meta( $this );
+		return $this->get_data_store()->get_flag( $this );
+	}
+}
diff --git a/plugins/woocommerce/tests/Tools/phpstan/phpunit.xml b/plugins/woocommerce/tests/Tools/phpstan/phpunit.xml
new file mode 100644
index 00000000000..e9211befcb4
--- /dev/null
+++ b/plugins/woocommerce/tests/Tools/phpstan/phpunit.xml
@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+	bootstrap="bootstrap.php"
+	colors="true"
+	xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/9.3/phpunit.xsd">
+	<testsuites>
+		<testsuite name="phpstan-rules">
+			<directory suffix="Test.php">.</directory>
+		</testsuite>
+	</testsuites>
+</phpunit>