Commit 8628c85357b for woocommerce
commit 8628c85357b862139f8c7008aa0149bb89918fcd
Author: Mahangu Weerasinghe <mahangu@users.noreply.github.com>
Date: Sun Nov 15 10:43:31 2026 +0530
Add review guideline evals and paired compatibility cases (#69741)
* Add eval cases for 40 AI review guidelines
* Refine guideline eval fixtures after adversarial review
* Fix eval boundaries, assertions, and guard routing
* Document local guideline eval setup
* Warn about API credentials overriding eval subscription logins
* Fix seven guideline eval finding expectations
* Fix order ownership eval finding expectation
* Remove private experiment history from eval documentation
* Clarify guideline eval authentication setup
* Add paired compatibility evals and review guidance
* Call the optional eval group a pair of cases
* Add event-name and CSS cascade regression coverage
* Clarify guideline eval fixtures and controls
Add examples with explicit context and precise expectations while
preserving existing case versions.
* Fix guideline eval cases and registrations
* Clarify valid consumer locations in guideline evals
* Fix optional eval source setup and refresh corpus documentation
* Address guideline eval review feedback
* Refine guideline evals and cover hook re-entrancy
* Refine hook and lifecycle guideline eval fixtures
diff --git a/.ai/review/evals/README.md b/.ai/review/evals/README.md
new file mode 100644
index 00000000000..3edc9fdd90b
--- /dev/null
+++ b/.ai/review/evals/README.md
@@ -0,0 +1,166 @@
+# AI review guideline evals
+
+This corpus covers 41 review guidelines with two bug cases and at least two safe boundary cases per guideline, for 165 registered cases. The SQL suite also retains a separate safe-batching example, G25E-v1, alongside its neutral refactor control. The root `ai-review-config.json` links each guideline to its suite. The `perf-blocking-remote-call`, `order-storage-access`, and `payment-callbacks` guidelines are outside this corpus.
+
+The committed suites contain these 165 snapshot cases. [Optional prepared pairs](#event-name-contract-pair) add historical patch cases in separate generated checkouts.
+
+## Set up the runner
+
+These evals use the separate A8C review CLI. Model runs require a compatible CLI checkout and access to the configured review engines. Contributors can inspect and edit the JSON suites and source fixtures without running a model.
+
+Use a POSIX system with PHP 8.2+ and the `mbstring`, `posix`, and `pcntl` extensions. Install Composer, Node.js 22+, and Git. Replace the example path, then run these commands from your AI Review CLI checkout:
+
+```sh
+cd /path/to/ai-review-cli/ai-review-cli-v2
+composer install
+npm ci
+mkdir -p "$HOME/.local/bin"
+ln -s "$PWD/bin/a8c-review" "$HOME/.local/bin/a8c-review"
+export PATH="$HOME/.local/bin:$PATH"
+a8c-review eval --help
+```
+
+If `a8c-review` is already installed, check that `eval --help` works before creating the symlink. Keep the CLI checkout in place: the symlink points to it. To make the PATH setting permanent, add the `export PATH` line to your shell startup file, such as `~/.zshrc` or `~/.bashrc`.
+
+Running the full suite with this repository's settings requires Claude Code and Codex CLI. The `npm ci` command above installs both. Sign in to each with an account that has access to the configured models and enough quota.
+
+Exported API credentials take precedence over subscription logins. When `OPENAI_API_KEY` is set, the runner calls `codex login --with-api-key`, replacing the saved ChatGPT login. `ANTHROPIC_API_KEY` or `ANTHROPIC_AUTH_TOKEN` overrides the Claude subscription login. This can cause unintended API billing.
+
+To use your subscriptions, unset these variables in the shell used for authentication and evals:
+
+```sh
+unset OPENAI_API_KEY ANTHROPIC_API_KEY ANTHROPIC_AUTH_TOKEN
+```
+
+If you are not already signed in, run these commands from the AI Review CLI directory:
+
+```sh
+node_modules/.bin/claude
+# Run /login in Claude, then exit.
+node_modules/.bin/codex login
+```
+
+Existing logins can be reused. Live evals consume model usage.
+
+## Run locally
+
+From the WooCommerce repository root on a branch containing these evals:
+
+```sh
+a8c-review eval --plan
+a8c-review eval
+a8c-review eval --guideline sql-clause-filter-contract
+```
+
+`--plan` validates the definitions and reads the fixtures without calling models. In the committed checkout it reports 165 cases. Live runs inherit this repository's reviewers, models, and prompt content. Named-guideline evaluation requires a CLI version that confirms the full named guideline reaches its configured reviewers before inference. Older V0 execution uses ordinary review selection and can omit that guideline. They require credentials for both the configured Claude and Codex engines. No model override is introduced by these suites.
+
+An unfiltered `eval` selects every registered case. Use `--guideline`, `--case` or `--group` to narrow the selection.
+
+Each live run prints a new results directory in the system temporary directory. Save that directory elsewhere if you need to retain the evidence. To run just one case:
+
+```sh
+a8c-review eval --guideline hook-payload-contract --case G01A-v3
+```
+
+The default timeout is 600 seconds per reviewer attempt. `--timeout 300` changes it to 300 seconds. It is not a limit for the whole suite.
+
+If a case reports `Native review failed`, inspect its `stdout.json` and `stderr.log` beneath the printed results directory. An authentication or quota error means the review did not complete, not that the case failed its expectation. For a quota limit, stop the run with Ctrl+C and rerun after quota is available. `--plan` does not check authentication or quota.
+
+Each fixture directory contains before and after source files. The suite's `path` is the simulated WooCommerce source location used for routing and finding matching. These are focused source fixtures, not executable WordPress integration tests. Supporting callers, consumers and framework behavior establish the contract being tested.
+
+The runner creates an isolated Git repository containing the case file and configured review prompts. It does not copy the whole WooCommerce checkout. Include the surrounding code needed to establish a case's behavior in its before and after files.
+
+All current `finding` expectations use `locations`, which require the finding's primary file and line to match a listed location. A `quiet` expectation requires no findings, including suppressed ones. The runner also supports legacy `contains` checks, but these suites do not use them. An `introduced_by` anchor alone cannot satisfy it. Inspect the finding's reasoning against the fixture before treating the automated score as evidence of detection. Routing alone does not determine whether a case passes.
+
+## Event-name contract pair
+
+The optional [event-name pair](fix-and-test-verification/groups/event-name-contract/README.md) tests whether the reviewer distinguishes documented inputs from incidental coercion.
+
+Its preparation script creates a separate 167-case checkout from committed policy files and existing local source objects. The default 165 cases remain unchanged.
+
+The [hook re-entrancy suite](hook-callback-reentrancy/README.md) also includes a bounded local behavior check.
+
+## CSS cascade pair
+
+The optional [TextControl cascade pair](css-layout-regression/groups/text-control-cascade/README.md) checks the prior computed styles before reporting a new sizing regression.
+
+It replays the complete historical patch and a collapsed-input control from the same parent. Its preparation script creates a separate 167-case checkout. The default 165 cases remain unchanged.
+
+## Adding cases
+
+Keep every file for a guideline's cases under its suite directory, the directory holding its `cases.json`. The runner excludes that directory from the reviewed fixture, so expectations and notes there are not shown to the reviewer. Do not put expected results or case labels in source a reviewer can see. A suite can be laid out like this:
+
+```text
+<guideline>/
+├── cases.json
+├── fixtures/<case>/before.php
+├── fixtures/<case>/after.php
+└── groups/<group-id>/
+ ├── README.md
+ ├── regression/<change>.patch
+ └── controls/<change>.patch
+```
+
+A case ID is the stable name used for `--case`, results and comparisons between runs. Do not reuse or rename an ID once results have been reported for it. Give a revised case a new version suffix.
+
+A suite may optionally list `groups`. Each group names one `regression` case and a nonempty list of `controls` from the same suite. For example, the regression can reproduce a reviewer false positive, and the controls check that a fix still reports real problems. A case belongs to at most one group. Each case keeps its own expectation and is graded on its own. There is no combined group result. Keep a group's files under `groups/<group-id>/`, with an optional `README.md` describing the cases and their roles. Register the group itself only in `cases.json`. Run a group with `--group <group-id>`. Combine it with `--guideline` or `--case` to run only cases matching every option. Selecting only some of a group's cases, for example with `--case`, prints a warning naming the missing ones.
+
+Each entry in `groups` references IDs already declared in `cases`:
+
+```json
+{ "id": "example-group", "regression": "G00E-example-v1", "controls": ["G00A-example-v1"] }
+```
+
+Groups require a CLI checkout that supports them. Earlier versions reject a suite containing `groups`. Check that `a8c-review eval --help` lists `--group` before running such a suite. Suites without `groups`, including the committed 165 cases, also run on earlier inline-case versions.
+
+## Limitations
+
+Case IDs are versioned. Fixture directories may retain their original names. The suite identifies the current case version. Evaluate revised cases again before relying on their results.
+
+These cases exercise review behavior on selected changes. They do not measure production-wide accuracy or isolate a guideline's effect from the broader review prompt.
+
+## Coverage
+
+| Guideline | Bug cases | Safe boundary cases |
+| --- | --- | --- |
+| [hook-payload-contract](hook-payload-contract/cases.json) | G01A-v3, G01B-v4 | G01C-v3, G01D-v3 |
+| [hook-firing-scope](hook-firing-scope/cases.json) | G02A-v3, G02B-v3 | G02C-v3, G02D-v3 |
+| [php-loose-type-juggling](php-loose-type-juggling/cases.json) | G03A-v3, G03B-v3 | G03C-v3, G03D-v3 |
+| [public-php-api-bc](public-php-api-bc/cases.json) | G04A-v3, G04B-v3 | G04C-v3, G04D-v3 |
+| [upgrade-time-class-load](upgrade-time-class-load/cases.json) | G05A-v3, G05B-v3 | G05C-v3, G05D-v3 |
+| [action-scheduler-lifecycle](action-scheduler-lifecycle/cases.json) | G06A-v3, G06B-v3 | G06D-v5, G06C-v6 |
+| [request-input-validation](request-input-validation/cases.json) | G07A-v4, G07B-v3 | G07C-v3, G07D-v4 |
+| [missing-object-deref](missing-object-deref/cases.json) | G08A-v3, G08B-v3 | G08C-v3, G08D-v3 |
+| [meta-option-value-semantics](meta-option-value-semantics/cases.json) | G09A-v3, G09B-v3 | G09C-v3, G09D-v3 |
+| [hpos-sync-on-read](hpos-sync-on-read/cases.json) | G10A-v3, G10B-v4 | G10C-v3, G10D-v3 |
+| [migration-upgrade-state](migration-upgrade-state/cases.json) | G11A-v3, G11B-v3 | G11C-v3, G11D-v3 |
+| [proxy-predicate-inference](proxy-predicate-inference/cases.json) | G12A-v4, G12B-v3 | G12C-v3, G12D-v8 |
+| [wc-query-shop-front-page](wc-query-shop-front-page/cases.json) | G13A-v3, G13B-v3 | G13C-v4, G13D-v3 |
+| [request-lifecycle-state](request-lifecycle-state/cases.json) | G14A-v3, G14B-v5 | G14C-v3, G14D-v4 |
+| [template-and-js-default](template-and-js-default/cases.json) | G15A-v3, G15B-v3 | G15C-v3, G15D-v3 |
+| [rest-response-contract](rest-response-contract/cases.json) | G16A-v3, G16B-v3 | G16C-v4, G16D-v3 |
+| [session-cart-persistence](session-cart-persistence/cases.json) | G17A-v3, G17B-v3 | G17C-v3, G17D-v3 |
+| [check-then-write-race](check-then-write-race/cases.json) | G18A-v3, G18B-v3 | G18C-v3, G18D-v3 |
+| [order-totals-recalc](order-totals-recalc/cases.json) | G19B-v3, G19A-v5 | G19D-v3, G19C-v4 |
+| [shipping-rate-pipeline](shipping-rate-pipeline/cases.json) | G20A-v4, G20B-v4 | G20D-v4, G20C-v7 |
+| [sql-clause-filter-contract](sql-clause-filter-contract/cases.json) | G21A-v3, G21B-v3 | G21C-v3, G21D-v3 |
+| [cache-invalidation-and-poisoning](cache-invalidation-and-poisoning/cases.json) | G22A-v3, G22B-v3 | G22C-v4, G22D-v3 |
+| [perf-refactor-semantic-drift](perf-refactor-semantic-drift/cases.json) | G23A-v6, G23B-v4 | G23C-v3, G23D-v3 |
+| [fix-and-test-verification](fix-and-test-verification/cases.json) | G24A-v3, G24B-v3 | G24C-v4, G24D-v3 |
+| [perf-sql-query-shape](perf-sql-query-shape/cases.json) | G25A-v4, G25B-v3 | G25D-v3, G25C-v8, G25E-v1 |
+| [perf-cache-option-priming](perf-cache-option-priming/cases.json) | G26A-v3, G26B-v6 | G26C-v3, G26D-v3 |
+| [perf-hot-path-eager-work](perf-hot-path-eager-work/cases.json) | G27A-v4, G27B-v4 | G27C-v4, G27D-v4 |
+| [script-handle-loading-contract](script-handle-loading-contract/cases.json) | G28A-v3, G28B-v4 | G28C-v3, G28D-v4 |
+| [classic-jquery-contract](classic-jquery-contract/cases.json) | G29A-v3, G29B-v3 | G29C-v3, G29D-v4 |
+| [js-public-api-contract](js-public-api-contract/cases.json) | G30A-v3, G30B-v3 | G30C-v3, G30D-v3 |
+| [checkout-extensibility-payload](checkout-extensibility-payload/cases.json) | G31A-v3, G31B-v3 | G31C-v3, G31D-v3 |
+| [css-layout-regression](css-layout-regression/cases.json) | G32B-v3, G32A-v4 | G32C-v3, G32D-v3 |
+| [js-request-failure-handling](js-request-failure-handling/cases.json) | G33A-v3, G33B-v5 | G33C-v3, G33D-v3 |
+| [react-render-and-keyboard](react-render-and-keyboard/cases.json) | G34A-v3, G34B-v3 | G34C-v3, G34D-v3 |
+| [i18n-string-identity](i18n-string-identity/cases.json) | G35A-v3, G35B-v3 | G35C-v3, G35D-v3 |
+| [sec-sql-prepare-and-identifiers](sec-sql-prepare-and-identifiers/cases.json) | G36A-v3, G36B-v4 | G36C-v3, G36D-v3 |
+| [sec-rest-and-ajax-permissions](sec-rest-and-ajax-permissions/cases.json) | G37A-v5, G37B-v3 | G37C-v3, G37D-v3 |
+| [sec-order-cart-ownership](sec-order-cart-ownership/cases.json) | G38B-v3, G38A-v5 | G38C-v3, G38D-v3 |
+| [sec-sanitize-and-escape](sec-sanitize-and-escape/cases.json) | G39B-v3, G39A-v6 | G39C-v3, G39D-v3 |
+| [sec-auth-tokens-and-cookies](sec-auth-tokens-and-cookies/cases.json) | G40A-v3, G40B-v4 | G40C-v3, G40D-v3 |
+| [hook-callback-reentrancy](hook-callback-reentrancy/cases.json) | G41A-v2, G41B-v2 | G41C-v2, G41D-v1 |
diff --git a/.ai/review/evals/action-scheduler-lifecycle/cases.json b/.ai/review/evals/action-scheduler-lifecycle/cases.json
new file mode 100644
index 00000000000..9f2de765893
--- /dev/null
+++ b/.ai/review/evals/action-scheduler-lifecycle/cases.json
@@ -0,0 +1,77 @@
+{
+ "version": 1,
+ "cases": [
+ {
+ "id": "G06A-v3",
+ "path": "plugins/woocommerce/includes/class-wc-action-scheduler-lifecycle.php",
+ "before": {
+ "file": "fixtures/G06A/before.php"
+ },
+ "after": {
+ "file": "fixtures/G06A/after.php"
+ },
+ "expect": {
+ "outcome": "finding",
+ "locations": [
+ {
+ "file": "plugins/woocommerce/includes/class-wc-action-scheduler-lifecycle.php",
+ "lines": [
+ 5,
+ 5
+ ],
+ "anchor": " as_schedule_single_action( time() + 60, 'woocommerce_export_queue_tick', array(), 'wc-export', true );"
+ }
+ ]
+ }
+ },
+ {
+ "id": "G06B-v3",
+ "path": "plugins/woocommerce/includes/class-wc-action-scheduler-lifecycle.php",
+ "before": {
+ "file": "fixtures/G06B/before.php"
+ },
+ "after": {
+ "file": "fixtures/G06B/after.php"
+ },
+ "expect": {
+ "outcome": "finding",
+ "locations": [
+ {
+ "file": "plugins/woocommerce/includes/class-wc-action-scheduler-lifecycle.php",
+ "lines": [
+ 4,
+ 4
+ ],
+ "anchor": " as_schedule_recurring_action( time() + 60, 3600, 'woocommerce_export_cleanup', array(), 'wc-export' );"
+ }
+ ]
+ }
+ },
+ {
+ "id": "G06D-v5",
+ "path": "plugins/woocommerce/includes/class-wc-action-scheduler-lifecycle.php",
+ "before": {
+ "file": "fixtures/G06D/before.php"
+ },
+ "after": {
+ "file": "fixtures/G06D/after.php"
+ },
+ "expect": {
+ "outcome": "quiet"
+ }
+ },
+ {
+ "id": "G06C-v6",
+ "path": "plugins/woocommerce/includes/class-wc-action-scheduler-lifecycle.php",
+ "before": {
+ "code": "<?php\n// Runtime dependency: Action Scheduler 4.2.0 (WooCommerce composer.lock).\n/* Queue registration is loaded in web and runner bootstrap after action_scheduler_init. IDs address immutable saved export snapshots; repeated requests for the same ID request the same output. */\nfunction wc_queue_one_export( $id ) {\n /* Action Scheduler 4.2.0, DBStore::build_where_clause_for_insert, ref 9e2c6b02:\n * WHERE status IN ( $pending_status_placeholders ) AND hook = %s AND `group_id` = %d AND args = %s\n * The placeholders receive pending/running statuses, hook, group ID, and JSON-encoded arguments. */\n return as_enqueue_async_action( 'woocommerce_export_one', array( $id ), 'wc-export', false );\n}\nfunction wc_run_one_export( $id ) { wc_write_export_file( $id ); }\nadd_action( 'woocommerce_export_one', 'wc_run_one_export' );\n"
+ },
+ "after": {
+ "code": "<?php\n// Runtime dependency: Action Scheduler 4.2.0 (WooCommerce composer.lock).\n/* Queue registration is loaded in web and runner bootstrap after action_scheduler_init. IDs address immutable saved export snapshots; repeated requests for the same ID request the same output. */\nfunction wc_queue_one_export( $id ) {\n /* Action Scheduler 4.2.0, DBStore::build_where_clause_for_insert, ref 9e2c6b02:\n * WHERE status IN ( $pending_status_placeholders ) AND hook = %s AND `group_id` = %d AND args = %s\n * The placeholders receive pending/running statuses, hook, group ID, and JSON-encoded arguments. */\n return as_enqueue_async_action( 'woocommerce_export_one', array( $id ), 'wc-export', true );\n}\nfunction wc_run_one_export( $id ) { wc_write_export_file( $id ); }\nadd_action( 'woocommerce_export_one', 'wc_run_one_export' );\n"
+ },
+ "expect": {
+ "outcome": "quiet"
+ }
+ }
+ ]
+}
diff --git a/.ai/review/evals/action-scheduler-lifecycle/fixtures/G06A/after.php b/.ai/review/evals/action-scheduler-lifecycle/fixtures/G06A/after.php
new file mode 100644
index 00000000000..b36b48e5ef5
--- /dev/null
+++ b/.ai/review/evals/action-scheduler-lifecycle/fixtures/G06A/after.php
@@ -0,0 +1,7 @@
+<?php
+/* Queue registration is loaded in web and runner bootstrap after action_scheduler_init. */
+function wc_export_queue_tick() {
+ wc_process_export_batch();
+ as_schedule_single_action( time() + 60, 'woocommerce_export_queue_tick', array(), 'wc-export', true );
+}
+add_action( 'woocommerce_export_queue_tick', 'wc_export_queue_tick' );
diff --git a/.ai/review/evals/action-scheduler-lifecycle/fixtures/G06A/before.php b/.ai/review/evals/action-scheduler-lifecycle/fixtures/G06A/before.php
new file mode 100644
index 00000000000..b04a8bc3afc
--- /dev/null
+++ b/.ai/review/evals/action-scheduler-lifecycle/fixtures/G06A/before.php
@@ -0,0 +1,7 @@
+<?php
+/* Queue registration is loaded in web and runner bootstrap after action_scheduler_init. */
+function wc_export_queue_tick() {
+ wc_process_export_batch();
+ as_schedule_single_action( time() + 60, 'woocommerce_export_queue_tick', array(), 'wc-export', false );
+}
+add_action( 'woocommerce_export_queue_tick', 'wc_export_queue_tick' );
diff --git a/.ai/review/evals/action-scheduler-lifecycle/fixtures/G06B/after.php b/.ai/review/evals/action-scheduler-lifecycle/fixtures/G06B/after.php
new file mode 100644
index 00000000000..0cd51fe49f1
--- /dev/null
+++ b/.ai/review/evals/action-scheduler-lifecycle/fixtures/G06B/after.php
@@ -0,0 +1,7 @@
+<?php
+/* Queue registration is loaded in web and runner bootstrap. */
+function wc_register_export_cleanup() {
+ as_schedule_recurring_action( time() + 60, 3600, 'woocommerce_export_cleanup', array(), 'wc-export' );
+}
+add_action( 'action_scheduler_init', 'wc_register_export_cleanup' );
+add_action( 'woocommerce_export_cleanup', 'wc_cleanup_export_files' );
diff --git a/.ai/review/evals/action-scheduler-lifecycle/fixtures/G06B/before.php b/.ai/review/evals/action-scheduler-lifecycle/fixtures/G06B/before.php
new file mode 100644
index 00000000000..2c0665798db
--- /dev/null
+++ b/.ai/review/evals/action-scheduler-lifecycle/fixtures/G06B/before.php
@@ -0,0 +1,9 @@
+<?php
+/* Queue registration is loaded in web and runner bootstrap. */
+function wc_register_export_cleanup() {
+ if ( ! as_has_scheduled_action( 'woocommerce_export_cleanup', array(), 'wc-export' ) ) {
+ as_schedule_recurring_action( time() + 60, 3600, 'woocommerce_export_cleanup', array(), 'wc-export' );
+ }
+}
+add_action( 'action_scheduler_init', 'wc_register_export_cleanup' );
+add_action( 'woocommerce_export_cleanup', 'wc_cleanup_export_files' );
diff --git a/.ai/review/evals/action-scheduler-lifecycle/fixtures/G06D/after.php b/.ai/review/evals/action-scheduler-lifecycle/fixtures/G06D/after.php
new file mode 100644
index 00000000000..9106e4f4270
--- /dev/null
+++ b/.ai/review/evals/action-scheduler-lifecycle/fixtures/G06D/after.php
@@ -0,0 +1,29 @@
+<?php
+// Extension bootstrap is loaded in web requests and Action Scheduler runner requests.
+final class Order_Export_Queue {
+ public function register() {
+ add_action( 'action_scheduler_init', array( $this, 'register_producer' ) );
+ add_action( 'woocommerce_export_order', array( $this, 'run' ) );
+ }
+
+ public function register_producer() {
+ add_action( 'woocommerce_order_status_completed', array( $this, 'queue' ) );
+ }
+
+ public function queue( $order_id ) {
+ return as_enqueue_async_action( 'woocommerce_export_order', array( (int) $order_id ), 'wc-export' );
+ }
+
+ public function run( $order_id ) {
+ $order = wc_get_order( $order_id );
+ if ( ! $order ) { return; }
+ $order->update_meta_data( '_last_exported_status', $order->get_status() );
+ $order->save();
+ }
+}
+
+( new Order_Export_Queue() )->register();
+
+add_action( 'woocommerce_export_order', static function ( $order_id ) {
+ update_option( 'last_export_observed_order', $order_id );
+}, 20 );
diff --git a/.ai/review/evals/action-scheduler-lifecycle/fixtures/G06D/before.php b/.ai/review/evals/action-scheduler-lifecycle/fixtures/G06D/before.php
new file mode 100644
index 00000000000..62bb1801f04
--- /dev/null
+++ b/.ai/review/evals/action-scheduler-lifecycle/fixtures/G06D/before.php
@@ -0,0 +1,31 @@
+<?php
+// Extension bootstrap is loaded in web requests and Action Scheduler runner requests.
+final class Order_Export_Queue {
+ public function register() {
+ add_action( 'action_scheduler_init', array( $this, 'register_producer' ) );
+ if ( is_admin() ) {
+ add_action( 'woocommerce_export_order', array( $this, 'run' ) );
+ }
+ }
+
+ public function register_producer() {
+ add_action( 'woocommerce_order_status_completed', array( $this, 'queue' ) );
+ }
+
+ public function queue( $order_id ) {
+ return as_enqueue_async_action( 'woocommerce_export_order', array( (int) $order_id ), 'wc-export' );
+ }
+
+ public function run( $order_id ) {
+ $order = wc_get_order( $order_id );
+ if ( ! $order ) { return; }
+ $order->update_meta_data( '_last_exported_status', $order->get_status() );
+ $order->save();
+ }
+}
+
+( new Order_Export_Queue() )->register();
+
+add_action( 'woocommerce_export_order', static function ( $order_id ) {
+ update_option( 'last_export_observed_order', $order_id );
+}, 20 );
diff --git a/.ai/review/evals/cache-invalidation-and-poisoning/cases.json b/.ai/review/evals/cache-invalidation-and-poisoning/cases.json
new file mode 100644
index 00000000000..34ad831f9f5
--- /dev/null
+++ b/.ai/review/evals/cache-invalidation-and-poisoning/cases.json
@@ -0,0 +1,77 @@
+{
+ "version": 1,
+ "cases": [
+ {
+ "id": "G22A-v3",
+ "path": "plugins/woocommerce/includes/class-wc-eval-22.php",
+ "before": {
+ "file": "fixtures/G22A/before.php"
+ },
+ "after": {
+ "file": "fixtures/G22A/after.php"
+ },
+ "expect": {
+ "outcome": "finding",
+ "locations": [
+ {
+ "file": "plugins/woocommerce/includes/class-wc-eval-22.php",
+ "lines": [
+ 4,
+ 4
+ ],
+ "anchor": " $key = $id;"
+ }
+ ]
+ }
+ },
+ {
+ "id": "G22B-v3",
+ "path": "plugins/woocommerce/includes/class-wc-eval-22.php",
+ "before": {
+ "file": "fixtures/G22B/before.php"
+ },
+ "after": {
+ "file": "fixtures/G22B/after.php"
+ },
+ "expect": {
+ "outcome": "finding",
+ "locations": [
+ {
+ "file": "plugins/woocommerce/includes/class-wc-eval-22.php",
+ "lines": [
+ 12,
+ 12
+ ],
+ "anchor": " wp_cache_delete( $id, 'stock_label' );"
+ }
+ ]
+ }
+ },
+ {
+ "id": "G22C-v4",
+ "path": "plugins/woocommerce/includes/class-wc-eval-22.php",
+ "before": {
+ "file": "fixtures/G22C-v4/before.php"
+ },
+ "after": {
+ "file": "fixtures/G22C-v4/after.php"
+ },
+ "expect": {
+ "outcome": "quiet"
+ }
+ },
+ {
+ "id": "G22D-v3",
+ "path": "plugins/woocommerce/includes/class-wc-eval-22.php",
+ "before": {
+ "file": "fixtures/G22D/before.php"
+ },
+ "after": {
+ "file": "fixtures/G22D/after.php"
+ },
+ "expect": {
+ "outcome": "quiet"
+ }
+ }
+ ]
+}
diff --git a/.ai/review/evals/cache-invalidation-and-poisoning/fixtures/G22A/after.php b/.ai/review/evals/cache-invalidation-and-poisoning/fixtures/G22A/after.php
new file mode 100644
index 00000000000..e4acb129d44
--- /dev/null
+++ b/.ai/review/evals/cache-invalidation-and-poisoning/fixtures/G22A/after.php
@@ -0,0 +1,10 @@
+<?php
+/* Persistent object cache. Catalog prices and exchange rates are fixed during the 60-second cache window. */
+function wc_price_for_currency( $id, $currency ) {
+ $key = $id;
+ $value = wp_cache_get( $key, 'converted_prices', false, $found );
+ if ( $found ) { return $value; }
+ $value = convert_catalog_price( $id, $currency );
+ wp_cache_set( $key, $value, 'converted_prices', 60 );
+ return $value;
+}
diff --git a/.ai/review/evals/cache-invalidation-and-poisoning/fixtures/G22A/before.php b/.ai/review/evals/cache-invalidation-and-poisoning/fixtures/G22A/before.php
new file mode 100644
index 00000000000..ac4a4285fdf
--- /dev/null
+++ b/.ai/review/evals/cache-invalidation-and-poisoning/fixtures/G22A/before.php
@@ -0,0 +1,10 @@
+<?php
+/* Persistent object cache. Catalog prices and exchange rates are fixed during the 60-second cache window. */
+function wc_price_for_currency( $id, $currency ) {
+ $key = $id . ':' . $currency;
+ $value = wp_cache_get( $key, 'converted_prices', false, $found );
+ if ( $found ) { return $value; }
+ $value = convert_catalog_price( $id, $currency );
+ wp_cache_set( $key, $value, 'converted_prices', 60 );
+ return $value;
+}
diff --git a/.ai/review/evals/cache-invalidation-and-poisoning/fixtures/G22B/after.php b/.ai/review/evals/cache-invalidation-and-poisoning/fixtures/G22B/after.php
new file mode 100644
index 00000000000..03f1f8b7410
--- /dev/null
+++ b/.ai/review/evals/cache-invalidation-and-poisoning/fixtures/G22B/after.php
@@ -0,0 +1,13 @@
+<?php
+/* Persistent object cache. This isolated stock-label subsystem has one writer, shown below. */
+function wc_stock_label( $id ) {
+ $value = wp_cache_get( $id, 'stock_labels', false, $found );
+ if ( $found ) { return $value; }
+ $value = (int) get_post_meta( $id, '_stock', true );
+ wp_cache_set( $id, $value, 'stock_labels' );
+ return $value;
+}
+function wc_write_stock_label( $id, $value ) {
+ update_post_meta( $id, '_stock', $value );
+ wp_cache_delete( $id, 'stock_label' );
+}
diff --git a/.ai/review/evals/cache-invalidation-and-poisoning/fixtures/G22B/before.php b/.ai/review/evals/cache-invalidation-and-poisoning/fixtures/G22B/before.php
new file mode 100644
index 00000000000..ac2b67f6053
--- /dev/null
+++ b/.ai/review/evals/cache-invalidation-and-poisoning/fixtures/G22B/before.php
@@ -0,0 +1,13 @@
+<?php
+/* Persistent object cache. This isolated stock-label subsystem has one writer, shown below. */
+function wc_stock_label( $id ) {
+ $value = wp_cache_get( $id, 'stock_labels', false, $found );
+ if ( $found ) { return $value; }
+ $value = (int) get_post_meta( $id, '_stock', true );
+ wp_cache_set( $id, $value, 'stock_labels' );
+ return $value;
+}
+function wc_write_stock_label( $id, $value ) {
+ update_post_meta( $id, '_stock', $value );
+ wp_cache_delete( $id, 'stock_labels' );
+}
diff --git a/.ai/review/evals/cache-invalidation-and-poisoning/fixtures/G22C-v4/after.php b/.ai/review/evals/cache-invalidation-and-poisoning/fixtures/G22C-v4/after.php
new file mode 100644
index 00000000000..739030ef24e
--- /dev/null
+++ b/.ai/review/evals/cache-invalidation-and-poisoning/fixtures/G22C-v4/after.php
@@ -0,0 +1,21 @@
+<?php
+final class WC_Checkout_Label_Map {
+ private $labels;
+ private $memo = array();
+
+ public function __construct( array $labels ) {
+ $this->labels = array_map( 'strval', array_slice( $labels, 0, 10, true ) );
+ }
+
+ private function normalize( $label ) {
+ return trim( preg_replace( '/\s+/u', ' ', html_entity_decode( $label, ENT_QUOTES, 'UTF-8' ) ) );
+ }
+
+ public function get( $id ) {
+ if ( ! array_key_exists( $id, $this->labels ) ) { return ''; }
+ if ( ! array_key_exists( $id, $this->memo ) ) {
+ $this->memo[ $id ] = $this->normalize( $this->labels[ $id ] );
+ }
+ return $this->memo[ $id ];
+ }
+}
diff --git a/.ai/review/evals/cache-invalidation-and-poisoning/fixtures/G22C-v4/before.php b/.ai/review/evals/cache-invalidation-and-poisoning/fixtures/G22C-v4/before.php
new file mode 100644
index 00000000000..3a7eb2d08f4
--- /dev/null
+++ b/.ai/review/evals/cache-invalidation-and-poisoning/fixtures/G22C-v4/before.php
@@ -0,0 +1,17 @@
+<?php
+final class WC_Checkout_Label_Map {
+ private $labels;
+
+ public function __construct( array $labels ) {
+ $this->labels = array_map( 'strval', array_slice( $labels, 0, 10, true ) );
+ }
+
+ private function normalize( $label ) {
+ return trim( preg_replace( '/\s+/u', ' ', html_entity_decode( $label, ENT_QUOTES, 'UTF-8' ) ) );
+ }
+
+ public function get( $id ) {
+ if ( ! array_key_exists( $id, $this->labels ) ) { return ''; }
+ return $this->normalize( $this->labels[ $id ] );
+ }
+}
diff --git a/.ai/review/evals/cache-invalidation-and-poisoning/fixtures/G22D/after.php b/.ai/review/evals/cache-invalidation-and-poisoning/fixtures/G22D/after.php
new file mode 100644
index 00000000000..51905c2b0e8
--- /dev/null
+++ b/.ai/review/evals/cache-invalidation-and-poisoning/fixtures/G22D/after.php
@@ -0,0 +1,13 @@
+<?php
+/* Persistent object cache. This isolated stock-label subsystem has one writer, shown below. */
+function wc_stock_label( $id ) {
+ $value = wp_cache_get( $id, 'stock_labels', false, $found );
+ if ( true === $found ) { return (int) $value; }
+ $value = (int) get_post_meta( $id, '_stock', true );
+ wp_cache_set( $id, $value, 'stock_labels' );
+ return $value;
+}
+function wc_write_stock_label( $id, $value ) {
+ update_post_meta( $id, '_stock', $value );
+ wp_cache_delete( $id, 'stock_labels' );
+}
diff --git a/.ai/review/evals/cache-invalidation-and-poisoning/fixtures/G22D/before.php b/.ai/review/evals/cache-invalidation-and-poisoning/fixtures/G22D/before.php
new file mode 100644
index 00000000000..ac2b67f6053
--- /dev/null
+++ b/.ai/review/evals/cache-invalidation-and-poisoning/fixtures/G22D/before.php
@@ -0,0 +1,13 @@
+<?php
+/* Persistent object cache. This isolated stock-label subsystem has one writer, shown below. */
+function wc_stock_label( $id ) {
+ $value = wp_cache_get( $id, 'stock_labels', false, $found );
+ if ( $found ) { return $value; }
+ $value = (int) get_post_meta( $id, '_stock', true );
+ wp_cache_set( $id, $value, 'stock_labels' );
+ return $value;
+}
+function wc_write_stock_label( $id, $value ) {
+ update_post_meta( $id, '_stock', $value );
+ wp_cache_delete( $id, 'stock_labels' );
+}
diff --git a/.ai/review/evals/check-then-write-race/cases.json b/.ai/review/evals/check-then-write-race/cases.json
new file mode 100644
index 00000000000..bc6c161d821
--- /dev/null
+++ b/.ai/review/evals/check-then-write-race/cases.json
@@ -0,0 +1,77 @@
+{
+ "version": 1,
+ "cases": [
+ {
+ "id": "G18A-v3",
+ "path": "plugins/woocommerce/src/Internal/Stock/Reservation.php",
+ "before": {
+ "file": "fixtures/G18A/before.php"
+ },
+ "after": {
+ "file": "fixtures/G18A/after.php"
+ },
+ "expect": {
+ "outcome": "finding",
+ "locations": [
+ {
+ "file": "plugins/woocommerce/src/Internal/Stock/Reservation.php",
+ "lines": [
+ 4,
+ 6
+ ],
+ "anchor": " $quantity = (int) $db->get_var( $db->prepare( 'SELECT quantity FROM stock WHERE product_id = %d', $product_id ) );"
+ }
+ ]
+ }
+ },
+ {
+ "id": "G18B-v3",
+ "path": "plugins/woocommerce/includes/coupon-usage.php",
+ "before": {
+ "file": "fixtures/G18B/before.php"
+ },
+ "after": {
+ "file": "fixtures/G18B/after.php"
+ },
+ "expect": {
+ "outcome": "finding",
+ "locations": [
+ {
+ "file": "plugins/woocommerce/includes/coupon-usage.php",
+ "lines": [
+ 4,
+ 6
+ ],
+ "anchor": " $row = $db->read_coupon( $coupon_id );"
+ }
+ ]
+ }
+ },
+ {
+ "id": "G18C-v3",
+ "path": "plugins/woocommerce/src/Internal/Stock/Reservation.php",
+ "before": {
+ "file": "fixtures/G18C/before.php"
+ },
+ "after": {
+ "file": "fixtures/G18C/after.php"
+ },
+ "expect": {
+ "outcome": "quiet"
+ }
+ },
+ {
+ "id": "G18D-v3",
+ "path": "plugins/woocommerce/includes/coupon-usage.php",
+ "before": {
+ "file": "fixtures/G18D/before.php"
+ },
+ "after": {
+ "file": "fixtures/G18D/after.php"
+ },
+ "expect": {
+ "outcome": "quiet"
+ }
+ }
+ ]
+}
diff --git a/.ai/review/evals/check-then-write-race/fixtures/G18A/after.php b/.ai/review/evals/check-then-write-race/fixtures/G18A/after.php
new file mode 100644
index 00000000000..3c13e04af92
--- /dev/null
+++ b/.ai/review/evals/check-then-write-race/fixtures/G18A/after.php
@@ -0,0 +1,7 @@
+<?php
+/* Stock table has PRIMARY KEY(product_id) and integer quantity. Called by checkout requests. */
+function reserve_stock_unit( $db, $product_id ) {
+ $quantity = (int) $db->get_var( $db->prepare( 'SELECT quantity FROM stock WHERE product_id = %d', $product_id ) );
+ if ( $quantity < 1 ) { return false; }
+ return 1 === $db->query( $db->prepare( 'UPDATE stock SET quantity = quantity - 1 WHERE product_id = %d', $product_id ) );
+}
diff --git a/.ai/review/evals/check-then-write-race/fixtures/G18A/before.php b/.ai/review/evals/check-then-write-race/fixtures/G18A/before.php
new file mode 100644
index 00000000000..16380b82530
--- /dev/null
+++ b/.ai/review/evals/check-then-write-race/fixtures/G18A/before.php
@@ -0,0 +1,5 @@
+<?php
+/* Stock table has PRIMARY KEY(product_id) and integer quantity. Called by checkout requests. */
+function reserve_stock_unit( $db, $product_id ) {
+ return 1 === $db->query( $db->prepare( 'UPDATE stock SET quantity = quantity - 1 WHERE product_id = %d AND quantity > 0', $product_id ) );
+}
diff --git a/.ai/review/evals/check-then-write-race/fixtures/G18B/after.php b/.ai/review/evals/check-then-write-race/fixtures/G18B/after.php
new file mode 100644
index 00000000000..9901dc3edb3
--- /dev/null
+++ b/.ai/review/evals/check-then-write-race/fixtures/G18B/after.php
@@ -0,0 +1,8 @@
+<?php
+/* Coupon redemption adapter. lock serializes callbacks across processes per key and releases in finally. DB read/write operations do not lock. */
+function increase_usage_count( $db, $coupon_id, $lock ) {
+ $row = $db->read_coupon( $coupon_id );
+ if ( $row['usage_count'] >= $row['usage_limit'] ) { return false; }
+ $db->write_usage_count( $coupon_id, $row['usage_count'] + 1 );
+ return true;
+}
diff --git a/.ai/review/evals/check-then-write-race/fixtures/G18B/before.php b/.ai/review/evals/check-then-write-race/fixtures/G18B/before.php
new file mode 100644
index 00000000000..a1da1410167
--- /dev/null
+++ b/.ai/review/evals/check-then-write-race/fixtures/G18B/before.php
@@ -0,0 +1,10 @@
+<?php
+/* Coupon redemption adapter. lock serializes callbacks across processes per key and releases in finally. DB read/write operations do not lock. */
+function increase_usage_count( $db, $coupon_id, $lock ) {
+ return $lock( 'coupon:' . $coupon_id, static function () use ( $db, $coupon_id ) {
+ $row = $db->read_coupon( $coupon_id );
+ if ( $row['usage_count'] >= $row['usage_limit'] ) { return false; }
+ $db->write_usage_count( $coupon_id, $row['usage_count'] + 1 );
+ return true;
+ } );
+}
diff --git a/.ai/review/evals/check-then-write-race/fixtures/G18C/after.php b/.ai/review/evals/check-then-write-race/fixtures/G18C/after.php
new file mode 100644
index 00000000000..305ae9d3ad6
--- /dev/null
+++ b/.ai/review/evals/check-then-write-race/fixtures/G18C/after.php
@@ -0,0 +1,6 @@
+<?php
+/* Stock table has PRIMARY KEY(product_id) and integer quantity. Called by checkout requests. */
+function reserve_stock_unit( $db, $product_id ) {
+ $sql = $db->prepare( 'UPDATE stock SET quantity = quantity - 1 WHERE product_id = %d AND quantity >= 1', $product_id );
+ return 1 === $db->query( $sql );
+}
diff --git a/.ai/review/evals/check-then-write-race/fixtures/G18C/before.php b/.ai/review/evals/check-then-write-race/fixtures/G18C/before.php
new file mode 100644
index 00000000000..16380b82530
--- /dev/null
+++ b/.ai/review/evals/check-then-write-race/fixtures/G18C/before.php
@@ -0,0 +1,5 @@
+<?php
+/* Stock table has PRIMARY KEY(product_id) and integer quantity. Called by checkout requests. */
+function reserve_stock_unit( $db, $product_id ) {
+ return 1 === $db->query( $db->prepare( 'UPDATE stock SET quantity = quantity - 1 WHERE product_id = %d AND quantity > 0', $product_id ) );
+}
diff --git a/.ai/review/evals/check-then-write-race/fixtures/G18D/after.php b/.ai/review/evals/check-then-write-race/fixtures/G18D/after.php
new file mode 100644
index 00000000000..6a0b1fc36e0
--- /dev/null
+++ b/.ai/review/evals/check-then-write-race/fixtures/G18D/after.php
@@ -0,0 +1,5 @@
+<?php
+// coupon_usage has PRIMARY KEY(coupon_id); counts and limits are nonnegative integers.
+function increase_usage_count( $db, $coupon_id, $lock ) {
+ return 1 === $db->query( $db->prepare( 'UPDATE coupon_usage SET usage_count = usage_count + 1 WHERE coupon_id = %d AND usage_count < usage_limit', $coupon_id ) );
+}
diff --git a/.ai/review/evals/check-then-write-race/fixtures/G18D/before.php b/.ai/review/evals/check-then-write-race/fixtures/G18D/before.php
new file mode 100644
index 00000000000..fcf7d920422
--- /dev/null
+++ b/.ai/review/evals/check-then-write-race/fixtures/G18D/before.php
@@ -0,0 +1,9 @@
+<?php
+// coupon_usage has PRIMARY KEY(coupon_id); counts and limits are nonnegative integers.
+function increase_usage_count( $db, $coupon_id, $lock ) {
+ return $lock( 'coupon:' . $coupon_id, static function () use ( $db, $coupon_id ) {
+ $row = $db->get_row( $db->prepare( 'SELECT usage_count, usage_limit FROM coupon_usage WHERE coupon_id = %d', $coupon_id ), ARRAY_A );
+ if ( ! $row || $row['usage_count'] >= $row['usage_limit'] ) { return false; }
+ return 1 === $db->query( $db->prepare( 'UPDATE coupon_usage SET usage_count = usage_count + 1 WHERE coupon_id = %d', $coupon_id ) );
+ } );
+}
diff --git a/.ai/review/evals/checkout-extensibility-payload/cases.json b/.ai/review/evals/checkout-extensibility-payload/cases.json
new file mode 100644
index 00000000000..8ee1c9448ac
--- /dev/null
+++ b/.ai/review/evals/checkout-extensibility-payload/cases.json
@@ -0,0 +1,77 @@
+{
+ "version": 1,
+ "cases": [
+ {
+ "id": "G31A-v3",
+ "path": "plugins/woocommerce/client/blocks/assets/js/blocks/checkout/extensions.js",
+ "before": {
+ "file": "fixtures/G31A/before.js"
+ },
+ "after": {
+ "file": "fixtures/G31A/after.js"
+ },
+ "expect": {
+ "outcome": "finding",
+ "locations": [
+ {
+ "file": "plugins/woocommerce/client/blocks/assets/js/blocks/checkout/extensions.js",
+ "lines": [
+ 2,
+ 2
+ ],
+ "anchor": "export function extensionAmount( extensions ) { const raw = extensions['sample-shipping'].amount; return typeof raw === 'number' && Number.isFinite( raw ) ? raw : 0; }"
+ }
+ ]
+ }
+ },
+ {
+ "id": "G31B-v3",
+ "path": "plugins/woocommerce/client/blocks/assets/js/blocks/checkout/extensions.js",
+ "before": {
+ "file": "fixtures/G31B/before.js"
+ },
+ "after": {
+ "file": "fixtures/G31B/after.js"
+ },
+ "expect": {
+ "outcome": "finding",
+ "locations": [
+ {
+ "file": "plugins/woocommerce/client/blocks/assets/js/blocks/checkout/extensions.js",
+ "lines": [
+ 4,
+ 4
+ ],
+ "anchor": " return additionalFields.filter( ( field ) => field.hidden !== true );"
+ }
+ ]
+ }
+ },
+ {
+ "id": "G31C-v3",
+ "path": "plugins/woocommerce/client/blocks/assets/js/blocks/checkout/extensions.js",
+ "before": {
+ "file": "fixtures/G31C/before.js"
+ },
+ "after": {
+ "file": "fixtures/G31C/after.js"
+ },
+ "expect": {
+ "outcome": "quiet"
+ }
+ },
+ {
+ "id": "G31D-v3",
+ "path": "plugins/woocommerce/client/blocks/assets/js/blocks/checkout/extensions.js",
+ "before": {
+ "file": "fixtures/G31D/before.js"
+ },
+ "after": {
+ "file": "fixtures/G31D/after.js"
+ },
+ "expect": {
+ "outcome": "quiet"
+ }
+ }
+ ]
+}
diff --git a/.ai/review/evals/checkout-extensibility-payload/fixtures/G31A/after.js b/.ai/review/evals/checkout-extensibility-payload/fixtures/G31A/after.js
new file mode 100644
index 00000000000..11098f14f3c
--- /dev/null
+++ b/.ai/review/evals/checkout-extensibility-payload/fixtures/G31A/after.js
@@ -0,0 +1,4 @@
+/* Checkout extension fee preview. Registered sample-shipping schema permits a finite number or decimal string for amount. */
+export function extensionAmount( extensions ) { const raw = extensions['sample-shipping'].amount; return typeof raw === 'number' && Number.isFinite( raw ) ? raw : 0; }
+
+export const extensionPayload = JSON.parse( '{"sample-shipping":{"amount":"5.00"}}' );
diff --git a/.ai/review/evals/checkout-extensibility-payload/fixtures/G31A/before.js b/.ai/review/evals/checkout-extensibility-payload/fixtures/G31A/before.js
new file mode 100644
index 00000000000..7905788628d
--- /dev/null
+++ b/.ai/review/evals/checkout-extensibility-payload/fixtures/G31A/before.js
@@ -0,0 +1,4 @@
+/* Checkout extension fee preview. Registered sample-shipping schema permits a finite number or decimal string for amount. */
+export function extensionAmount( extensions ) { const raw = extensions['sample-shipping'].amount; const value = Number( raw ); return Number.isFinite( value ) ? value : 0; }
+
+export const extensionPayload = JSON.parse( '{"sample-shipping":{"amount":"5.00"}}' );
diff --git a/.ai/review/evals/checkout-extensibility-payload/fixtures/G31B/after.js b/.ai/review/evals/checkout-extensibility-payload/fixtures/G31B/after.js
new file mode 100644
index 00000000000..b19553e32cf
--- /dev/null
+++ b/.ai/review/evals/checkout-extensibility-payload/fixtures/G31B/after.js
@@ -0,0 +1,8 @@
+/* Private extension adapter. Field definitions are serialized by the extension, not the WooCommerce Additional Fields API. */
+const extensionResponse = JSON.parse( '{"fields":[{"id":"reference","hidden":1},{"id":"note","hidden":0}]}' );
+export function visibleAdditionalFields( additionalFields ) {
+ return additionalFields.filter( ( field ) => field.hidden !== true );
+}
+export function renderExtensionFields( render ) {
+ visibleAdditionalFields( extensionResponse.fields ).forEach( ( field ) => render( field.id ) );
+}
diff --git a/.ai/review/evals/checkout-extensibility-payload/fixtures/G31B/before.js b/.ai/review/evals/checkout-extensibility-payload/fixtures/G31B/before.js
new file mode 100644
index 00000000000..d134e98138d
--- /dev/null
+++ b/.ai/review/evals/checkout-extensibility-payload/fixtures/G31B/before.js
@@ -0,0 +1,8 @@
+/* Private extension adapter. Field definitions are serialized by the extension, not the WooCommerce Additional Fields API. */
+const extensionResponse = JSON.parse( '{"fields":[{"id":"reference","hidden":1},{"id":"note","hidden":0}]}' );
+export function visibleAdditionalFields( additionalFields ) {
+ return additionalFields.filter( ( field ) => ! field.hidden );
+}
+export function renderExtensionFields( render ) {
+ visibleAdditionalFields( extensionResponse.fields ).forEach( ( field ) => render( field.id ) );
+}
diff --git a/.ai/review/evals/checkout-extensibility-payload/fixtures/G31C/after.js b/.ai/review/evals/checkout-extensibility-payload/fixtures/G31C/after.js
new file mode 100644
index 00000000000..043b74cbd1e
--- /dev/null
+++ b/.ai/review/evals/checkout-extensibility-payload/fixtures/G31C/after.js
@@ -0,0 +1,8 @@
+/* Core-owned private checkout reducer; extension payloads do not enter this state. */
+function normalizeCoreTotal( payload ) { return typeof payload.total === 'number' && Number.isFinite( payload.total ) ? payload.total : 0; }
+function renderCoreTotal( payload ) { return normalizeCoreTotal( payload ); }
+
+export function selectTotal( linePrices ) {
+ const total = linePrices.reduce( ( sum, price ) => sum + Number( price ), 0 );
+ return renderCoreTotal( { total: Number.isFinite( total ) ? total : 0 } );
+}
diff --git a/.ai/review/evals/checkout-extensibility-payload/fixtures/G31C/before.js b/.ai/review/evals/checkout-extensibility-payload/fixtures/G31C/before.js
new file mode 100644
index 00000000000..0c4bad1937d
--- /dev/null
+++ b/.ai/review/evals/checkout-extensibility-payload/fixtures/G31C/before.js
@@ -0,0 +1,8 @@
+/* Core-owned private checkout reducer; extension payloads do not enter this state. */
+function normalizeCoreTotal( payload ) { return Number( payload.total ); }
+function renderCoreTotal( payload ) { return normalizeCoreTotal( payload ); }
+
+export function selectTotal( linePrices ) {
+ const total = linePrices.reduce( ( sum, price ) => sum + Number( price ), 0 );
+ return renderCoreTotal( { total: Number.isFinite( total ) ? total : 0 } );
+}
diff --git a/.ai/review/evals/checkout-extensibility-payload/fixtures/G31D/after.js b/.ai/review/evals/checkout-extensibility-payload/fixtures/G31D/after.js
new file mode 100644
index 00000000000..beec0f9e4e1
--- /dev/null
+++ b/.ai/review/evals/checkout-extensibility-payload/fixtures/G31D/after.js
@@ -0,0 +1,4 @@
+/* Checkout extension fee preview. Registered sample-shipping schema permits a finite number or decimal string for amount. */
+export function extensionAmount( extensions ) { const raw = extensions['sample-shipping'].amount; if ( typeof raw !== 'number' && typeof raw !== 'string' ) { return 0; } const value = Number( raw ); return Number.isFinite( value ) ? value : 0; }
+
+export const extensionPayload = JSON.parse( '{"sample-shipping":{"amount":"5.00"}}' );
diff --git a/.ai/review/evals/checkout-extensibility-payload/fixtures/G31D/before.js b/.ai/review/evals/checkout-extensibility-payload/fixtures/G31D/before.js
new file mode 100644
index 00000000000..7905788628d
--- /dev/null
+++ b/.ai/review/evals/checkout-extensibility-payload/fixtures/G31D/before.js
@@ -0,0 +1,4 @@
+/* Checkout extension fee preview. Registered sample-shipping schema permits a finite number or decimal string for amount. */
+export function extensionAmount( extensions ) { const raw = extensions['sample-shipping'].amount; const value = Number( raw ); return Number.isFinite( value ) ? value : 0; }
+
+export const extensionPayload = JSON.parse( '{"sample-shipping":{"amount":"5.00"}}' );
diff --git a/.ai/review/evals/classic-jquery-contract/cases.json b/.ai/review/evals/classic-jquery-contract/cases.json
new file mode 100644
index 00000000000..9f2883cff20
--- /dev/null
+++ b/.ai/review/evals/classic-jquery-contract/cases.json
@@ -0,0 +1,77 @@
+{
+ "version": 1,
+ "cases": [
+ {
+ "id": "G29A-v3",
+ "path": "plugins/woocommerce/client/legacy/js/frontend/checkout.js",
+ "before": {
+ "file": "fixtures/G29A/before.js"
+ },
+ "after": {
+ "file": "fixtures/G29A/after.js"
+ },
+ "expect": {
+ "outcome": "finding",
+ "locations": [
+ {
+ "file": "plugins/woocommerce/client/legacy/js/frontend/checkout.js",
+ "lines": [
+ 2,
+ 2
+ ],
+ "anchor": "function updateCheckout( address ) { return jQuery.ajax( { url: wc_checkout_params.wc_ajax_url, type: 'POST', data: jQuery.param( { security: wc_checkout_params.update_order_review_nonce, country: address.country, postcode: address.postcode } ) } ); }"
+ }
+ ]
+ }
+ },
+ {
+ "id": "G29B-v3",
+ "path": "plugins/woocommerce/client/legacy/js/frontend/checkout.js",
+ "before": {
+ "file": "fixtures/G29B/before.js"
+ },
+ "after": {
+ "file": "fixtures/G29B/after.js"
+ },
+ "expect": {
+ "outcome": "finding",
+ "locations": [
+ {
+ "file": "plugins/woocommerce/client/legacy/js/frontend/checkout.js",
+ "lines": [
+ 2,
+ 2
+ ],
+ "anchor": "function notifyAddedToCart( fragments, cartHash, button ) { jQuery( document ).trigger( 'added_to_cart', [ fragments, cartHash, button ] ); }"
+ }
+ ]
+ }
+ },
+ {
+ "id": "G29C-v3",
+ "path": "plugins/woocommerce/client/legacy/js/frontend/checkout.js",
+ "before": {
+ "file": "fixtures/G29C/before.js"
+ },
+ "after": {
+ "file": "fixtures/G29C/after.js"
+ },
+ "expect": {
+ "outcome": "quiet"
+ }
+ },
+ {
+ "id": "G29D-v4",
+ "path": "plugins/woocommerce/client/legacy/js/frontend/checkout.js",
+ "before": {
+ "code": "/* Classic cart notification. */\nfunction notifyAddedToCart( fragments, cartHash, button ) { jQuery( document.body ).trigger( 'added_to_cart', [ fragments, cartHash, button ] ); }\n"
+ },
+ "after": {
+ "code": "/* Classic cart notification. */\nfunction notifyAddedToCart( fragments, cartHash, button ) { const event = jQuery.Event( 'added_to_cart' ); event.wcSource = 'classic'; jQuery( document.body ).trigger( event, [ fragments, cartHash, button ] ); }\n"
+ },
+ "expect": {
+ "outcome": "quiet"
+ }
+ }
+ ]
+}
diff --git a/.ai/review/evals/classic-jquery-contract/fixtures/G29A/after.js b/.ai/review/evals/classic-jquery-contract/fixtures/G29A/after.js
new file mode 100644
index 00000000000..5d072e63cec
--- /dev/null
+++ b/.ai/review/evals/classic-jquery-contract/fixtures/G29A/after.js
@@ -0,0 +1,4 @@
+/* Classic checkout request and an installed extension’s prefilter. */
+function updateCheckout( address ) { return jQuery.ajax( { url: wc_checkout_params.wc_ajax_url, type: 'POST', data: jQuery.param( { security: wc_checkout_params.update_order_review_nonce, country: address.country, postcode: address.postcode } ) } ); }
+
+jQuery.ajaxPrefilter( ( options, originalOptions ) => { if ( originalOptions.data?.country === 'AQ' ) { options.beforeSend = () => false; } } );
diff --git a/.ai/review/evals/classic-jquery-contract/fixtures/G29A/before.js b/.ai/review/evals/classic-jquery-contract/fixtures/G29A/before.js
new file mode 100644
index 00000000000..e04483905a2
--- /dev/null
+++ b/.ai/review/evals/classic-jquery-contract/fixtures/G29A/before.js
@@ -0,0 +1,4 @@
+/* Classic checkout request and an installed extension’s prefilter. */
+function updateCheckout( address ) { return jQuery.ajax( { url: wc_checkout_params.wc_ajax_url, type: 'POST', data: { security: wc_checkout_params.update_order_review_nonce, country: address.country, postcode: address.postcode } } ); }
+
+jQuery.ajaxPrefilter( ( options, originalOptions ) => { if ( originalOptions.data?.country === 'AQ' ) { options.beforeSend = () => false; } } );
diff --git a/.ai/review/evals/classic-jquery-contract/fixtures/G29B/after.js b/.ai/review/evals/classic-jquery-contract/fixtures/G29B/after.js
new file mode 100644
index 00000000000..7288d1839a3
--- /dev/null
+++ b/.ai/review/evals/classic-jquery-contract/fixtures/G29B/after.js
@@ -0,0 +1,4 @@
+/* Classic cart notification and an installed extension’s listener. */
+function notifyAddedToCart( fragments, cartHash, button ) { jQuery( document ).trigger( 'added_to_cart', [ fragments, cartHash, button ] ); }
+
+jQuery( document.body ).on( 'added_to_cart', ( event, fragments, cartHash, button ) => { button.removeClass( 'loading' ); } );
diff --git a/.ai/review/evals/classic-jquery-contract/fixtures/G29B/before.js b/.ai/review/evals/classic-jquery-contract/fixtures/G29B/before.js
new file mode 100644
index 00000000000..d49ab739a08
--- /dev/null
+++ b/.ai/review/evals/classic-jquery-contract/fixtures/G29B/before.js
@@ -0,0 +1,4 @@
+/* Classic cart notification and an installed extension’s listener. */
+function notifyAddedToCart( fragments, cartHash, button ) { jQuery( document.body ).trigger( 'added_to_cart', [ fragments, cartHash, button ] ); }
+
+jQuery( document.body ).on( 'added_to_cart', ( event, fragments, cartHash, button ) => { button.removeClass( 'loading' ); } );
diff --git a/.ai/review/evals/classic-jquery-contract/fixtures/G29C/after.js b/.ai/review/evals/classic-jquery-contract/fixtures/G29C/after.js
new file mode 100644
index 00000000000..c04926fc7c3
--- /dev/null
+++ b/.ai/review/evals/classic-jquery-contract/fixtures/G29C/after.js
@@ -0,0 +1,4 @@
+/* Classic checkout uses jQuery defaults; address values are strings. */
+function updateCheckout( address ) { const buildData = () => ( { security: wc_checkout_params.update_order_review_nonce, country: address.country } ); const data = buildData(); return jQuery.ajax( { url: wc_checkout_params.wc_ajax_url, type: 'POST', data } ); }
+
+jQuery.ajaxPrefilter( ( options, originalOptions ) => { if ( originalOptions.data?.country === 'AQ' ) { options.beforeSend = () => false; } } );
diff --git a/.ai/review/evals/classic-jquery-contract/fixtures/G29C/before.js b/.ai/review/evals/classic-jquery-contract/fixtures/G29C/before.js
new file mode 100644
index 00000000000..22184263086
--- /dev/null
+++ b/.ai/review/evals/classic-jquery-contract/fixtures/G29C/before.js
@@ -0,0 +1,4 @@
+/* Classic checkout uses jQuery defaults; address values are strings. */
+function updateCheckout( address ) { return jQuery.ajax( { url: wc_checkout_params.wc_ajax_url, type: 'POST', data: { security: wc_checkout_params.update_order_review_nonce, country: address.country } } ); }
+
+jQuery.ajaxPrefilter( ( options, originalOptions ) => { if ( originalOptions.data?.country === 'AQ' ) { options.beforeSend = () => false; } } );
diff --git a/.ai/review/evals/css-layout-regression/cases.json b/.ai/review/evals/css-layout-regression/cases.json
new file mode 100644
index 00000000000..a3635173fa7
--- /dev/null
+++ b/.ai/review/evals/css-layout-regression/cases.json
@@ -0,0 +1,77 @@
+{
+ "version": 1,
+ "cases": [
+ {
+ "id": "G32B-v3",
+ "path": "plugins/woocommerce/client/blocks/assets/js/blocks/cart/style.css",
+ "before": {
+ "file": "fixtures/G32B/before.css"
+ },
+ "after": {
+ "file": "fixtures/G32B/after.css"
+ },
+ "expect": {
+ "outcome": "finding",
+ "locations": [
+ {
+ "file": "plugins/woocommerce/client/blocks/assets/js/blocks/cart/style.css",
+ "lines": [
+ 4,
+ 4
+ ],
+ "anchor": "button { min-height: 44px; }"
+ }
+ ]
+ }
+ },
+ {
+ "id": "G32C-v3",
+ "path": "plugins/woocommerce/client/blocks/assets/js/blocks/cart/style.css",
+ "before": {
+ "file": "fixtures/G32C/before.css"
+ },
+ "after": {
+ "file": "fixtures/G32C/after.css"
+ },
+ "expect": {
+ "outcome": "quiet"
+ }
+ },
+ {
+ "id": "G32D-v3",
+ "path": "plugins/woocommerce/client/blocks/assets/js/blocks/cart/style.css",
+ "before": {
+ "file": "fixtures/G32D/before.css"
+ },
+ "after": {
+ "file": "fixtures/G32D/after.css"
+ },
+ "expect": {
+ "outcome": "quiet"
+ }
+ },
+ {
+ "id": "G32A-v4",
+ "path": "plugins/woocommerce/client/blocks/assets/js/blocks/cart/style.css",
+ "before": {
+ "code": "/* Main and sidebar are consecutive children in the cart editor. */\n.editor-styles-wrapper .wc-block-cart { display: block; }\n.wc-block-cart .wc-block-cart__main + .wc-block-cart__sidebar { margin-block-start: 24px; }\n"
+ },
+ "after": {
+ "code": "/* Main and sidebar are consecutive children in the cart editor. */\n.editor-styles-wrapper .wc-block-cart { display: block; }\n.wc-block-cart { column-gap: 24px; }\n.wc-block-cart .wc-block-cart__main + .wc-block-cart__sidebar { margin-block-start: 0; }\n"
+ },
+ "expect": {
+ "outcome": "finding",
+ "locations": [
+ {
+ "file": "plugins/woocommerce/client/blocks/assets/js/blocks/cart/style.css",
+ "lines": [
+ 3,
+ 4
+ ],
+ "anchor": ".wc-block-cart .wc-block-cart__main + .wc-block-cart__sidebar { margin-block-start: 0; }"
+ }
+ ]
+ }
+ }
+ ]
+}
diff --git a/.ai/review/evals/css-layout-regression/fixtures/G32B/after.css b/.ai/review/evals/css-layout-regression/fixtures/G32B/after.css
new file mode 100644
index 00000000000..3901d356541
--- /dev/null
+++ b/.ai/review/evals/css-layout-regression/fixtures/G32B/after.css
@@ -0,0 +1,4 @@
+/* Theme header and Cart styles loaded together. */
+.site-header { height: 24px; overflow: hidden; }
+.site-header button { height: 24px; }
+button { min-height: 44px; }
diff --git a/.ai/review/evals/css-layout-regression/fixtures/G32B/before.css b/.ai/review/evals/css-layout-regression/fixtures/G32B/before.css
new file mode 100644
index 00000000000..6555aaca435
--- /dev/null
+++ b/.ai/review/evals/css-layout-regression/fixtures/G32B/before.css
@@ -0,0 +1,4 @@
+/* Theme header and Cart styles loaded together. */
+.site-header { height: 24px; overflow: hidden; }
+.site-header button { height: 24px; }
+.wc-block-cart .wc-block-cart__coupon button { min-height: 44px; }
diff --git a/.ai/review/evals/css-layout-regression/fixtures/G32C/after.css b/.ai/review/evals/css-layout-regression/fixtures/G32C/after.css
new file mode 100644
index 00000000000..ba674e9ac97
--- /dev/null
+++ b/.ai/review/evals/css-layout-regression/fixtures/G32C/after.css
@@ -0,0 +1,3 @@
+/* The new help block is introduced in this release; existing Cart markup is unchanged. */
+.wc-block-cart .wc-block-cart__coupon button { min-height: 44px; }
+.wc-block-new-cart-help .wc-block-new-cart-help__body { padding-block: 16px; }
diff --git a/.ai/review/evals/css-layout-regression/fixtures/G32C/before.css b/.ai/review/evals/css-layout-regression/fixtures/G32C/before.css
new file mode 100644
index 00000000000..83a76f88bd4
--- /dev/null
+++ b/.ai/review/evals/css-layout-regression/fixtures/G32C/before.css
@@ -0,0 +1,2 @@
+/* The new help block is introduced in this release; existing Cart markup is unchanged. */
+.wc-block-cart .wc-block-cart__coupon button { min-height: 44px; }
diff --git a/.ai/review/evals/css-layout-regression/fixtures/G32D/after.css b/.ai/review/evals/css-layout-regression/fixtures/G32D/after.css
new file mode 100644
index 00000000000..075ec3c8e7b
--- /dev/null
+++ b/.ai/review/evals/css-layout-regression/fixtures/G32D/after.css
@@ -0,0 +1,3 @@
+/* Cart main and sidebar are consecutive block children. */
+.wc-block-cart { writing-mode: horizontal-tb; display: block; }
+.wc-block-cart .wc-block-cart__main + .wc-block-cart__sidebar { margin-block-start: 24px; }
diff --git a/.ai/review/evals/css-layout-regression/fixtures/G32D/before.css b/.ai/review/evals/css-layout-regression/fixtures/G32D/before.css
new file mode 100644
index 00000000000..d1ec551134b
--- /dev/null
+++ b/.ai/review/evals/css-layout-regression/fixtures/G32D/before.css
@@ -0,0 +1,3 @@
+/* Cart main and sidebar are consecutive block children. */
+.wc-block-cart { writing-mode: horizontal-tb; display: block; }
+.wc-block-cart .wc-block-cart__main + .wc-block-cart__sidebar { margin-top: 24px; }
diff --git a/.ai/review/evals/css-layout-regression/groups/text-control-cascade/README.md b/.ai/review/evals/css-layout-regression/groups/text-control-cascade/README.md
new file mode 100644
index 00000000000..f3cc95a9719
--- /dev/null
+++ b/.ai/review/evals/css-layout-regression/groups/text-control-cascade/README.md
@@ -0,0 +1,48 @@
+# TextControl CSS cascade pair
+
+This optional pair checks whether a reviewer establishes the prior CSS cascade before reporting a new fixed input height.
+
+The original is the complete historical change from WooCommerce PR #69456.
+Its parent pins `@wordpress/components` 30.6.5, whose stylesheet already gives TextControl inputs a 32px height.
+The dependency version, archive integrity, and stylesheet path are recorded in `sources.json`.
+Preparation downloads that pinned archive, checks its SHA-512 integrity, and adds its unmodified stylesheet to the historical source used by both cases.
+The reviewer can read it at `eval-context/wordpress-components-30.6.5/style.css`, alongside its provenance.
+The original and control patches remain unchanged.
+The WooCommerce change adds an explicit 32px declaration to the wrapper stylesheet.
+A pre-existing `min-height: 30px` does not establish that the prior height was automatic.
+
+The control changes both height constraints to zero. It collapses text, number, date, and color inputs in the isolated browser check.
+This is a layout regression, not a redundant declaration.
+
+The browser check uses native inputs, the pinned upstream stylesheet, and compiled WooCommerce SCSS.
+Color and shadow variables use neutral values. Layout declarations remain unchanged.
+It does not reproduce a full WordPress 7.1 page or test text-only zoom.
+
+The v2 case IDs distinguish the added dependency source from earlier preparation.
+The group is optional. It does not change the default suite.
+Run this command from the policy repository. The source repository must contain both pinned commits:
+
+```sh
+python3 .ai/review/evals/css-layout-regression/groups/text-control-cascade/prepare.py \
+ --policy-repo "$PWD" \
+ --source-repo /path/to/woocommerce-with-historical-objects \
+ --output /path/to/new-cascade-checkout
+```
+
+The script checks source completeness and patch hashes. It creates a derived historical base containing the dependency reference files in the new checkout.
+It requires access to the pinned npm archive and refuses to overwrite an existing output directory.
+
+Keep both input repositories available while using the generated checkout.
+Preparation does not call a model or modify either input repository.
+
+```sh
+a8c-review eval --path /path/to/new-cascade-checkout \
+ --guideline css-layout-regression --group text-control-cascade --plan
+a8c-review eval --path /path/to/new-cascade-checkout \
+ --guideline css-layout-regression --group text-control-cascade
+```
+
+The second command uses the configured engines and their allowance.
+Follow the main [eval setup instructions](../../../README.md) before running it.
+The original's quiet expectation includes suppressed findings.
+The control checks the finding's primary file and line.
diff --git a/.ai/review/evals/css-layout-regression/groups/text-control-cascade/cases.template.json b/.ai/review/evals/css-layout-regression/groups/text-control-cascade/cases.template.json
new file mode 100644
index 00000000000..0e35087800f
--- /dev/null
+++ b/.ai/review/evals/css-layout-regression/groups/text-control-cascade/cases.template.json
@@ -0,0 +1,44 @@
+{
+ "version": 1,
+ "cases": [
+ {
+ "id": "text-control-fixed-height-original-v2",
+ "base_commit": "587df95622697a3772e9386f6b97b755c5f02379",
+ "patch": {
+ "file": "groups/text-control-cascade/original.patch"
+ },
+ "expect": {
+ "outcome": "quiet"
+ }
+ },
+ {
+ "id": "text-control-collapsed-control-v2",
+ "base_commit": "587df95622697a3772e9386f6b97b755c5f02379",
+ "patch": {
+ "file": "groups/text-control-cascade/control.patch"
+ },
+ "expect": {
+ "outcome": "finding",
+ "locations": [
+ {
+ "file": "packages/js/components/src/text-control/style.scss",
+ "lines": [
+ 46,
+ 47
+ ],
+ "anchor": "min-height: 0;"
+ }
+ ]
+ }
+ }
+ ],
+ "groups": [
+ {
+ "id": "text-control-cascade",
+ "regression": "text-control-fixed-height-original-v2",
+ "controls": [
+ "text-control-collapsed-control-v2"
+ ]
+ }
+ ]
+}
diff --git a/.ai/review/evals/css-layout-regression/groups/text-control-cascade/control.patch b/.ai/review/evals/css-layout-regression/groups/text-control-cascade/control.patch
new file mode 100644
index 00000000000..24406506292
--- /dev/null
+++ b/.ai/review/evals/css-layout-regression/groups/text-control-cascade/control.patch
@@ -0,0 +1,24 @@
+diff --git a/packages/js/components/changelog/fix-text-control-height-wp-7-1 b/packages/js/components/changelog/fix-text-control-height-wp-7-1
+new file mode 100644
+index 0000000000000000000000000000000000000000..d001705743a70b517e624543f2a0babdc984cc37
+--- /dev/null
++++ b/packages/js/components/changelog/fix-text-control-height-wp-7-1
+@@ -0,0 +1,4 @@
++Significance: patch
++Type: fix
++
++Fix `TextControl` inputs overflowing their box and hiding its bottom border on WordPress 7.1.
+diff --git a/packages/js/components/src/text-control/style.scss b/packages/js/components/src/text-control/style.scss
+index 3b8522a78af3ffb3f852353be1182602ee947d91..43ab1a8d6486c6ae21905325ca11b0a3e1ee450b 100644
+--- a/packages/js/components/src/text-control/style.scss
++++ b/packages/js/components/src/text-control/style.scss
+@@ -43,7 +43,8 @@
+ line-height: 21px;
+ margin: 0;
+ padding: 0;
+- min-height: 30px;
++ min-height: 0;
++ height: 0;
+
+ &:focus {
+ box-shadow: none;
diff --git a/.ai/review/evals/css-layout-regression/groups/text-control-cascade/original.patch b/.ai/review/evals/css-layout-regression/groups/text-control-cascade/original.patch
new file mode 100644
index 00000000000..773b060a6fe
--- /dev/null
+++ b/.ai/review/evals/css-layout-regression/groups/text-control-cascade/original.patch
@@ -0,0 +1,22 @@
+diff --git a/packages/js/components/changelog/fix-text-control-height-wp-7-1 b/packages/js/components/changelog/fix-text-control-height-wp-7-1
+new file mode 100644
+index 0000000000000000000000000000000000000000..d001705743a70b517e624543f2a0babdc984cc37
+--- /dev/null
++++ b/packages/js/components/changelog/fix-text-control-height-wp-7-1
+@@ -0,0 +1,4 @@
++Significance: patch
++Type: fix
++
++Fix `TextControl` inputs overflowing their box and hiding its bottom border on WordPress 7.1.
+diff --git a/packages/js/components/src/text-control/style.scss b/packages/js/components/src/text-control/style.scss
+index 3b8522a78af3ffb3f852353be1182602ee947d91..e757221a947387ded39896c8a38067605e5e608f 100644
+--- a/packages/js/components/src/text-control/style.scss
++++ b/packages/js/components/src/text-control/style.scss
+@@ -44,6 +44,7 @@
+ margin: 0;
+ padding: 0;
+ min-height: 30px;
++ height: 32px;
+
+ &:focus {
+ box-shadow: none;
diff --git a/.ai/review/evals/css-layout-regression/groups/text-control-cascade/prepare.py b/.ai/review/evals/css-layout-regression/groups/text-control-cascade/prepare.py
new file mode 100644
index 00000000000..4ee2b56d9ad
--- /dev/null
+++ b/.ai/review/evals/css-layout-regression/groups/text-control-cascade/prepare.py
@@ -0,0 +1,131 @@
+#!/usr/bin/env python3
+"""Register this historical pair in a new checkout using local Git objects."""
+import argparse
+import base64
+import hashlib
+import io
+import json
+import os
+import shutil
+from pathlib import Path
+import subprocess
+import tempfile
+import tarfile
+from urllib.request import urlopen
+
+PACKAGE = Path(__file__).resolve().parent
+SUITE = Path('.ai/review/evals') / PACKAGE.parents[1].name
+ENV = dict(os.environ, GIT_NO_LAZY_FETCH='1', GIT_TERMINAL_PROMPT='0',
+ GIT_CONFIG_GLOBAL=os.devnull, GIT_CONFIG_NOSYSTEM='1')
+
+
+def git(repo, *args, env=None, input=None):
+ return subprocess.check_output(['git', '-C', str(repo), *args], env=env or ENV, input=input)
+
+
+def dependency_source(repo, pins):
+ dependency = pins['upstream_dependency']
+ with urlopen(dependency['url'], timeout=60) as response:
+ archive = response.read()
+ integrity = 'sha512-' + base64.b64encode(hashlib.sha512(archive).digest()).decode()
+ if integrity != dependency['integrity']:
+ raise ValueError('Upstream dependency archive integrity differs')
+ with tarfile.open(fileobj=io.BytesIO(archive), mode='r:gz') as package:
+ member = package.getmember(dependency['stylesheet'])
+ if not member.isfile():
+ raise ValueError('Upstream stylesheet must be a regular file')
+ stylesheet = package.extractfile(member).read()
+ reference = 'eval-context/wordpress-components-' + dependency['version']
+ note = ('# Historical TextControl dependency source\n\n'
+ + dependency['name'] + ' ' + dependency['version'] + '\n'
+ + dependency['url'] + '\n' + integrity + '\n'
+ + 'Archive member: ' + dependency['stylesheet'] + '\n'
+ + 'style.css is the unmodified pinned upstream stylesheet.\n'
+ + 'It accompanies the historical source; it is not part of either test patch.\n')
+ with tempfile.TemporaryDirectory(prefix='historical-reference-') as temp:
+ env = dict(ENV, GIT_INDEX_FILE=str(Path(temp) / 'index'),
+ GIT_AUTHOR_NAME='Eval fixture', GIT_AUTHOR_EMAIL='eval@example.invalid',
+ GIT_COMMITTER_NAME='Eval fixture', GIT_COMMITTER_EMAIL='eval@example.invalid',
+ GIT_AUTHOR_DATE='2000-01-01T00:00:00Z', GIT_COMMITTER_DATE='2000-01-01T00:00:00Z')
+ git(repo, 'read-tree', pins['base'], env=env)
+ for name, content in [('style.css', stylesheet), ('README.md', note.encode())]:
+ path = reference + '/' + name
+ if git(repo, 'ls-tree', pins['base'], '--', path):
+ raise ValueError('Historical reference source path already exists')
+ blob = git(repo, 'hash-object', '-w', '--stdin', '--no-filters', input=content).decode().strip()
+ git(repo, 'update-index', '--add', '--cacheinfo', '100644', blob, path, env=env)
+ tree = git(repo, 'write-tree', env=env).decode().strip()
+ return git(repo, 'commit-tree', tree, '-p', pins['base'],
+ '-m', 'Attach pinned TextControl stylesheet for the historical eval', env=env).decode().strip()
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument('--policy-repo', required=True, type=Path)
+ parser.add_argument('--source-repo', required=True, type=Path)
+ parser.add_argument('--output', required=True, type=Path)
+ args = parser.parse_args()
+ pins = json.loads((PACKAGE / 'sources.json').read_text())
+ template = json.loads((PACKAGE / 'cases.template.json').read_text())
+ for commit in (pins['base'], pins['head']):
+ git(args.source_repo, 'cat-file', '-e', commit + '^{commit}')
+ objects = git(args.source_repo, 'rev-list', '--objects', '--missing=print',
+ pins['base'], pins['head'], '--')
+ if any(line.startswith(b'?') for line in objects.splitlines()):
+ raise ValueError('Historical source objects are incomplete')
+ original = git(args.source_repo, 'diff', '--binary', '--full-index',
+ '--no-ext-diff', '--no-textconv', '--no-color',
+ '--src-prefix=a/', '--dst-prefix=b/', '-U3',
+ pins['base'], pins['head'], '--')
+ if original != (PACKAGE / 'original.patch').read_bytes():
+ raise ValueError('Original patch differs from the pinned source change')
+ with tempfile.TemporaryDirectory(prefix='historical-pair-') as temp:
+ env = dict(ENV, GIT_INDEX_FILE=str(Path(temp) / 'index'))
+ for name, expected in pins['patch_sha256'].items():
+ patch = PACKAGE / name
+ if hashlib.sha256(patch.read_bytes()).hexdigest() != expected:
+ raise ValueError('Patch hash differs: ' + name)
+ git(args.source_repo, 'read-tree', pins['base'], env=env)
+ git(args.source_repo, 'apply', '--cached', '--check', str(patch), env=env)
+ output = args.output.resolve()
+ if output.exists():
+ raise ValueError('Output must not exist; preparation never overwrites files')
+ for repo in (args.policy_repo, args.source_repo):
+ root = Path(git(repo, 'rev-parse', '--show-toplevel').decode().strip()).resolve()
+ if output == root or root in output.parents:
+ raise ValueError('Output must be outside the input repositories')
+ policy = git(args.policy_repo, 'rev-parse', 'HEAD').decode().strip()
+ git(args.policy_repo, 'clone', '--shared', '--no-checkout', '--',
+ str(args.policy_repo.resolve()), str(output))
+ git(output, 'sparse-checkout', 'set', '--no-cone', '/.ai/', '/AGENTS.md',
+ '/CLAUDE.md', '/ai-review-config.json')
+ git(output, 'checkout', '--detach', policy)
+ objects = git(args.source_repo, 'rev-parse', '--path-format=absolute',
+ '--git-path', 'objects').decode().strip()
+ with (output / '.git/objects/info/alternates').open('a') as stream:
+ stream.write(objects + '\n')
+ source_base = dependency_source(output, pins)
+ target = output / SUITE
+ for name in pins['patch_sha256']:
+ destination = target / 'groups' / PACKAGE.name / name
+ destination.parent.mkdir(parents=True, exist_ok=True)
+ shutil.copyfile(PACKAGE / name, destination)
+ cases = json.loads((target / 'cases.json').read_text())
+ ids = {case['id'] for case in cases['cases']}
+ for case in template['cases']:
+ if case['id'] in ids:
+ raise ValueError('Case ID is already registered: ' + case['id'])
+ case['base_commit'] = source_base
+ cases['cases'].append(case)
+ groups = cases.setdefault('groups', [])
+ if any(group['id'] == template['groups'][0]['id'] for group in groups):
+ raise ValueError('Group is already registered')
+ groups.extend(template['groups'])
+ (target / 'cases.json').write_text(json.dumps(cases, indent=2) + '\n')
+ print(json.dumps({'output': str(output), 'policy_commit': policy,
+ 'source_base': source_base, 'historical_base': pins['base'],
+ 'group': template['groups'][0]['id']}, indent=2))
+
+
+if __name__ == '__main__':
+ main()
diff --git a/.ai/review/evals/css-layout-regression/groups/text-control-cascade/sources.json b/.ai/review/evals/css-layout-regression/groups/text-control-cascade/sources.json
new file mode 100644
index 00000000000..c09703c792b
--- /dev/null
+++ b/.ai/review/evals/css-layout-regression/groups/text-control-cascade/sources.json
@@ -0,0 +1,15 @@
+{
+ "base": "587df95622697a3772e9386f6b97b755c5f02379",
+ "head": "e9eb08b2b3ee00a0adbbb136e0ed35ddd74e9ae1",
+ "patch_sha256": {
+ "original.patch": "3fa79a9ecd43f93bd38f33d268609dd60916456d6e3726c490803351a585a74f",
+ "control.patch": "c38579789d403e77bca6458dbc994ed6597e59d0ccd579b8613ce88fe333e012"
+ },
+ "upstream_dependency": {
+ "name": "@wordpress/components",
+ "version": "30.6.5",
+ "url": "https://registry.npmjs.org/@wordpress/components/-/components-30.6.5.tgz",
+ "integrity": "sha512-jSDpUz1BBQvpWUGwqZUlI22PMGMtiN5ooKS4GlQqjUmEBJH4llwiRblkZBKgdznv+5KDYyDiRNIGCz/hLSdwZw==",
+ "stylesheet": "package/build-style/style.css"
+ }
+}
diff --git a/.ai/review/evals/fix-and-test-verification/cases.json b/.ai/review/evals/fix-and-test-verification/cases.json
new file mode 100644
index 00000000000..a18c8b02086
--- /dev/null
+++ b/.ai/review/evals/fix-and-test-verification/cases.json
@@ -0,0 +1,93 @@
+{
+ "version": 1,
+ "cases": [
+ {
+ "id": "G24A-v3",
+ "path": "plugins/woocommerce/includes/class-wc-eval-24.php",
+ "before": {
+ "file": "fixtures/G24A/before.php"
+ },
+ "after": {
+ "file": "fixtures/G24A/after.php"
+ },
+ "expect": {
+ "outcome": "finding",
+ "locations": [
+ {
+ "file": "plugins/woocommerce/includes/class-wc-eval-24.php",
+ "lines": [
+ 4,
+ 4
+ ],
+ "anchor": " return 'wc-cancelled' === $status ? 'Cancelled' : 'Other';"
+ },
+ {
+ "file": "plugins/woocommerce/includes/class-wc-eval-24.php",
+ "lines": [
+ 12,
+ 13
+ ],
+ "anchor": " $order = $this->getMockBuilder( WC_Order::class )->onlyMethods( array( 'get_status' ) )->getMock();"
+ }
+ ]
+ }
+ },
+ {
+ "id": "G24B-v3",
+ "path": "plugins/woocommerce/includes/class-wc-eval-24.php",
+ "before": {
+ "file": "fixtures/G24B/before.php"
+ },
+ "after": {
+ "file": "fixtures/G24B/after.php"
+ },
+ "expect": {
+ "outcome": "finding",
+ "locations": [
+ {
+ "file": "plugins/woocommerce/includes/class-wc-eval-24.php",
+ "lines": [
+ 4,
+ 4
+ ],
+ "anchor": " return array( 'post_type' => 'product', 'posts_per_page' => 1, 'orderby' => 'ID', 'order' => 'ASC', 'catalog_page' => max( 1, $paged ) );"
+ },
+ {
+ "file": "plugins/woocommerce/includes/class-wc-eval-24.php",
+ "lines": [
+ 12,
+ 12
+ ],
+ "anchor": " $this->assertSame( 2, wc_catalog_page_args( 2 )['catalog_page'] );"
+ }
+ ]
+ }
+ },
+ {
+ "id": "G24C-v4",
+ "path": "plugins/woocommerce/includes/class-wc-eval-24.php",
+ "before": {
+ "file": "fixtures/G24C/before.php"
+ },
+ "after": {
+ "file": "fixtures/G24C/after.php"
+ },
+ "expect": {
+ "outcome": "quiet"
+ }
+ },
+ {
+ "id": "G24D-v3",
+ "path": "plugins/woocommerce/includes/class-wc-eval-24.php",
+ "before": {
+ "file": "fixtures/G24D/before.php"
+ },
+ "after": {
+ "file": "fixtures/G24D/after.php"
+ },
+ "expect": {
+ "outcome": "quiet"
+ }
+ }
+ ]
+}
diff --git a/.ai/review/evals/fix-and-test-verification/fixtures/G24A/after.php b/.ai/review/evals/fix-and-test-verification/fixtures/G24A/after.php
new file mode 100644
index 00000000000..9429ab00d94
--- /dev/null
+++ b/.ai/review/evals/fix-and-test-verification/fixtures/G24A/after.php
@@ -0,0 +1,16 @@
+<?php
+/* Order-admin label helper and its regression test, colocated for this eval. */
+function wc_cancelled_status_label( $status ) {
+ return 'wc-cancelled' === $status ? 'Cancelled' : 'Other';
+}
+function wc_admin_cancelled_label( $order ) {
+ return wc_cancelled_status_label( $order->get_status() );
+}
+
+class CancelledLabelTest extends WC_Unit_Test_Case {
+ public function test_cancelled_label(): void {
+ $order = $this->getMockBuilder( WC_Order::class )->onlyMethods( array( 'get_status' ) )->getMock();
+ $order->method( 'get_status' )->willReturn( 'wc-cancelled' );
+ $this->assertSame( 'Cancelled', wc_admin_cancelled_label( $order ) );
+ }
+}
diff --git a/.ai/review/evals/fix-and-test-verification/fixtures/G24A/before.php b/.ai/review/evals/fix-and-test-verification/fixtures/G24A/before.php
new file mode 100644
index 00000000000..190fb18c702
--- /dev/null
+++ b/.ai/review/evals/fix-and-test-verification/fixtures/G24A/before.php
@@ -0,0 +1,16 @@
+<?php
+/* Order-admin label helper and its regression test, colocated for this eval. */
+function wc_cancelled_status_label( $status ) {
+ return 'cancelled' === $status ? 'Cancelled' : 'Other';
+}
+function wc_admin_cancelled_label( $order ) {
+ return wc_cancelled_status_label( $order->get_status() );
+}
+
+class CancelledLabelTest extends WC_Unit_Test_Case {
+ public function test_cancelled_label(): void {
+ $order = new WC_Order();
+ $order->set_status( 'cancelled' );
+ $this->assertSame( 'Cancelled', wc_admin_cancelled_label( $order ) );
+ }
+}
diff --git a/.ai/review/evals/fix-and-test-verification/fixtures/G24B/after.php b/.ai/review/evals/fix-and-test-verification/fixtures/G24B/after.php
new file mode 100644
index 00000000000..4af24baa631
--- /dev/null
+++ b/.ai/review/evals/fix-and-test-verification/fixtures/G24B/after.php
@@ -0,0 +1,14 @@
+<?php
+/* Catalog pagination helper and its regression test, colocated for this eval. */
+function wc_catalog_page_args( $paged ) {
+ return array( 'post_type' => 'product', 'posts_per_page' => 1, 'orderby' => 'ID', 'order' => 'ASC', 'catalog_page' => max( 1, $paged ) );
+}
+function wc_catalog_page( $paged ) { return new WP_Query( wc_catalog_page_args( $paged ) ); }
+
+class CatalogPageTest extends WC_Unit_Test_Case {
+ public function test_second_page(): void {
+ $ids = self::factory()->post->create_many( 2, array( 'post_type' => 'product', 'post_status' => 'publish' ) );
+ $query = wc_catalog_page( 2 );
+ $this->assertSame( 2, wc_catalog_page_args( 2 )['catalog_page'] );
+ }
+}
diff --git a/.ai/review/evals/fix-and-test-verification/fixtures/G24B/before.php b/.ai/review/evals/fix-and-test-verification/fixtures/G24B/before.php
new file mode 100644
index 00000000000..ee2ba01f0b1
--- /dev/null
+++ b/.ai/review/evals/fix-and-test-verification/fixtures/G24B/before.php
@@ -0,0 +1,14 @@
+<?php
+/* Catalog pagination helper and its regression test, colocated for this eval. */
+function wc_catalog_page_args( $paged ) {
+ return array( 'post_type' => 'product', 'posts_per_page' => 1, 'orderby' => 'ID', 'order' => 'ASC', 'paged' => max( 1, $paged ) );
+}
+function wc_catalog_page( $paged ) { return new WP_Query( wc_catalog_page_args( $paged ) ); }
+
+class CatalogPageTest extends WC_Unit_Test_Case {
+ public function test_second_page(): void {
+ $ids = self::factory()->post->create_many( 2, array( 'post_type' => 'product', 'post_status' => 'publish' ) );
+ $query = wc_catalog_page( 2 );
+ $this->assertSame( $ids[1], (int) $query->posts[0]->ID );
+ }
+}
diff --git a/.ai/review/evals/fix-and-test-verification/fixtures/G24C/after.php b/.ai/review/evals/fix-and-test-verification/fixtures/G24C/after.php
new file mode 100644
index 00000000000..bc49452e9f8
--- /dev/null
+++ b/.ai/review/evals/fix-and-test-verification/fixtures/G24C/after.php
@@ -0,0 +1,6 @@
+<?php
+/* Refactor of catalog pagination; route passes integer page numbers. */
+function wc_catalog_page_args( int $paged ) {
+ return array( 'post_type' => 'product', 'posts_per_page' => 12, 'paged' => ( $paged < 1 ? 1 : $paged ) );
+}
+function wc_catalog_page( int $paged ) { return new WP_Query( wc_catalog_page_args( $paged ) ); }
diff --git a/.ai/review/evals/fix-and-test-verification/fixtures/G24C/before.php b/.ai/review/evals/fix-and-test-verification/fixtures/G24C/before.php
new file mode 100644
index 00000000000..923e2981800
--- /dev/null
+++ b/.ai/review/evals/fix-and-test-verification/fixtures/G24C/before.php
@@ -0,0 +1,6 @@
+<?php
+/* Refactor of catalog pagination; route passes integer page numbers. */
+function wc_catalog_page_args( int $paged ) {
+ return array( 'post_type' => 'product', 'posts_per_page' => 12, 'paged' => max( 1, $paged ) );
+}
+function wc_catalog_page( int $paged ) { return new WP_Query( wc_catalog_page_args( $paged ) ); }
diff --git a/.ai/review/evals/fix-and-test-verification/fixtures/G24D/after.php b/.ai/review/evals/fix-and-test-verification/fixtures/G24D/after.php
new file mode 100644
index 00000000000..121a632dc0c
--- /dev/null
+++ b/.ai/review/evals/fix-and-test-verification/fixtures/G24D/after.php
@@ -0,0 +1,14 @@
+<?php
+/* Catalog pagination fix and regression test, colocated for this eval. */
+function wc_catalog_page_args( $paged ) {
+ return array( 'post_type' => 'product', 'posts_per_page' => 1, 'orderby' => 'ID', 'order' => 'ASC', 'paged' => max( 1, $paged ) );
+}
+function wc_catalog_page( $page ) { return new WP_Query( wc_catalog_page_args( $page ) ); }
+
+class CatalogPageTest extends WC_Unit_Test_Case {
+ public function test_second_page(): void {
+ $ids = self::factory()->post->create_many( 2, array( 'post_type' => 'product', 'post_status' => 'publish' ) );
+ $query = wc_catalog_page( 2 );
+ $this->assertSame( $ids[1], (int) $query->posts[0]->ID );
+ }
+}
diff --git a/.ai/review/evals/fix-and-test-verification/fixtures/G24D/before.php b/.ai/review/evals/fix-and-test-verification/fixtures/G24D/before.php
new file mode 100644
index 00000000000..daa4be1c595
--- /dev/null
+++ b/.ai/review/evals/fix-and-test-verification/fixtures/G24D/before.php
@@ -0,0 +1,14 @@
+<?php
+/* Catalog pagination fix and regression test, colocated for this eval. */
+function wc_catalog_page_args( $paged ) {
+ return array( 'post_type' => 'product', 'posts_per_page' => 1, 'orderby' => 'ID', 'order' => 'ASC', 'paged' => max( 1, $paged ) );
+}
+function wc_catalog_page( $page ) { return new WP_Query( wc_catalog_page_args( 1 ) ); }
+
+class CatalogPageTest extends WC_Unit_Test_Case {
+ public function test_second_page(): void {
+ $ids = self::factory()->post->create_many( 2, array( 'post_type' => 'product', 'post_status' => 'publish' ) );
+ $query = wc_catalog_page( 2 );
+ $this->assertSame( $ids[1], (int) $query->posts[0]->ID );
+ }
+}
diff --git a/.ai/review/evals/fix-and-test-verification/groups/event-name-contract/README.md b/.ai/review/evals/fix-and-test-verification/groups/event-name-contract/README.md
new file mode 100644
index 00000000000..74abe1f8c56
--- /dev/null
+++ b/.ai/review/evals/fix-and-test-verification/groups/event-name-contract/README.md
@@ -0,0 +1,43 @@
+# Supported event-name contracts
+
+The original patch rejects non-string event names. This pair tests whether the reviewer treats incidental coercion as a supported input contract.
+
+The change deliberately stops accepting numeric names. Valid string names keep the same behavior. The public Tracks package documents and types `eventName` as a string.
+
+The control inverts the guard, which rejects every valid string name. That change prevents supported events from reaching the queue.
+
+Count warnings about former numeric coercion separately from failures involving supported strings. A real consumer or a documented contract can establish an actionable compatibility problem.
+
+Read [the original change](https://github.com/woocommerce/woocommerce/commit/e8409074363258daff3d66b02feaa75fa6894f72) and [the public event-name contract](https://github.com/woocommerce/woocommerce/blob/25ca8e656937378844d3899f8f1c6d0a9307921d/packages/js/tracks/src/index.ts) for the source behavior.
+
+## Prepare the pair
+
+Run these commands from the WooCommerce repository that contains this group. The source repository must already contain both commits listed in `sources.json`.
+
+```sh
+python3 .ai/review/evals/fix-and-test-verification/groups/event-name-contract/prepare.py \
+ --policy-repo "$PWD" \
+ --source-repo /path/to/woocommerce-with-historical-objects \
+ --output /path/to/new-event-name-contract-checkout
+```
+
+The script checks the original patch and both patch hashes. It uses local Git objects and creates a separate checkout. Keep both input repositories available while using that checkout.
+
+The generated checkout registers this pair alongside the 165 default cases. Preparation never calls a model or changes an input repository.
+
+```sh
+a8c-review eval --path /path/to/new-event-name-contract-checkout \
+ --guideline fix-and-test-verification --group event-name-contract --plan
+a8c-review eval --path /path/to/new-event-name-contract-checkout \
+ --guideline fix-and-test-verification --group event-name-contract
+```
+
+The second command uses the configured review engines and consumes their allowance. Follow the main [eval setup instructions](../../../README.md) before running it.
+
+## Interpret the results
+
+The original uses the complete historical patch and source tree. The control starts from the same parent and introduces the described defect.
+
+The `quiet` expectation includes suppressed findings. The control expectation checks the finding's primary file and line. Read both surfaced and suppressed findings before recording target frequencies.
+
+These cases test review behavior. They do not run a full WordPress installation or measure production-wide accuracy.
diff --git a/.ai/review/evals/fix-and-test-verification/groups/event-name-contract/cases.template.json b/.ai/review/evals/fix-and-test-verification/groups/event-name-contract/cases.template.json
new file mode 100644
index 00000000000..2e1e4182565
--- /dev/null
+++ b/.ai/review/evals/fix-and-test-verification/groups/event-name-contract/cases.template.json
@@ -0,0 +1,44 @@
+{
+ "version": 1,
+ "cases": [
+ {
+ "id": "tracks-event-name-original-v1",
+ "base_commit": "25ca8e656937378844d3899f8f1c6d0a9307921d",
+ "patch": {
+ "file": "groups/event-name-contract/original.patch"
+ },
+ "expect": {
+ "outcome": "quiet"
+ }
+ },
+ {
+ "id": "tracks-event-name-control-v1",
+ "base_commit": "25ca8e656937378844d3899f8f1c6d0a9307921d",
+ "patch": {
+ "file": "groups/event-name-contract/control.patch"
+ },
+ "expect": {
+ "outcome": "finding",
+ "locations": [
+ {
+ "file": "plugins/woocommerce/includes/tracks/class-wc-site-tracking.php",
+ "lines": [
+ 123,
+ 123
+ ],
+ "anchor": "if ( typeof name === 'string' || ! name ) {"
+ }
+ ]
+ }
+ }
+ ],
+ "groups": [
+ {
+ "id": "event-name-contract",
+ "regression": "tracks-event-name-original-v1",
+ "controls": [
+ "tracks-event-name-control-v1"
+ ]
+ }
+ ]
+}
diff --git a/.ai/review/evals/fix-and-test-verification/groups/event-name-contract/control.patch b/.ai/review/evals/fix-and-test-verification/groups/event-name-contract/control.patch
new file mode 100644
index 00000000000..b7d46e0aef1
--- /dev/null
+++ b/.ai/review/evals/fix-and-test-verification/groups/event-name-contract/control.patch
@@ -0,0 +1,32 @@
+diff --git a/plugins/woocommerce/changelog/wooplug-7771-guard-undefined-tracks-event-name b/plugins/woocommerce/changelog/wooplug-7771-guard-undefined-tracks-event-name
+new file mode 100644
+index 0000000000000000000000000000000000000000..e4b20c120e4bd675d716c7f8e1b5f004e4f04b74
+--- /dev/null
++++ b/plugins/woocommerce/changelog/wooplug-7771-guard-undefined-tracks-event-name
+@@ -0,0 +1,4 @@
++Significance: patch
++Type: fix
++
++Skip client-side Tracks events recorded without a valid event name instead of sending them as `wcadmin_undefined`.
+diff --git a/plugins/woocommerce/includes/tracks/class-wc-site-tracking.php b/plugins/woocommerce/includes/tracks/class-wc-site-tracking.php
+index 69462ae3f04fdc29f04432673547e32d35efcb03..4cea85060147c4e015a4fc43150f7c3a564924d3 100644
+--- a/plugins/woocommerce/includes/tracks/class-wc-site-tracking.php
++++ b/plugins/woocommerce/includes/tracks/class-wc-site-tracking.php
+@@ -119,6 +119,17 @@ class WC_Site_Tracking {
+ return;
+ }
+
++ // Without this, a missing name is prefixed into a bogus `wcadmin_undefined` event that Tracks rejects.
++ if ( typeof name === 'string' || ! name ) {
++ if ( <?php echo 'production' !== $environment_type ? 'true' : 'false'; ?> ) {
++ /* eslint-disable no-console */
++ // Pass the value as its own argument: interpolating a Symbol (or null-prototype object) would throw.
++ console.error( 'A valid event name must be specified. Received invalid event name:', name );
++ /* eslint-enable no-console */
++ }
++ return;
++ }
++
+ const eventName = '<?php echo esc_attr( WC_Tracks::PREFIX ); ?>' + name;
+ let eventProperties = properties || {};
+ eventProperties = { ...eventProperties, ...<?php echo json_encode( $filtered_properties, JSON_HEX_TAG | JSON_UNESCAPED_SLASHES ); // phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode ?> };
diff --git a/.ai/review/evals/fix-and-test-verification/groups/event-name-contract/original.patch b/.ai/review/evals/fix-and-test-verification/groups/event-name-contract/original.patch
new file mode 100644
index 00000000000..5d90fa84f89
--- /dev/null
+++ b/.ai/review/evals/fix-and-test-verification/groups/event-name-contract/original.patch
@@ -0,0 +1,32 @@
+diff --git a/plugins/woocommerce/changelog/wooplug-7771-guard-undefined-tracks-event-name b/plugins/woocommerce/changelog/wooplug-7771-guard-undefined-tracks-event-name
+new file mode 100644
+index 0000000000000000000000000000000000000000..e4b20c120e4bd675d716c7f8e1b5f004e4f04b74
+--- /dev/null
++++ b/plugins/woocommerce/changelog/wooplug-7771-guard-undefined-tracks-event-name
+@@ -0,0 +1,4 @@
++Significance: patch
++Type: fix
++
++Skip client-side Tracks events recorded without a valid event name instead of sending them as `wcadmin_undefined`.
+diff --git a/plugins/woocommerce/includes/tracks/class-wc-site-tracking.php b/plugins/woocommerce/includes/tracks/class-wc-site-tracking.php
+index 69462ae3f04fdc29f04432673547e32d35efcb03..c33f2c600b29e012b3b877e16377b75796b06e66 100644
+--- a/plugins/woocommerce/includes/tracks/class-wc-site-tracking.php
++++ b/plugins/woocommerce/includes/tracks/class-wc-site-tracking.php
+@@ -119,6 +119,17 @@ class WC_Site_Tracking {
+ return;
+ }
+
++ // Without this, a missing name is prefixed into a bogus `wcadmin_undefined` event that Tracks rejects.
++ if ( typeof name !== 'string' || ! name ) {
++ if ( <?php echo 'production' !== $environment_type ? 'true' : 'false'; ?> ) {
++ /* eslint-disable no-console */
++ // Pass the value as its own argument: interpolating a Symbol (or null-prototype object) would throw.
++ console.error( 'A valid event name must be specified. Received invalid event name:', name );
++ /* eslint-enable no-console */
++ }
++ return;
++ }
++
+ const eventName = '<?php echo esc_attr( WC_Tracks::PREFIX ); ?>' + name;
+ let eventProperties = properties || {};
+ eventProperties = { ...eventProperties, ...<?php echo json_encode( $filtered_properties, JSON_HEX_TAG | JSON_UNESCAPED_SLASHES ); // phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode ?> };
diff --git a/.ai/review/evals/fix-and-test-verification/groups/event-name-contract/prepare.py b/.ai/review/evals/fix-and-test-verification/groups/event-name-contract/prepare.py
new file mode 100644
index 00000000000..0769a8b7b4d
--- /dev/null
+++ b/.ai/review/evals/fix-and-test-verification/groups/event-name-contract/prepare.py
@@ -0,0 +1,89 @@
+#!/usr/bin/env python3
+"""Register this historical pair in a new checkout using local Git objects."""
+import argparse
+import hashlib
+import json
+import os
+import shutil
+from pathlib import Path
+import subprocess
+import tempfile
+
+PACKAGE = Path(__file__).resolve().parent
+SUITE = Path('.ai/review/evals') / PACKAGE.parents[1].name
+ENV = dict(os.environ, GIT_NO_LAZY_FETCH='1', GIT_TERMINAL_PROMPT='0',
+ GIT_CONFIG_GLOBAL=os.devnull, GIT_CONFIG_NOSYSTEM='1')
+
+
+def git(repo, *args, env=None):
+ return subprocess.check_output(['git', '-C', str(repo), *args], env=env or ENV)
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument('--policy-repo', required=True, type=Path)
+ parser.add_argument('--source-repo', required=True, type=Path)
+ parser.add_argument('--output', required=True, type=Path)
+ args = parser.parse_args()
+ pins = json.loads((PACKAGE / 'sources.json').read_text())
+ template = json.loads((PACKAGE / 'cases.template.json').read_text())
+ for commit in (pins['base'], pins['head']):
+ git(args.source_repo, 'cat-file', '-e', commit + '^{commit}')
+ objects = git(args.source_repo, 'rev-list', '--objects', '--missing=print',
+ pins['base'], pins['head'], '--')
+ if any(line.startswith(b'?') for line in objects.splitlines()):
+ raise ValueError('Historical source objects are incomplete')
+ original = git(args.source_repo, 'diff', '--binary', '--full-index',
+ '--no-ext-diff', '--no-textconv', '--no-color',
+ '--src-prefix=a/', '--dst-prefix=b/', '-U3',
+ pins['base'], pins['head'], '--')
+ if original != (PACKAGE / 'original.patch').read_bytes():
+ raise ValueError('Original patch differs from the pinned source change')
+ with tempfile.TemporaryDirectory(prefix='historical-pair-') as temp:
+ env = dict(ENV, GIT_INDEX_FILE=str(Path(temp) / 'index'))
+ for name, expected in pins['patch_sha256'].items():
+ patch = PACKAGE / name
+ if hashlib.sha256(patch.read_bytes()).hexdigest() != expected:
+ raise ValueError('Patch hash differs: ' + name)
+ git(args.source_repo, 'read-tree', pins['base'], env=env)
+ git(args.source_repo, 'apply', '--cached', '--check', str(patch), env=env)
+ output = args.output.resolve()
+ if output.exists():
+ raise ValueError('Output must not exist; preparation never overwrites files')
+ for repo in (args.policy_repo, args.source_repo):
+ root = Path(git(repo, 'rev-parse', '--show-toplevel').decode().strip()).resolve()
+ if output == root or root in output.parents:
+ raise ValueError('Output must be outside the input repositories')
+ policy = git(args.policy_repo, 'rev-parse', 'HEAD').decode().strip()
+ git(args.policy_repo, 'clone', '--shared', '--no-checkout', '--',
+ str(args.policy_repo.resolve()), str(output))
+ git(output, 'sparse-checkout', 'set', '--no-cone', '/.ai/', '/AGENTS.md',
+ '/CLAUDE.md', '/ai-review-config.json')
+ git(output, 'checkout', '--detach', policy)
+ objects = git(args.source_repo, 'rev-parse', '--path-format=absolute',
+ '--git-path', 'objects').decode().strip()
+ with (output / '.git/objects/info/alternates').open('a') as stream:
+ stream.write(objects + '\n')
+ target = output / SUITE
+ for name in pins['patch_sha256']:
+ destination = target / 'groups' / PACKAGE.name / name
+ destination.parent.mkdir(parents=True, exist_ok=True)
+ shutil.copyfile(PACKAGE / name, destination)
+ cases = json.loads((target / 'cases.json').read_text())
+ ids = {case['id'] for case in cases['cases']}
+ for case in template['cases']:
+ if case['id'] in ids:
+ raise ValueError('Case ID is already registered: ' + case['id'])
+ cases['cases'].append(case)
+ groups = cases.setdefault('groups', [])
+ if any(group['id'] == template['groups'][0]['id'] for group in groups):
+ raise ValueError('Group is already registered')
+ groups.extend(template['groups'])
+ (target / 'cases.json').write_text(json.dumps(cases, indent=2) + '\n')
+ print(json.dumps({'output': str(output), 'policy_commit': policy,
+ 'source_base': pins['base'],
+ 'group': template['groups'][0]['id']}, indent=2))
+
+
+if __name__ == '__main__':
+ main()
diff --git a/.ai/review/evals/fix-and-test-verification/groups/event-name-contract/sources.json b/.ai/review/evals/fix-and-test-verification/groups/event-name-contract/sources.json
new file mode 100644
index 00000000000..a173f965648
--- /dev/null
+++ b/.ai/review/evals/fix-and-test-verification/groups/event-name-contract/sources.json
@@ -0,0 +1,8 @@
+{
+ "base": "25ca8e656937378844d3899f8f1c6d0a9307921d",
+ "head": "e8409074363258daff3d66b02feaa75fa6894f72",
+ "patch_sha256": {
+ "original.patch": "e011987573f74c9eebe88f297d3d58d85c2028ffc59530fb1d33ad84f7f06538",
+ "control.patch": "ac19e25d7c27b6af81caccc738cb5fb118edd1fcfac61f94bdb4b29b6e3ac04d"
+ }
+}
diff --git a/.ai/review/evals/hook-callback-reentrancy/README.md b/.ai/review/evals/hook-callback-reentrancy/README.md
new file mode 100644
index 00000000000..fde9558cb66
--- /dev/null
+++ b/.ai/review/evals/hook-callback-reentrancy/README.md
@@ -0,0 +1,25 @@
+# Hook callback re-entrancy cases
+
+These reduced cases follow the GUID and attachment-metadata cycle introduced in [#70198](https://github.com/woocommerce/woocommerce/pull/70198), fixed in [#70782](https://github.com/woocommerce/woocommerce/pull/70782), and covered by the guideline from [#70788](https://github.com/woocommerce/woocommerce/pull/70788).
+
+- G41A-v2 adds an unguarded metadata read to a GUID callback. The behavior harness supplies an external media-plugin callback that fires the GUID hook again; it is not included in the reviewer fixture.
+- G41B-v2 removes `finally` cleanup from a guard. A caught metadata-provider exception leaves later GUID reads bypassing the download check.
+- G41C-v2 adds a guard with `finally` cleanup. Nested calls preserve the supplied GUID, and the outer call still hides downloadable attachments.
+- G41D-v1 refactors an argument-only callback without adding filtered reads.
+
+Run the bounded behavior check without WordPress or model calls:
+
+```sh
+php .ai/review/evals/hook-callback-reentrancy/verify.php
+```
+
+The check models the relevant hook dispatch and metadata short-circuit behavior. It stops recursive calls at a small depth and checks exception recovery, repeated reads, nested and outer return values, and ordinary attachments. It is not a WordPress or Real Media Library integration test.
+
+Run the suite through a compatible review CLI:
+
+```sh
+a8c-review eval --guideline hook-callback-reentrancy --plan
+a8c-review eval --guideline hook-callback-reentrancy
+```
+
+The second command consumes the configured model allowance. A passing behavior check or plan does not establish model recall. These cases do not evaluate the guideline's direct-database-query exemption.
diff --git a/.ai/review/evals/hook-callback-reentrancy/cases.json b/.ai/review/evals/hook-callback-reentrancy/cases.json
new file mode 100644
index 00000000000..bb63f743ca5
--- /dev/null
+++ b/.ai/review/evals/hook-callback-reentrancy/cases.json
@@ -0,0 +1,77 @@
+{
+ "version": 1,
+ "cases": [
+ {
+ "id": "G41A-v2",
+ "path": "plugins/woocommerce/src/Internal/ProductDownloads/DownloadableFileAttachments.php",
+ "before": {
+ "file": "fixtures/G41A/before.php"
+ },
+ "after": {
+ "file": "fixtures/G41A/after.php"
+ },
+ "expect": {
+ "outcome": "finding",
+ "locations": [
+ {
+ "file": "plugins/woocommerce/src/Internal/ProductDownloads/DownloadableFileAttachments.php",
+ "lines": [
+ 13,
+ 13
+ ],
+ "anchor": " $path = get_post_meta( $attachment_id, '_wp_attached_file', true );"
+ }
+ ]
+ }
+ },
+ {
+ "id": "G41B-v2",
+ "path": "plugins/woocommerce/src/Internal/ProductDownloads/DownloadableFileAttachments.php",
+ "before": {
+ "file": "fixtures/G41B/before.php"
+ },
+ "after": {
+ "file": "fixtures/G41B/after.php"
+ },
+ "expect": {
+ "outcome": "finding",
+ "locations": [
+ {
+ "file": "plugins/woocommerce/src/Internal/ProductDownloads/DownloadableFileAttachments.php",
+ "lines": [
+ 13,
+ 16
+ ],
+ "anchor": " $path = get_post_meta( $attachment_id, '_wp_attached_file', true );"
+ }
+ ]
+ }
+ },
+ {
+ "id": "G41C-v2",
+ "path": "plugins/woocommerce/src/Internal/ProductDownloads/DownloadableFileAttachments.php",
+ "before": {
+ "file": "fixtures/G41C/before.php"
+ },
+ "after": {
+ "file": "fixtures/G41C/after.php"
+ },
+ "expect": {
+ "outcome": "quiet"
+ }
+ },
+ {
+ "id": "G41D-v1",
+ "path": "plugins/woocommerce/src/Internal/ProductDownloads/DownloadableFileAttachments.php",
+ "before": {
+ "file": "fixtures/G41D/before.php"
+ },
+ "after": {
+ "file": "fixtures/G41D/after.php"
+ },
+ "expect": {
+ "outcome": "quiet"
+ }
+ }
+ ]
+}
diff --git a/.ai/review/evals/hook-callback-reentrancy/fixtures/G41A/after.php b/.ai/review/evals/hook-callback-reentrancy/fixtures/G41A/after.php
new file mode 100644
index 00000000000..f369bb27929
--- /dev/null
+++ b/.ai/review/evals/hook-callback-reentrancy/fixtures/G41A/after.php
@@ -0,0 +1,19 @@
+<?php
+final class Downloadable_Attachment_Guids {
+ private $reading = false;
+
+ public function register() {
+ add_filter( 'get_the_guid', array( $this, 'filter_guid' ), 10, 2 );
+ }
+
+ public function filter_guid( $guid, $attachment_id ) {
+ if ( $attachment_id <= 0 || 'attachment' !== get_post_type( $attachment_id ) ) {
+ return $guid;
+ }
+ $path = get_post_meta( $attachment_id, '_wp_attached_file', true );
+ return 0 === stripos( (string) $path, 'woocommerce_uploads/' ) ? '' : $guid;
+ }
+
+}
+
+( new Downloadable_Attachment_Guids() )->register();
diff --git a/.ai/review/evals/hook-callback-reentrancy/fixtures/G41A/before.php b/.ai/review/evals/hook-callback-reentrancy/fixtures/G41A/before.php
new file mode 100644
index 00000000000..a4779e6df95
--- /dev/null
+++ b/.ai/review/evals/hook-callback-reentrancy/fixtures/G41A/before.php
@@ -0,0 +1,15 @@
+<?php
+final class Downloadable_Attachment_Guids {
+ private $reading = false;
+
+ public function register() {
+ add_filter( 'get_the_guid', array( $this, 'filter_guid' ), 10, 2 );
+ }
+
+ public function filter_guid( $guid, $attachment_id ) {
+ return $guid;
+ }
+
+}
+
+( new Downloadable_Attachment_Guids() )->register();
diff --git a/.ai/review/evals/hook-callback-reentrancy/fixtures/G41B/after.php b/.ai/review/evals/hook-callback-reentrancy/fixtures/G41B/after.php
new file mode 100644
index 00000000000..e40678506ab
--- /dev/null
+++ b/.ai/review/evals/hook-callback-reentrancy/fixtures/G41B/after.php
@@ -0,0 +1,45 @@
+<?php
+final class Downloadable_Attachment_Guids {
+ private $reading = false;
+
+ public function register() {
+ add_filter( 'get_the_guid', array( $this, 'filter_guid' ), 10, 2 );
+ }
+
+ public function filter_guid( $guid, $attachment_id ) {
+ if ( $this->reading || $attachment_id <= 0 || 'attachment' !== get_post_type( $attachment_id ) ) {
+ return $guid;
+ }
+ $this->reading = true;
+ $path = get_post_meta( $attachment_id, '_wp_attached_file', true );
+ $this->reading = false;
+ return 0 === stripos( (string) $path, 'woocommerce_uploads/' ) ? '' : $guid;
+ }
+
+}
+
+( new Downloadable_Attachment_Guids() )->register();
+
+// A provider can fail once; the request catches the error and later renders another attachment.
+add_filter( 'get_post_metadata', static function ( $value, $object_id, $meta_key, $single ) {
+ static $first = true;
+ if ( $first && '_wp_attached_file' === $meta_key && $single ) {
+ $first = false;
+ throw new RuntimeException( 'Media provider temporarily unavailable' );
+ }
+ return $value;
+}, 5, 4 );
+
+final class Attachment_Guid_Renderer {
+ public function render( $attachment_ids ) {
+ $guids = array();
+ foreach ( $attachment_ids as $attachment_id ) {
+ try {
+ $guids[] = get_the_guid( $attachment_id );
+ } catch ( RuntimeException $error ) {
+ $guids[] = '';
+ }
+ }
+ return $guids;
+ }
+}
diff --git a/.ai/review/evals/hook-callback-reentrancy/fixtures/G41B/before.php b/.ai/review/evals/hook-callback-reentrancy/fixtures/G41B/before.php
new file mode 100644
index 00000000000..695a6bdf631
--- /dev/null
+++ b/.ai/review/evals/hook-callback-reentrancy/fixtures/G41B/before.php
@@ -0,0 +1,48 @@
+<?php
+final class Downloadable_Attachment_Guids {
+ private $reading = false;
+
+ public function register() {
+ add_filter( 'get_the_guid', array( $this, 'filter_guid' ), 10, 2 );
+ }
+
+ public function filter_guid( $guid, $attachment_id ) {
+ if ( $this->reading || $attachment_id <= 0 || 'attachment' !== get_post_type( $attachment_id ) ) {
+ return $guid;
+ }
+ $this->reading = true;
+ try {
+ $path = get_post_meta( $attachment_id, '_wp_attached_file', true );
+ return 0 === stripos( (string) $path, 'woocommerce_uploads/' ) ? '' : $guid;
+ } finally {
+ $this->reading = false;
+ }
+ }
+
+}
+
+( new Downloadable_Attachment_Guids() )->register();
+
+// A provider can fail once; the request catches the error and later renders another attachment.
+add_filter( 'get_post_metadata', static function ( $value, $object_id, $meta_key, $single ) {
+ static $first = true;
+ if ( $first && '_wp_attached_file' === $meta_key && $single ) {
+ $first = false;
+ throw new RuntimeException( 'Media provider temporarily unavailable' );
+ }
+ return $value;
+}, 5, 4 );
+
+final class Attachment_Guid_Renderer {
+ public function render( $attachment_ids ) {
+ $guids = array();
+ foreach ( $attachment_ids as $attachment_id ) {
+ try {
+ $guids[] = get_the_guid( $attachment_id );
+ } catch ( RuntimeException $error ) {
+ $guids[] = '';
+ }
+ }
+ return $guids;
+ }
+}
diff --git a/.ai/review/evals/hook-callback-reentrancy/fixtures/G41C/after.php b/.ai/review/evals/hook-callback-reentrancy/fixtures/G41C/after.php
new file mode 100644
index 00000000000..1feaf09a8ae
--- /dev/null
+++ b/.ai/review/evals/hook-callback-reentrancy/fixtures/G41C/after.php
@@ -0,0 +1,24 @@
+<?php
+final class Downloadable_Attachment_Guids {
+ private $reading = false;
+
+ public function register() {
+ add_filter( 'get_the_guid', array( $this, 'filter_guid' ), 10, 2 );
+ }
+
+ public function filter_guid( $guid, $attachment_id ) {
+ if ( $this->reading || $attachment_id <= 0 || 'attachment' !== get_post_type( $attachment_id ) ) {
+ return $guid;
+ }
+ $this->reading = true;
+ try {
+ $path = get_post_meta( $attachment_id, '_wp_attached_file', true );
+ return 0 === stripos( (string) $path, 'woocommerce_uploads/' ) ? '' : $guid;
+ } finally {
+ $this->reading = false;
+ }
+ }
+
+}
+
+( new Downloadable_Attachment_Guids() )->register();
diff --git a/.ai/review/evals/hook-callback-reentrancy/fixtures/G41C/before.php b/.ai/review/evals/hook-callback-reentrancy/fixtures/G41C/before.php
new file mode 100644
index 00000000000..f369bb27929
--- /dev/null
+++ b/.ai/review/evals/hook-callback-reentrancy/fixtures/G41C/before.php
@@ -0,0 +1,19 @@
+<?php
+final class Downloadable_Attachment_Guids {
+ private $reading = false;
+
+ public function register() {
+ add_filter( 'get_the_guid', array( $this, 'filter_guid' ), 10, 2 );
+ }
+
+ public function filter_guid( $guid, $attachment_id ) {
+ if ( $attachment_id <= 0 || 'attachment' !== get_post_type( $attachment_id ) ) {
+ return $guid;
+ }
+ $path = get_post_meta( $attachment_id, '_wp_attached_file', true );
+ return 0 === stripos( (string) $path, 'woocommerce_uploads/' ) ? '' : $guid;
+ }
+
+}
+
+( new Downloadable_Attachment_Guids() )->register();
diff --git a/.ai/review/evals/hook-callback-reentrancy/fixtures/G41D/after.php b/.ai/review/evals/hook-callback-reentrancy/fixtures/G41D/after.php
new file mode 100644
index 00000000000..ff342027435
--- /dev/null
+++ b/.ai/review/evals/hook-callback-reentrancy/fixtures/G41D/after.php
@@ -0,0 +1,14 @@
+<?php
+final class Attachment_Guid_Scheme {
+ public function register() {
+ add_filter( 'get_the_guid', array( $this, 'filter_guid' ), 10, 2 );
+ }
+
+ public function filter_guid( $guid, $attachment_id ) {
+ return is_string( $guid ) && 0 === strpos( $guid, 'legacy-media://' )
+ ? 'media://' . substr( $guid, strlen( 'legacy-media://' ) )
+ : $guid;
+ }
+}
+
+( new Attachment_Guid_Scheme() )->register();
diff --git a/.ai/review/evals/hook-callback-reentrancy/fixtures/G41D/before.php b/.ai/review/evals/hook-callback-reentrancy/fixtures/G41D/before.php
new file mode 100644
index 00000000000..48ff54b861a
--- /dev/null
+++ b/.ai/review/evals/hook-callback-reentrancy/fixtures/G41D/before.php
@@ -0,0 +1,15 @@
+<?php
+final class Attachment_Guid_Scheme {
+ public function register() {
+ add_filter( 'get_the_guid', array( $this, 'filter_guid' ), 10, 2 );
+ }
+
+ public function filter_guid( $guid, $attachment_id ) {
+ if ( ! is_string( $guid ) || 0 !== strpos( $guid, 'legacy-media://' ) ) {
+ return $guid;
+ }
+ return 'media://' . substr( $guid, strlen( 'legacy-media://' ) );
+ }
+}
+
+( new Attachment_Guid_Scheme() )->register();
diff --git a/.ai/review/evals/hook-callback-reentrancy/verify.php b/.ai/review/evals/hook-callback-reentrancy/verify.php
new file mode 100644
index 00000000000..3e37b6d8436
--- /dev/null
+++ b/.ai/review/evals/hook-callback-reentrancy/verify.php
@@ -0,0 +1,156 @@
+<?php
+/** Bounded checks of the reduced fixtures, without WordPress or model calls. */
+final class Reentrancy_Fixture_Check {
+ public static function same( $expected, $actual, $message ) {
+ if ( $expected !== $actual ) {
+ throw new RuntimeException( $message );
+ }
+ }
+}
+
+final class Fixture_Recursion_Limit extends RuntimeException {}
+
+if ( ! isset( $argv[1], $argv[2] ) ) {
+ foreach ( array( 'G41A', 'G41B', 'G41C', 'G41D' ) as $case ) {
+ foreach ( array( 'before', 'after' ) as $side ) {
+ passthru( escapeshellarg( PHP_BINARY ) . ' ' . escapeshellarg( __FILE__ ) . ' ' . $case . ' ' . $side, $status );
+ if ( 0 !== $status ) {
+ exit( $status );
+ }
+ }
+ }
+ echo "All eight fixture snapshots behaved as expected.\n";
+ exit( 0 );
+}
+
+$case = $argv[1];
+$side = $argv[2];
+if ( ! in_array( $case, array( 'G41A', 'G41B', 'G41C', 'G41D' ), true ) || ! in_array( $side, array( 'before', 'after' ), true ) ) {
+ throw new InvalidArgumentException( 'Unknown fixture' );
+}
+
+$filters = array();
+$hook_errors = array();
+$guid_depth = 0;
+$nested_guids = array();
+$metadata_reads = 0;
+$fail_next_read = false;
+
+function add_filter( $name, $callback, $priority = 10, $accepted_args = 1 ) {
+ $GLOBALS['filters'][ $name ][ $priority ][] = array( $callback, $accepted_args );
+}
+
+function apply_filters( $name, $value, ...$args ) {
+ $priorities = $GLOBALS['filters'][ $name ] ?? array();
+ ksort( $priorities );
+ foreach ( $priorities as $callbacks ) {
+ foreach ( $callbacks as list( $callback, $accepted_args ) ) {
+ try {
+ $value = $callback( ...array_slice( array( $value, ...$args ), 0, $accepted_args ) );
+ } catch ( Throwable $error ) {
+ if ( 'get_post_metadata' === $name ) {
+ $GLOBALS['hook_errors'][] = $error->getMessage();
+ }
+ throw $error;
+ }
+ }
+ }
+ return $value;
+}
+
+function get_post_type( $id ) {
+ return 104 === $id ? 'post' : 'attachment';
+}
+
+function get_the_guid( $id ) {
+ $GLOBALS['guid_depth']++;
+ try {
+ if ( $GLOBALS['guid_depth'] > 8 ) {
+ throw new Fixture_Recursion_Limit( 'Mutual recursion exceeded eight GUID calls' );
+ }
+ $stored = 103 === $id ? 'rml://shortcut' : 'https://example.test/attachment-' . $id;
+ $result = apply_filters( 'get_the_guid', $stored, $id );
+ if ( $GLOBALS['guid_depth'] > 1 ) {
+ $GLOBALS['nested_guids'][] = $result;
+ }
+ return $result;
+ } finally {
+ $GLOBALS['guid_depth']--;
+ }
+}
+
+function get_post_meta( $id, $key, $single = false ) {
+ $GLOBALS['metadata_reads']++;
+ if ( $GLOBALS['fail_next_read'] ) {
+ $GLOBALS['fail_next_read'] = false;
+ throw new RuntimeException( 'Injected provider failure' );
+ }
+ $value = apply_filters( 'get_post_metadata', null, $id, $key, $single );
+ if ( null !== $value ) {
+ return $single && is_array( $value ) ? reset( $value ) : $value;
+ }
+ $stored = 101 === $id ? 'woocommerce_uploads/private.pdf' : '2026/photo.jpg';
+ return $single ? $stored : array( $stored );
+}
+
+if ( 'G41D' !== $case ) {
+ // A media plugin resolves virtual attachments while filtering the stored file path.
+ add_filter( 'get_post_metadata', static function ( $value, $object_id, $meta_key, $single ) {
+ if ( '_wp_attached_file' === $meta_key && $single ) {
+ $guid = get_the_guid( $object_id );
+ if ( is_string( $guid ) && 0 === strpos( $guid, 'rml://' ) ) {
+ return 'shared/library-file.jpg';
+ }
+ }
+ return $value;
+ }, 10, 4 );
+}
+
+require __DIR__ . '/fixtures/' . $case . '/' . $side . '.php';
+
+$should_recurse = ( 'G41A' === $case && 'after' === $side ) || ( 'G41C' === $case && 'before' === $side );
+if ( $should_recurse ) {
+ $caught = false;
+ try {
+ get_the_guid( 101 );
+ } catch ( Fixture_Recursion_Limit $error ) {
+ $caught = true;
+ }
+ Reentrancy_Fixture_Check::same( true, $caught, 'Expected bounded mutual recursion' );
+ Reentrancy_Fixture_Check::same( 0, $guid_depth, 'Stack depth did not unwind' );
+} elseif ( 'G41B' === $case ) {
+ $expected = 'before' === $side
+ ? array( '', '', '' )
+ : array( '', 'https://example.test/attachment-101', 'https://example.test/attachment-101' );
+ Reentrancy_Fixture_Check::same( $expected, ( new Attachment_Guid_Renderer() )->render( array( 101, 101, 101 ) ), 'Wrong results after a caught provider exception' );
+ Reentrancy_Fixture_Check::same( array( 'Media provider temporarily unavailable' ), $hook_errors, 'Expected exactly one provider failure before successful later reads' );
+} elseif ( 'G41C' === $case ) {
+ Reentrancy_Fixture_Check::same( '', get_the_guid( 101 ), 'Outer call must hide the downloadable attachment' );
+ Reentrancy_Fixture_Check::same( array( 'https://example.test/attachment-101' ), $nested_guids, 'Nested call must preserve its input' );
+ Reentrancy_Fixture_Check::same( 'https://example.test/attachment-102', get_the_guid( 102 ), 'Ordinary attachment changed' );
+ Reentrancy_Fixture_Check::same( 'rml://shortcut', get_the_guid( 103 ), 'Metadata short-circuit changed' );
+ $reads = $metadata_reads;
+ Reentrancy_Fixture_Check::same( 'https://example.test/attachment-104', get_the_guid( 104 ), 'Non-attachment changed' );
+ Reentrancy_Fixture_Check::same( 'https://example.test/attachment-0', get_the_guid( 0 ), 'Invalid ID changed' );
+ Reentrancy_Fixture_Check::same( $reads, $metadata_reads, 'Cheap checks must precede the filtered read' );
+ $fail_next_read = true;
+ $caught = false;
+ try {
+ get_the_guid( 101 );
+ } catch ( RuntimeException $error ) {
+ Reentrancy_Fixture_Check::same( 'Injected provider failure', $error->getMessage(), 'Wrong injected exception' );
+ $caught = true;
+ }
+ Reentrancy_Fixture_Check::same( true, $caught, 'Expected injected exception' );
+ Reentrancy_Fixture_Check::same( '', get_the_guid( 101 ), 'Guard did not reset after exception' );
+ Reentrancy_Fixture_Check::same( '', get_the_guid( 101 ), 'Guard did not reset after normal return' );
+} elseif ( 'G41D' === $case ) {
+ foreach ( array( array( 'legacy-media://item', 'media://item' ), array( 'https://example.test/image', 'https://example.test/image' ), array( null, null ), array( false, false ), array( '', '' ) ) as list( $input, $expected ) ) {
+ Reentrancy_Fixture_Check::same( $expected, apply_filters( 'get_the_guid', $input, 101 ), 'Argument-only refactor changed its result' );
+ }
+ Reentrancy_Fixture_Check::same( 0, $metadata_reads, 'Argument-only callback read metadata' );
+} else {
+ Reentrancy_Fixture_Check::same( 'https://example.test/attachment-101', get_the_guid( 101 ), 'Baseline must preserve its input' );
+ Reentrancy_Fixture_Check::same( 0, $metadata_reads, 'Baseline unexpectedly read metadata' );
+}
+echo 'PASS: ' . $case . ' ' . $side . "\n";
diff --git a/.ai/review/evals/hook-firing-scope/cases.json b/.ai/review/evals/hook-firing-scope/cases.json
new file mode 100644
index 00000000000..32a450ae943
--- /dev/null
+++ b/.ai/review/evals/hook-firing-scope/cases.json
@@ -0,0 +1,77 @@
+{
+ "version": 1,
+ "cases": [
+ {
+ "id": "G02A-v3",
+ "path": "plugins/woocommerce/includes/class-wc-hook-firing-scope.php",
+ "before": {
+ "file": "fixtures/G02A/before.php"
+ },
+ "after": {
+ "file": "fixtures/G02A/after.php"
+ },
+ "expect": {
+ "outcome": "finding",
+ "locations": [
+ {
+ "file": "plugins/woocommerce/includes/class-wc-hook-firing-scope.php",
+ "lines": [
+ 3,
+ 4
+ ],
+ "anchor": " if ( $customer ) {"
+ }
+ ]
+ }
+ },
+ {
+ "id": "G02B-v3",
+ "path": "plugins/woocommerce/includes/class-wc-hook-firing-scope.php",
+ "before": {
+ "file": "fixtures/G02B/before.php"
+ },
+ "after": {
+ "file": "fixtures/G02B/after.php"
+ },
+ "expect": {
+ "outcome": "finding",
+ "locations": [
+ {
+ "file": "plugins/woocommerce/includes/class-wc-hook-firing-scope.php",
+ "lines": [
+ 5,
+ 5
+ ],
+ "anchor": " $store->delete_items( $id );"
+ }
+ ]
+ }
+ },
+ {
+ "id": "G02C-v3",
+ "path": "plugins/woocommerce/includes/class-wc-hook-firing-scope.php",
+ "before": {
+ "file": "fixtures/G02C/before.php"
+ },
+ "after": {
+ "file": "fixtures/G02C/after.php"
+ },
+ "expect": {
+ "outcome": "quiet"
+ }
+ },
+ {
+ "id": "G02D-v3",
+ "path": "plugins/woocommerce/includes/class-wc-hook-firing-scope.php",
+ "before": {
+ "file": "fixtures/G02D/before.php"
+ },
+ "after": {
+ "file": "fixtures/G02D/after.php"
+ },
+ "expect": {
+ "outcome": "quiet"
+ }
+ }
+ ]
+}
diff --git a/.ai/review/evals/hook-firing-scope/fixtures/G02A/after.php b/.ai/review/evals/hook-firing-scope/fixtures/G02A/after.php
new file mode 100644
index 00000000000..9ed7261a018
--- /dev/null
+++ b/.ai/review/evals/hook-firing-scope/fixtures/G02A/after.php
@@ -0,0 +1,12 @@
+<?php
+function wc_notify_customer_created( $customer, $is_new ) {
+ if ( $customer ) {
+ do_action( 'woocommerce_analytics_new_customer', $customer->get_id() );
+ }
+ return $customer;
+}
+
+function customer_imported( $customer_id ) {
+ $customer = new WC_Customer( $customer_id );
+ return wc_notify_customer_created( $customer, 0 === $customer_id );
+}
diff --git a/.ai/review/evals/hook-firing-scope/fixtures/G02A/before.php b/.ai/review/evals/hook-firing-scope/fixtures/G02A/before.php
new file mode 100644
index 00000000000..83f01d2171e
--- /dev/null
+++ b/.ai/review/evals/hook-firing-scope/fixtures/G02A/before.php
@@ -0,0 +1,12 @@
+<?php
+function wc_notify_customer_created( $customer, $is_new ) {
+ if ( $is_new ) {
+ do_action( 'woocommerce_analytics_new_customer', $customer->get_id() );
+ }
+ return $customer;
+}
+
+function customer_imported( $customer_id ) {
+ $customer = new WC_Customer( $customer_id );
+ return wc_notify_customer_created( $customer, 0 === $customer_id );
+}
diff --git a/.ai/review/evals/hook-firing-scope/fixtures/G02B/after.php b/.ai/review/evals/hook-firing-scope/fixtures/G02B/after.php
new file mode 100644
index 00000000000..c95e4bf7922
--- /dev/null
+++ b/.ai/review/evals/hook-firing-scope/fixtures/G02B/after.php
@@ -0,0 +1,15 @@
+<?php
+class WC_Item_Writer {
+ protected function delete_items( $store, $id ) { $store->delete_items( $id ); }
+ public function replace_items( $store, $id, $items ) {
+ $store->delete_items( $id );
+ $store->insert_items( $id, $items );
+ }
+}
+
+class Extension_Item_Writer extends WC_Item_Writer {
+ protected function delete_items( $store, $id ) {
+ $store->delete_extension_items( $id );
+ parent::delete_items( $store, $id );
+ }
+}
diff --git a/.ai/review/evals/hook-firing-scope/fixtures/G02B/before.php b/.ai/review/evals/hook-firing-scope/fixtures/G02B/before.php
new file mode 100644
index 00000000000..87df9c0a3af
--- /dev/null
+++ b/.ai/review/evals/hook-firing-scope/fixtures/G02B/before.php
@@ -0,0 +1,15 @@
+<?php
+class WC_Item_Writer {
+ protected function delete_items( $store, $id ) { $store->delete_items( $id ); }
+ public function replace_items( $store, $id, $items ) {
+ $this->delete_items( $store, $id );
+ $store->insert_items( $id, $items );
+ }
+}
+
+class Extension_Item_Writer extends WC_Item_Writer {
+ protected function delete_items( $store, $id ) {
+ $store->delete_extension_items( $id );
+ parent::delete_items( $store, $id );
+ }
+}
diff --git a/.ai/review/evals/hook-firing-scope/fixtures/G02C/after.php b/.ai/review/evals/hook-firing-scope/fixtures/G02C/after.php
new file mode 100644
index 00000000000..630d6d55c61
--- /dev/null
+++ b/.ai/review/evals/hook-firing-scope/fixtures/G02C/after.php
@@ -0,0 +1,6 @@
+<?php
+function wc_emit_export_done( $id ) {
+ do_action( 'woocommerce_export_done', $id );
+ $result = array( 'id' => $id );
+ return $result;
+}
diff --git a/.ai/review/evals/hook-firing-scope/fixtures/G02C/before.php b/.ai/review/evals/hook-firing-scope/fixtures/G02C/before.php
new file mode 100644
index 00000000000..e870a1e1253
--- /dev/null
+++ b/.ai/review/evals/hook-firing-scope/fixtures/G02C/before.php
@@ -0,0 +1,6 @@
+<?php
+function wc_emit_export_done( $id ) {
+ $result = array( 'id' => $id );
+ do_action( 'woocommerce_export_done', $id );
+ return $result;
+}
diff --git a/.ai/review/evals/hook-firing-scope/fixtures/G02D/after.php b/.ai/review/evals/hook-firing-scope/fixtures/G02D/after.php
new file mode 100644
index 00000000000..882b29d93d4
--- /dev/null
+++ b/.ai/review/evals/hook-firing-scope/fixtures/G02D/after.php
@@ -0,0 +1,7 @@
+<?php
+class WC_Export_Notifier {
+ public function register() {
+ add_action( 'woocommerce_export_done', array( $this, 'notify' ), 10, 1 );
+ }
+ public function notify( $id ) { error_log( 'export ' . $id ); }
+}
diff --git a/.ai/review/evals/hook-firing-scope/fixtures/G02D/before.php b/.ai/review/evals/hook-firing-scope/fixtures/G02D/before.php
new file mode 100644
index 00000000000..83453a5f916
--- /dev/null
+++ b/.ai/review/evals/hook-firing-scope/fixtures/G02D/before.php
@@ -0,0 +1,8 @@
+<?php
+class WC_Export_Notifier {
+ public function register() {
+ add_action( 'woocommerce_export_done', array( $this, 'notify' ), 10, 1 );
+ add_action( 'woocommerce_export_done', array( $this, 'notify' ), 10, 1 );
+ }
+ public function notify( $id ) { error_log( 'export ' . $id ); }
+}
diff --git a/.ai/review/evals/hook-payload-contract/cases.json b/.ai/review/evals/hook-payload-contract/cases.json
new file mode 100644
index 00000000000..409d01d423a
--- /dev/null
+++ b/.ai/review/evals/hook-payload-contract/cases.json
@@ -0,0 +1,85 @@
+{
+ "version": 1,
+ "cases": [
+ {
+ "id": "G01A-v3",
+ "path": "plugins/woocommerce/includes/class-wc-hook-payload-contract.php",
+ "before": {
+ "file": "fixtures/G01A/before.php"
+ },
+ "after": {
+ "file": "fixtures/G01A/after.php"
+ },
+ "expect": {
+ "outcome": "finding",
+ "locations": [
+ {
+ "file": "plugins/woocommerce/includes/class-wc-hook-payload-contract.php",
+ "lines": [
+ 3,
+ 3
+ ],
+ "anchor": " do_action( 'woocommerce_order_export_status', $id, 'wc-' . $status );"
+ }
+ ]
+ }
+ },
+ {
+ "id": "G01B-v4",
+ "path": "plugins/woocommerce/includes/class-wc-hook-payload-contract.php",
+ "before": {
+ "file": "fixtures/G01B/before.php"
+ },
+ "after": {
+ "file": "fixtures/G01B/after.php"
+ },
+ "expect": {
+ "outcome": "finding",
+ "locations": [
+ {
+ "file": "plugins/woocommerce/includes/class-wc-hook-payload-contract.php",
+ "lines": [
+ 3,
+ 3
+ ],
+ "anchor": " $args = array( 'id' => $order->get_id(), 'state' => $order->get_status() );"
+ },
+ {
+ "file": "plugins/woocommerce/includes/class-wc-hook-payload-contract.php",
+ "lines": [
+ 8,
+ 8
+ ],
+ "anchor": " $args['include_in_export'] = 'pending' === $args['status'];"
+ }
+ ]
+ }
+ },
+ {
+ "id": "G01C-v3",
+ "path": "plugins/woocommerce/includes/class-wc-hook-payload-contract.php",
+ "before": {
+ "file": "fixtures/G01C/before.php"
+ },
+ "after": {
+ "file": "fixtures/G01C/after.php"
+ },
+ "expect": {
+ "outcome": "quiet"
+ }
+ },
+ {
+ "id": "G01D-v3",
+ "path": "plugins/woocommerce/includes/class-wc-hook-payload-contract.php",
+ "before": {
+ "file": "fixtures/G01D/before.php"
+ },
+ "after": {
+ "file": "fixtures/G01D/after.php"
+ },
+ "expect": {
+ "outcome": "quiet"
+ }
+ }
+ ]
+}
diff --git a/.ai/review/evals/hook-payload-contract/fixtures/G01A/after.php b/.ai/review/evals/hook-payload-contract/fixtures/G01A/after.php
new file mode 100644
index 00000000000..bd7fdc78521
--- /dev/null
+++ b/.ai/review/evals/hook-payload-contract/fixtures/G01A/after.php
@@ -0,0 +1,8 @@
+<?php
+function wc_notify_status( $id, $status ) {
+ do_action( 'woocommerce_order_export_status', $id, 'wc-' . $status );
+}
+
+add_action( 'woocommerce_order_export_status', static function ( $id, $status ) {
+ if ( 'pending' === $status ) { update_option( 'export_pending_order', $id ); }
+}, 10, 2 );
diff --git a/.ai/review/evals/hook-payload-contract/fixtures/G01A/before.php b/.ai/review/evals/hook-payload-contract/fixtures/G01A/before.php
new file mode 100644
index 00000000000..fe346bf55bb
--- /dev/null
+++ b/.ai/review/evals/hook-payload-contract/fixtures/G01A/before.php
@@ -0,0 +1,8 @@
+<?php
+function wc_notify_status( $id, $status ) {
+ do_action( 'woocommerce_order_export_status', $id, $status );
+}
+
+add_action( 'woocommerce_order_export_status', static function ( $id, $status ) {
+ if ( 'pending' === $status ) { update_option( 'export_pending_order', $id ); }
+}, 10, 2 );
diff --git a/.ai/review/evals/hook-payload-contract/fixtures/G01B/after.php b/.ai/review/evals/hook-payload-contract/fixtures/G01B/after.php
new file mode 100644
index 00000000000..56bf9b6459d
--- /dev/null
+++ b/.ai/review/evals/hook-payload-contract/fixtures/G01B/after.php
@@ -0,0 +1,10 @@
+<?php
+function wc_export_args( $order ) {
+ $args = array( 'id' => $order->get_id(), 'state' => $order->get_status() );
+ return apply_filters( 'woocommerce_export_args', $args, $order );
+}
+
+add_filter( 'woocommerce_export_args', static function ( $args, $order ) {
+ $args['include_in_export'] = 'pending' === $args['status'];
+ return $args;
+}, 10, 2 );
diff --git a/.ai/review/evals/hook-payload-contract/fixtures/G01B/before.php b/.ai/review/evals/hook-payload-contract/fixtures/G01B/before.php
new file mode 100644
index 00000000000..b9da7ef3017
--- /dev/null
+++ b/.ai/review/evals/hook-payload-contract/fixtures/G01B/before.php
@@ -0,0 +1,10 @@
+<?php
+function wc_export_args( $order ) {
+ $args = array( 'id' => $order->get_id(), 'status' => $order->get_status() );
+ return apply_filters( 'woocommerce_export_args', $args, $order );
+}
+
+add_filter( 'woocommerce_export_args', static function ( $args, $order ) {
+ $args['include_in_export'] = 'pending' === $args['status'];
+ return $args;
+}, 10, 2 );
diff --git a/.ai/review/evals/hook-payload-contract/fixtures/G01C/after.php b/.ai/review/evals/hook-payload-contract/fixtures/G01C/after.php
new file mode 100644
index 00000000000..397262bfbff
--- /dev/null
+++ b/.ai/review/evals/hook-payload-contract/fixtures/G01C/after.php
@@ -0,0 +1,10 @@
+<?php
+function wc_export_args( $order ) {
+ $args = array( 'id' => $order->get_id(), 'status' => $order->get_status() );
+ return apply_filters( 'woocommerce_export_args', $args, $order, 'admin' );
+}
+
+add_filter( 'woocommerce_export_args', static function ( $args, $order ) {
+ $args['include_in_export'] = 'pending' === $args['status'];
+ return $args;
+}, 10, 2 );
diff --git a/.ai/review/evals/hook-payload-contract/fixtures/G01C/before.php b/.ai/review/evals/hook-payload-contract/fixtures/G01C/before.php
new file mode 100644
index 00000000000..b9da7ef3017
--- /dev/null
+++ b/.ai/review/evals/hook-payload-contract/fixtures/G01C/before.php
@@ -0,0 +1,10 @@
+<?php
+function wc_export_args( $order ) {
+ $args = array( 'id' => $order->get_id(), 'status' => $order->get_status() );
+ return apply_filters( 'woocommerce_export_args', $args, $order );
+}
+
+add_filter( 'woocommerce_export_args', static function ( $args, $order ) {
+ $args['include_in_export'] = 'pending' === $args['status'];
+ return $args;
+}, 10, 2 );
diff --git a/.ai/review/evals/hook-payload-contract/fixtures/G01D/after.php b/.ai/review/evals/hook-payload-contract/fixtures/G01D/after.php
new file mode 100644
index 00000000000..18f9ae590f8
--- /dev/null
+++ b/.ai/review/evals/hook-payload-contract/fixtures/G01D/after.php
@@ -0,0 +1,9 @@
+<?php
+function wc_export_payload( $id, $status ) {
+ $row = (object) array( 'id' => $id, 'status' => $status );
+ do_action( 'woocommerce_order_export_status', $row->id, $row->status );
+}
+
+add_action( 'woocommerce_order_export_status', static function ( $id, $status ) {
+ if ( 'pending' === $status ) { update_option( 'export_pending_order', $id ); }
+}, 10, 2 );
diff --git a/.ai/review/evals/hook-payload-contract/fixtures/G01D/before.php b/.ai/review/evals/hook-payload-contract/fixtures/G01D/before.php
new file mode 100644
index 00000000000..426df2e307e
--- /dev/null
+++ b/.ai/review/evals/hook-payload-contract/fixtures/G01D/before.php
@@ -0,0 +1,9 @@
+<?php
+function wc_export_payload( $id, $status ) {
+ $row = array( $id, $status );
+ do_action( 'woocommerce_order_export_status', $row[0], $row[1] );
+}
+
+add_action( 'woocommerce_order_export_status', static function ( $id, $status ) {
+ if ( 'pending' === $status ) { update_option( 'export_pending_order', $id ); }
+}, 10, 2 );
diff --git a/.ai/review/evals/hpos-sync-on-read/cases.json b/.ai/review/evals/hpos-sync-on-read/cases.json
new file mode 100644
index 00000000000..c45eb872034
--- /dev/null
+++ b/.ai/review/evals/hpos-sync-on-read/cases.json
@@ -0,0 +1,77 @@
+{
+ "version": 1,
+ "cases": [
+ {
+ "id": "G10A-v3",
+ "path": "plugins/woocommerce/src/Internal/DataStores/Orders/OrderMetaSynchronizer.php",
+ "before": {
+ "file": "fixtures/G10A/before.php"
+ },
+ "after": {
+ "file": "fixtures/G10A/after.php"
+ },
+ "expect": {
+ "outcome": "finding",
+ "locations": [
+ {
+ "file": "plugins/woocommerce/src/Internal/DataStores/Orders/OrderMetaSynchronizer.php",
+ "lines": [
+ 5,
+ 5
+ ],
+ "anchor": " if ( $hpos === $cpt ) { return; }"
+ }
+ ]
+ }
+ },
+ {
+ "id": "G10B-v4",
+ "path": "plugins/woocommerce/src/Internal/DataStores/Orders/OrderMetaSynchronizer.php",
+ "before": {
+ "file": "fixtures/G10B/before.php"
+ },
+ "after": {
+ "file": "fixtures/G10B/after.php"
+ },
+ "expect": {
+ "outcome": "finding",
+ "locations": [
+ {
+ "file": "plugins/woocommerce/src/Internal/DataStores/Orders/OrderMetaSynchronizer.php",
+ "lines": [
+ 5,
+ 7
+ ],
+ "anchor": " $status = preg_replace( '/^wc-/', '', $post->post_status );"
+ }
+ ]
+ }
+ },
+ {
+ "id": "G10C-v3",
+ "path": "plugins/woocommerce/src/Internal/DataStores/Orders/OrderMetaSynchronizer.php",
+ "before": {
+ "file": "fixtures/G10C/before.php"
+ },
+ "after": {
+ "file": "fixtures/G10C/after.php"
+ },
+ "expect": {
+ "outcome": "quiet"
+ }
+ },
+ {
+ "id": "G10D-v3",
+ "path": "plugins/woocommerce/src/Internal/DataStores/Orders/OrderMetaSynchronizer.php",
+ "before": {
+ "file": "fixtures/G10D/before.php"
+ },
+ "after": {
+ "file": "fixtures/G10D/after.php"
+ },
+ "expect": {
+ "outcome": "quiet"
+ }
+ }
+ ]
+}
diff --git a/.ai/review/evals/hpos-sync-on-read/fixtures/G10A/after.php b/.ai/review/evals/hpos-sync-on-read/fixtures/G10A/after.php
new file mode 100644
index 00000000000..a5c234f59e2
--- /dev/null
+++ b/.ai/review/evals/hpos-sync-on-read/fixtures/G10A/after.php
@@ -0,0 +1,15 @@
+<?php
+/* Reduced HPOS compatibility synchronizer; the read adapter below enables synchronization. */
+class OrderMetaSynchronizer {
+ public function sync( $hpos, $cpt, $store ) {
+ if ( $hpos === $cpt ) { return; }
+ $store->write_meta( $hpos );
+ }
+}
+
+function read_compatible_order_meta( $hpos_json, $cpt_json, $store ) {
+ $hpos = json_decode( $hpos_json );
+ $cpt = json_decode( $cpt_json );
+ ( new OrderMetaSynchronizer() )->sync( $hpos, $cpt, $store );
+ return $hpos;
+}
diff --git a/.ai/review/evals/hpos-sync-on-read/fixtures/G10A/before.php b/.ai/review/evals/hpos-sync-on-read/fixtures/G10A/before.php
new file mode 100644
index 00000000000..517dca3f676
--- /dev/null
+++ b/.ai/review/evals/hpos-sync-on-read/fixtures/G10A/before.php
@@ -0,0 +1,15 @@
+<?php
+/* Reduced HPOS compatibility synchronizer; the read adapter below enables synchronization. */
+class OrderMetaSynchronizer {
+ public function sync( $hpos, $cpt, $store ) {
+ if ( $hpos == $cpt ) { return; }
+ $store->write_meta( $hpos );
+ }
+}
+
+function read_compatible_order_meta( $hpos_json, $cpt_json, $store ) {
+ $hpos = json_decode( $hpos_json );
+ $cpt = json_decode( $cpt_json );
+ ( new OrderMetaSynchronizer() )->sync( $hpos, $cpt, $store );
+ return $hpos;
+}
diff --git a/.ai/review/evals/hpos-sync-on-read/fixtures/G10B/after.php b/.ai/review/evals/hpos-sync-on-read/fixtures/G10B/after.php
new file mode 100644
index 00000000000..505dc7588d1
--- /dev/null
+++ b/.ai/review/evals/hpos-sync-on-read/fixtures/G10B/after.php
@@ -0,0 +1,14 @@
+<?php
+/* Reduced HPOS compatibility synchronizer; HPOS is authoritative. */
+class OrderMetaSynchronizer {
+ public function sync_post( $post, $order, $store ) {
+ $status = preg_replace( '/^wc-/', '', $post->post_status );
+ if ( $status === $order->get_status() ) { return; }
+ $store->write_status( $order->get_id(), $status );
+ }
+}
+
+function read_placeholder( $order, $store ) {
+ $post = (object) array( 'post_type' => 'shop_order_placehold', 'post_status' => 'draft' );
+ ( new OrderMetaSynchronizer() )->sync_post( $post, $order, $store );
+}
diff --git a/.ai/review/evals/hpos-sync-on-read/fixtures/G10B/before.php b/.ai/review/evals/hpos-sync-on-read/fixtures/G10B/before.php
new file mode 100644
index 00000000000..4bfd9d8e93a
--- /dev/null
+++ b/.ai/review/evals/hpos-sync-on-read/fixtures/G10B/before.php
@@ -0,0 +1,15 @@
+<?php
+/* Reduced HPOS compatibility synchronizer; HPOS is authoritative. */
+class OrderMetaSynchronizer {
+ public function sync_post( $post, $order, $store ) {
+ if ( 'shop_order_placehold' === $post->post_type ) { return; }
+ $status = preg_replace( '/^wc-/', '', $post->post_status );
+ if ( $status === $order->get_status() ) { return; }
+ $store->write_status( $order->get_id(), $status );
+ }
+}
+
+function read_placeholder( $order, $store ) {
+ $post = (object) array( 'post_type' => 'shop_order_placehold', 'post_status' => 'draft' );
+ ( new OrderMetaSynchronizer() )->sync_post( $post, $order, $store );
+}
diff --git a/.ai/review/evals/hpos-sync-on-read/fixtures/G10C/after.php b/.ai/review/evals/hpos-sync-on-read/fixtures/G10C/after.php
new file mode 100644
index 00000000000..350d4be45e8
--- /dev/null
+++ b/.ai/review/evals/hpos-sync-on-read/fixtures/G10C/after.php
@@ -0,0 +1,9 @@
+<?php
+final class OrderMetaSynchronizer {
+ public function sync_dates( DateTimeImmutable $hpos_date, DateTimeImmutable $cpt_date, $store ) {
+ $hpos = $hpos_date->format( 'U' );
+ $cpt = $cpt_date->format( 'U' );
+ if ( $hpos === $cpt ) { return; }
+ $store->write_modified( $hpos_date );
+ }
+}
diff --git a/.ai/review/evals/hpos-sync-on-read/fixtures/G10C/before.php b/.ai/review/evals/hpos-sync-on-read/fixtures/G10C/before.php
new file mode 100644
index 00000000000..f1b9cdc355a
--- /dev/null
+++ b/.ai/review/evals/hpos-sync-on-read/fixtures/G10C/before.php
@@ -0,0 +1,9 @@
+<?php
+final class OrderMetaSynchronizer {
+ public function sync_dates( DateTimeImmutable $hpos_date, DateTimeImmutable $cpt_date, $store ) {
+ $hpos = $hpos_date->format( 'U' );
+ $cpt = $cpt_date->format( 'U' );
+ if ( $hpos == $cpt ) { return; }
+ $store->write_modified( $hpos_date );
+ }
+}
diff --git a/.ai/review/evals/hpos-sync-on-read/fixtures/G10D/after.php b/.ai/review/evals/hpos-sync-on-read/fixtures/G10D/after.php
new file mode 100644
index 00000000000..8627a780831
--- /dev/null
+++ b/.ai/review/evals/hpos-sync-on-read/fixtures/G10D/after.php
@@ -0,0 +1,8 @@
+<?php
+/* Order modified dates are DateTimeImmutable instances. */
+class OrderMetaSynchronizer {
+ public function sync_date( $hpos_date, $cpt_date, $store ) {
+ if ( 0 === ( $hpos_date->getTimestamp() - $cpt_date->getTimestamp() ) ) { return; }
+ $store->write_modified( $hpos_date );
+ }
+}
diff --git a/.ai/review/evals/hpos-sync-on-read/fixtures/G10D/before.php b/.ai/review/evals/hpos-sync-on-read/fixtures/G10D/before.php
new file mode 100644
index 00000000000..cf6cd94cc95
--- /dev/null
+++ b/.ai/review/evals/hpos-sync-on-read/fixtures/G10D/before.php
@@ -0,0 +1,8 @@
+<?php
+/* Order modified dates are DateTimeImmutable instances. */
+class OrderMetaSynchronizer {
+ public function sync_date( $hpos_date, $cpt_date, $store ) {
+ if ( $hpos_date->getTimestamp() === $cpt_date->getTimestamp() ) { return; }
+ $store->write_modified( $hpos_date );
+ }
+}
diff --git a/.ai/review/evals/i18n-string-identity/cases.json b/.ai/review/evals/i18n-string-identity/cases.json
new file mode 100644
index 00000000000..4056922d0c9
--- /dev/null
+++ b/.ai/review/evals/i18n-string-identity/cases.json
@@ -0,0 +1,77 @@
+{
+ "version": 1,
+ "cases": [
+ {
+ "id": "G35A-v3",
+ "path": "plugins/woocommerce/includes/class-wc-cross-sells-assets.php",
+ "before": {
+ "file": "fixtures/G35A/before.php"
+ },
+ "after": {
+ "file": "fixtures/G35A/after.php"
+ },
+ "expect": {
+ "outcome": "finding",
+ "locations": [
+ {
+ "file": "plugins/woocommerce/includes/class-wc-cross-sells-assets.php",
+ "lines": [
+ 4,
+ 4
+ ],
+ "anchor": " wp_set_script_translations( 'wc-cross-sell', 'woocommerce' );"
+ }
+ ]
+ }
+ },
+ {
+ "id": "G35B-v3",
+ "path": "plugins/woocommerce/client/blocks/assets/js/blocks/product-cross-sells/heading.js",
+ "before": {
+ "file": "fixtures/G35B/before.js"
+ },
+ "after": {
+ "file": "fixtures/G35B/after.js"
+ },
+ "expect": {
+ "outcome": "finding",
+ "locations": [
+ {
+ "file": "plugins/woocommerce/client/blocks/assets/js/blocks/product-cross-sells/heading.js",
+ "lines": [
+ 3,
+ 3
+ ],
+ "anchor": "export const getHeading = () => __( 'You may also like\u2026', 'woocommerce-extra' );"
+ }
+ ]
+ }
+ },
+ {
+ "id": "G35C-v3",
+ "path": "plugins/woocommerce/client/blocks/assets/js/blocks/product-cross-sells/heading.js",
+ "before": {
+ "file": "fixtures/G35C/before.js"
+ },
+ "after": {
+ "file": "fixtures/G35C/after.js"
+ },
+ "expect": {
+ "outcome": "quiet"
+ }
+ },
+ {
+ "id": "G35D-v3",
+ "path": "plugins/woocommerce/client/blocks/assets/js/blocks/product-cross-sells/heading.js",
+ "before": {
+ "file": "fixtures/G35D/before.js"
+ },
+ "after": {
+ "file": "fixtures/G35D/after.js"
+ },
+ "expect": {
+ "outcome": "quiet"
+ }
+ }
+ ]
+}
diff --git a/.ai/review/evals/i18n-string-identity/fixtures/G35A/after.php b/.ai/review/evals/i18n-string-identity/fixtures/G35A/after.php
new file mode 100644
index 00000000000..840e30966e6
--- /dev/null
+++ b/.ai/review/evals/i18n-string-identity/fixtures/G35A/after.php
@@ -0,0 +1,7 @@
+<?php
+function wc_cross_sells_assets() {
+ wp_register_script( 'wc-cross-sells', plugins_url( 'assets/cross-sells.js', __FILE__ ), array( 'wp-i18n' ), '1.0', true );
+ wp_set_script_translations( 'wc-cross-sell', 'woocommerce' );
+ wp_enqueue_script( 'wc-cross-sells' );
+}
+add_action( 'wp_enqueue_scripts', 'wc_cross_sells_assets' );
diff --git a/.ai/review/evals/i18n-string-identity/fixtures/G35A/before.php b/.ai/review/evals/i18n-string-identity/fixtures/G35A/before.php
new file mode 100644
index 00000000000..2f968b0003b
--- /dev/null
+++ b/.ai/review/evals/i18n-string-identity/fixtures/G35A/before.php
@@ -0,0 +1,7 @@
+<?php
+function wc_cross_sells_assets() {
+ wp_register_script( 'wc-cross-sells', plugins_url( 'assets/cross-sells.js', __FILE__ ), array( 'wp-i18n' ), '1.0', true );
+ wp_set_script_translations( 'wc-cross-sells', 'woocommerce' );
+ wp_enqueue_script( 'wc-cross-sells' );
+}
+add_action( 'wp_enqueue_scripts', 'wc_cross_sells_assets' );
diff --git a/.ai/review/evals/i18n-string-identity/fixtures/G35B/after.js b/.ai/review/evals/i18n-string-identity/fixtures/G35B/after.js
new file mode 100644
index 00000000000..d25cd55392a
--- /dev/null
+++ b/.ai/review/evals/i18n-string-identity/fixtures/G35B/after.js
@@ -0,0 +1,3 @@
+/* This bundle loads only the woocommerce translation domain. */
+import { __ } from '@wordpress/i18n';
+export const getHeading = () => __( 'You may also like…', 'woocommerce-extra' );
diff --git a/.ai/review/evals/i18n-string-identity/fixtures/G35B/before.js b/.ai/review/evals/i18n-string-identity/fixtures/G35B/before.js
new file mode 100644
index 00000000000..5a45724bd5b
--- /dev/null
+++ b/.ai/review/evals/i18n-string-identity/fixtures/G35B/before.js
@@ -0,0 +1,3 @@
+/* This bundle loads only the woocommerce translation domain. */
+import { __ } from '@wordpress/i18n';
+export const getHeading = () => __( 'You may also like…', 'woocommerce' );
diff --git a/.ai/review/evals/i18n-string-identity/fixtures/G35C/after.js b/.ai/review/evals/i18n-string-identity/fixtures/G35C/after.js
new file mode 100644
index 00000000000..41fe8746116
--- /dev/null
+++ b/.ai/review/evals/i18n-string-identity/fixtures/G35C/after.js
@@ -0,0 +1,4 @@
+/* Empty-state label is new UI in this release. */
+import { __ } from '@wordpress/i18n';
+export const getHeading = () => __( 'You may also like…', 'woocommerce' );
+export const getEmptyLabel = () => __( 'No related products', 'woocommerce' );
diff --git a/.ai/review/evals/i18n-string-identity/fixtures/G35C/before.js b/.ai/review/evals/i18n-string-identity/fixtures/G35C/before.js
new file mode 100644
index 00000000000..1207c6a4242
--- /dev/null
+++ b/.ai/review/evals/i18n-string-identity/fixtures/G35C/before.js
@@ -0,0 +1,3 @@
+/* Empty-state label is new UI in this release. */
+import { __ } from '@wordpress/i18n';
+export const getHeading = () => __( 'You may also like…', 'woocommerce' );
diff --git a/.ai/review/evals/i18n-string-identity/fixtures/G35D/after.js b/.ai/review/evals/i18n-string-identity/fixtures/G35D/after.js
new file mode 100644
index 00000000000..d89b51e6978
--- /dev/null
+++ b/.ai/review/evals/i18n-string-identity/fixtures/G35D/after.js
@@ -0,0 +1,3 @@
+/* Release copy edit: correct the shipped English typo “liek” to “like”. */
+import { __ } from '@wordpress/i18n';
+export const getHeading = () => __( 'You may also like…', 'woocommerce' );
diff --git a/.ai/review/evals/i18n-string-identity/fixtures/G35D/before.js b/.ai/review/evals/i18n-string-identity/fixtures/G35D/before.js
new file mode 100644
index 00000000000..3f015f95084
--- /dev/null
+++ b/.ai/review/evals/i18n-string-identity/fixtures/G35D/before.js
@@ -0,0 +1,3 @@
+/* Release copy edit: correct the shipped English typo “liek” to “like”. */
+import { __ } from '@wordpress/i18n';
+export const getHeading = () => __( 'You may also liek…', 'woocommerce' );
diff --git a/.ai/review/evals/js-public-api-contract/cases.json b/.ai/review/evals/js-public-api-contract/cases.json
new file mode 100644
index 00000000000..b0ad31e30ad
--- /dev/null
+++ b/.ai/review/evals/js-public-api-contract/cases.json
@@ -0,0 +1,77 @@
+{
+ "version": 1,
+ "cases": [
+ {
+ "id": "G30A-v3",
+ "path": "packages/js/currency/src/index.ts",
+ "before": {
+ "file": "fixtures/G30A/before.ts"
+ },
+ "after": {
+ "file": "fixtures/G30A/after.ts"
+ },
+ "expect": {
+ "outcome": "finding",
+ "locations": [
+ {
+ "file": "packages/js/currency/src/index.ts",
+ "lines": [
+ 6,
+ 8
+ ],
+ "anchor": "export * from './utils';"
+ }
+ ]
+ }
+ },
+ {
+ "id": "G30B-v3",
+ "path": "packages/js/currency/src/utils.tsx",
+ "before": {
+ "file": "fixtures/G30B/before.tsx"
+ },
+ "after": {
+ "file": "fixtures/G30B/after.tsx"
+ },
+ "expect": {
+ "outcome": "finding",
+ "locations": [
+ {
+ "file": "packages/js/currency/src/utils.tsx",
+ "lines": [
+ 148,
+ 148
+ ],
+ "anchor": "\t\tif ( ! number ) { return ''; }"
+ }
+ ]
+ }
+ },
+ {
+ "id": "G30C-v3",
+ "path": "packages/js/currency/src/index.ts",
+ "before": {
+ "file": "fixtures/G30C/before.ts"
+ },
+ "after": {
+ "file": "fixtures/G30C/after.ts"
+ },
+ "expect": {
+ "outcome": "quiet"
+ }
+ },
+ {
+ "id": "G30D-v3",
+ "path": "packages/js/currency/src/utils.tsx",
+ "before": {
+ "file": "fixtures/G30D/before.tsx"
+ },
+ "after": {
+ "file": "fixtures/G30D/after.tsx"
+ },
+ "expect": {
+ "outcome": "quiet"
+ }
+ }
+ ]
+}
diff --git a/.ai/review/evals/js-public-api-contract/fixtures/G30A/after.ts b/.ai/review/evals/js-public-api-contract/fixtures/G30A/after.ts
new file mode 100644
index 00000000000..97789dded50
--- /dev/null
+++ b/.ai/review/evals/js-public-api-contract/fixtures/G30A/after.ts
@@ -0,0 +1,9 @@
+/**
+ * Internal dependencies
+ */
+import { CurrencyFactory } from './utils';
+
+
+
+export * from './utils';
+export * from './currency-context';
diff --git a/.ai/review/evals/js-public-api-contract/fixtures/G30A/before.ts b/.ai/review/evals/js-public-api-contract/fixtures/G30A/before.ts
new file mode 100644
index 00000000000..6a8854f4516
--- /dev/null
+++ b/.ai/review/evals/js-public-api-contract/fixtures/G30A/before.ts
@@ -0,0 +1,9 @@
+/**
+ * Internal dependencies
+ */
+import { CurrencyFactory } from './utils';
+
+export default CurrencyFactory;
+
+export * from './utils';
+export * from './currency-context';
diff --git a/.ai/review/evals/js-public-api-contract/fixtures/G30B/after.tsx b/.ai/review/evals/js-public-api-contract/fixtures/G30B/after.tsx
new file mode 100644
index 00000000000..32bfec5a8cb
--- /dev/null
+++ b/.ai/review/evals/js-public-api-contract/fixtures/G30B/after.tsx
@@ -0,0 +1,507 @@
+/**
+ * External dependencies
+ */
+import { createElement } from '@wordpress/element';
+import { decodeEntities } from '@wordpress/html-entities';
+import { sprintf } from '@wordpress/i18n';
+import { NumberConfig, numberFormat, parseNumber } from '@woocommerce/number';
+import deprecated from '@wordpress/deprecated';
+
+/**
+ * @typedef {import('@woocommerce/number').NumberConfig} NumberConfig
+ */
+/**
+ * @typedef {Object} CurrencyProps
+ * @property {string} code Currency ISO code.
+ * @property {string} symbol Symbol, can be multi-character. Should be in plain text, w/o HTML markup. HTML entities will be decoded.
+ * @property {string} symbolPosition Where the symbol should be relative to the amount. One of `'left' | 'right' | 'left_space | 'right_space'`.
+ * @typedef {NumberConfig & CurrencyProps} CurrencyConfig
+ */
+
+export type SymbolPosition = 'left' | 'right' | 'left_space' | 'right_space';
+
+export type CurrencyProps = {
+ code: string;
+ symbol: string;
+ symbolPosition: SymbolPosition;
+ priceFormat?: string;
+};
+
+export type CurrencyConfig = Partial< NumberConfig & CurrencyProps >;
+
+export type Currency = {
+ code: string;
+ symbol: string;
+ symbolPosition: string;
+ decimalSeparator: string;
+ priceFormat: string;
+ thousandSeparator: string;
+ precision: number;
+};
+
+export type CountryInfo = {
+ // https://github.com/woocommerce/woocommerce/blob/trunk/plugins/woocommerce/i18n/locale-info.php#L15-L28
+ currency_code: string;
+ currency_pos: SymbolPosition;
+ thousand_sep: string;
+ decimal_sep: string;
+ num_decimals: number;
+ weight_unit: string;
+ dimension_unit: string;
+ direction: string;
+ default_locale: string;
+ name: string;
+ singular: string;
+ plural: string;
+ short_symbol: string;
+ locales: string[];
+};
+
+/**
+ *
+ * @param {CurrencyConfig} currencySetting
+ * @return {Object} currency object
+ */
+const CurrencyFactoryBase = function ( currencySetting?: CurrencyConfig ) {
+ let currency: Currency;
+
+ function stripTags( str: string ) {
+ // sanitize Polyfill - see https://github.com/WordPress/WordPress/blob/master/wp-includes/js/wp-sanitize.js
+ const strippedStr = str
+ .replace( /<!--[\s\S]*?(-->|$)/g, '' )
+ .replace( /<(script|style)[^>]*>[\s\S]*?(<\/\1>|$)/gi, '' )
+ .replace( /<\/?[a-z][\s\S]*?(>|$)/gi, '' );
+
+ if ( strippedStr !== str ) {
+ return stripTags( strippedStr );
+ }
+
+ return strippedStr;
+ }
+
+ /**
+ * Get the default price format from a currency.
+ *
+ * @param {CurrencyConfig} config Currency configuration.
+ * @return {string} Price format.
+ */
+ function getPriceFormat( config: CurrencyConfig ) {
+ if ( config.priceFormat ) {
+ return stripTags( config.priceFormat.toString() );
+ }
+
+ switch ( config.symbolPosition ) {
+ case 'left':
+ return '%1$s%2$s';
+ case 'right':
+ return '%2$s%1$s';
+ case 'left_space':
+ return '%1$s %2$s';
+ case 'right_space':
+ return '%2$s %1$s';
+ }
+
+ return '%1$s%2$s';
+ }
+
+ function setCurrency( setting?: CurrencyConfig ) {
+ const defaultCurrency = {
+ code: 'USD',
+ symbol: '$',
+ symbolPosition: 'left' as const,
+ thousandSeparator: ',',
+ decimalSeparator: '.',
+ precision: 2,
+ };
+ const config = { ...defaultCurrency, ...setting };
+
+ let precision = config.precision;
+ if ( precision === null ) {
+ // eslint-disable-next-line no-console
+ console.warn( 'Currency precision is null' );
+ // eslint-enable-next-line no-console
+
+ precision = NaN;
+ } else if ( typeof precision === 'string' ) {
+ precision = parseInt( precision, 10 );
+ }
+
+ currency = {
+ code: config.code.toString(),
+ symbol: decodeEntities( config.symbol.toString() ),
+ symbolPosition: config.symbolPosition.toString(),
+ decimalSeparator: config.decimalSeparator.toString(),
+ priceFormat: getPriceFormat( config ),
+ thousandSeparator: config.thousandSeparator.toString(),
+ precision,
+ };
+ }
+
+ /**
+ * Formats money value.
+ *
+ * @param {number|string} number number to format
+ * @param {boolean} [useCode=false] Set to `true` to use the currency code instead of the symbol.
+ * @return {?string} A formatted string.
+ */
+ function formatAmount( number: number | string, useCode = false ) {
+ if ( ! number ) { return ''; }
+ const formattedNumber = numberFormat( currency, number );
+
+ if ( formattedNumber === '' ) {
+ return formattedNumber;
+ }
+
+ const { priceFormat, symbol, code } = currency;
+
+ // @ts-expect-error priceFormat is dynamic, but is expected to include placeholders for the currency and amount.
+
+ return sprintf( priceFormat, useCode ? code : symbol, formattedNumber );
+ }
+
+ /**
+ * Formats money value.
+ *
+ * @deprecated
+ * @param {number|string} number number to format
+ * @return {?string} A formatted string.
+ */
+ function formatCurrency( number: number | string ) {
+ deprecated( 'Currency().formatCurrency', {
+ version: '5.0.0',
+ alternative: 'Currency().formatAmount',
+ plugin: 'WooCommerce',
+ hint: '`formatAmount` accepts the same arguments as formatCurrency',
+ } );
+ return formatAmount( number );
+ }
+
+ /**
+ * Get formatted data for a country from supplied locale and symbol info.
+ *
+ * @param {string} countryCode Country code.
+ * @param {Object} localeInfo Locale info by country code.
+ * @param {Object} currencySymbols Currency symbols by symbol code. HTML entities will be decoded.
+ * @return {CurrencyConfig | {}} Formatted currency data for country.
+ */
+ function getDataForCountry(
+ countryCode: string,
+ localeInfo: Record< string, CountryInfo | undefined > = {},
+ currencySymbols: Record< string, string | undefined > = {}
+ ): CurrencyConfig | Record< string, never > {
+ const countryInfo = localeInfo[ countryCode ];
+ if ( ! countryInfo ) {
+ return {};
+ }
+
+ const symbol = currencySymbols[ countryInfo.currency_code ];
+ if ( ! symbol ) {
+ return {};
+ }
+
+ return {
+ code: countryInfo.currency_code,
+ symbol: decodeEntities( symbol ),
+ symbolPosition: countryInfo.currency_pos,
+ thousandSeparator: countryInfo.thousand_sep,
+ decimalSeparator: countryInfo.decimal_sep,
+ precision: countryInfo.num_decimals,
+ };
+ }
+
+ setCurrency( currencySetting );
+
+ return {
+ getCurrencyConfig: () => {
+ return { ...currency };
+ },
+ getDataForCountry,
+ setCurrency,
+ formatAmount,
+ formatCurrency,
+ getPriceFormat,
+
+ /**
+ * Get the rounded decimal value of a number at the precision used for the current currency.
+ * This is a work-around for fraction-cents, meant to be used like `wc_format_decimal`
+ *
+ * @param {number|string} number A floating point number (or integer), or string that converts to a number
+ * @return {number} The original number rounded to a decimal point
+ */
+ formatDecimal( number: number | string ) {
+ if ( typeof number !== 'number' ) {
+ number = parseFloat( number );
+ }
+ if ( Number.isNaN( number ) ) {
+ return 0;
+ }
+ const { precision } = currency;
+ return (
+ Math.round( number * Math.pow( 10, precision ) ) /
+ Math.pow( 10, precision )
+ );
+ },
+
+ /**
+ * Get the string representation of a floating point number to the precision used by the current currency.
+ * This is different from `formatAmount` by not returning the currency symbol.
+ *
+ * @param {number|string} number A floating point number (or integer), or string that converts to a number
+ * @return {string} The original number rounded to a decimal point
+ */
+ formatDecimalString( number: number | string ) {
+ if ( typeof number !== 'number' ) {
+ number = parseFloat( number );
+ }
+ if ( Number.isNaN( number ) ) {
+ return '';
+ }
+ const { precision } = currency;
+ return number.toFixed( precision );
+ },
+
+ /**
+ * Render a currency for display in a component.
+ *
+ * @param {number|string} number A floating point number (or integer), or string that converts to a number
+ * @return {Node|string} The number formatted as currency and rendered for display.
+ */
+ render( number: number | string ) {
+ if ( typeof number !== 'number' ) {
+ number = parseFloat( number );
+ }
+ if ( number < 0 ) {
+ return (
+ <span className="is-negative">
+ { formatAmount( number ) }
+ </span>
+ );
+ }
+ return formatAmount( number );
+ },
+ };
+};
+
+export const CurrencyFactory = CurrencyFactoryBase;
+
+/**
+ * Returns currency data by country/region. Contains code, symbol, position, thousands separator, decimal separator, and precision.
+ *
+ * Dev Note: When adding new currencies below, the exchange rate array should also be updated in WooCommerce Admin's `business-details.js`.
+ *
+ * @deprecated
+ * @return {Object} Currency data.
+ */
+export function getCurrencyData() {
+ deprecated( 'getCurrencyData', {
+ version: '3.1.0',
+ alternative: 'CurrencyFactory.getDataForCountry',
+ plugin: 'WooCommerce Admin',
+ hint: 'Pass in the country, locale data, and symbol info to use getDataForCountry',
+ } );
+
+ // See https://github.com/woocommerce/woocommerce-admin/issues/3101.
+ return {
+ US: {
+ code: 'USD',
+ symbol: '$',
+ symbolPosition: 'left',
+ thousandSeparator: ',',
+ decimalSeparator: '.',
+ precision: 2,
+ },
+ EU: {
+ code: 'EUR',
+ symbol: '€',
+ symbolPosition: 'left',
+ thousandSeparator: '.',
+ decimalSeparator: ',',
+ precision: 2,
+ },
+ IN: {
+ code: 'INR',
+ symbol: '₹',
+ symbolPosition: 'left',
+ thousandSeparator: ',',
+ decimalSeparator: '.',
+ precision: 2,
+ },
+ GB: {
+ code: 'GBP',
+ symbol: '£',
+ symbolPosition: 'left',
+ thousandSeparator: ',',
+ decimalSeparator: '.',
+ precision: 2,
+ },
+ BR: {
+ code: 'BRL',
+ symbol: 'R$',
+ symbolPosition: 'left',
+ thousandSeparator: '.',
+ decimalSeparator: ',',
+ precision: 2,
+ },
+ VN: {
+ code: 'VND',
+ symbol: '₫',
+ symbolPosition: 'right',
+ thousandSeparator: '.',
+ decimalSeparator: ',',
+ precision: 1,
+ },
+ ID: {
+ code: 'IDR',
+ symbol: 'Rp',
+ symbolPosition: 'left',
+ thousandSeparator: '.',
+ decimalSeparator: ',',
+ precision: 0,
+ },
+ BD: {
+ code: 'BDT',
+ symbol: '৳',
+ symbolPosition: 'left',
+ thousandSeparator: ',',
+ decimalSeparator: '.',
+ precision: 0,
+ },
+ PK: {
+ code: 'PKR',
+ symbol: '₨',
+ symbolPosition: 'left',
+ thousandSeparator: ',',
+ decimalSeparator: '.',
+ precision: 2,
+ },
+ RU: {
+ code: 'RUB',
+ symbol: '₽',
+ symbolPosition: 'right',
+ thousandSeparator: ' ',
+ decimalSeparator: ',',
+ precision: 2,
+ },
+ TR: {
+ code: 'TRY',
+ symbol: '₺',
+ symbolPosition: 'left',
+ thousandSeparator: '.',
+ decimalSeparator: ',',
+ precision: 2,
+ },
+ MX: {
+ code: 'MXN',
+ symbol: '$',
+ symbolPosition: 'left',
+ thousandSeparator: ',',
+ decimalSeparator: '.',
+ precision: 2,
+ },
+ CA: {
+ code: 'CAD',
+ symbol: '$',
+ symbolPosition: 'left',
+ thousandSeparator: ',',
+ decimalSeparator: '.',
+ precision: 2,
+ },
+ };
+}
+
+/**
+ * Escape special characters for user input in regex.
+ *
+ * @param {string} string
+ * @return {string} string
+ */
+const escapeRegExp = ( string: string ) => {
+ return string.replace( /[.*+?^${}()|[\]\\]/g, '\\$&' );
+};
+
+/**
+ * Localises a number or numeric string for display, adding the appropriate thousands and decimal separators.
+ * For compatibility reasons, it returns the input if it's not a number or a string of numbers.
+ */
+export const localiseMonetaryValue = (
+ config: NumberConfig,
+ number: number | string | unknown
+) => {
+ if ( typeof number === 'number' ) {
+ return numberFormat( config, number );
+ }
+
+ if ( typeof number === 'string' ) {
+ const dot = escapeRegExp( config.decimalSeparator );
+ const comma = escapeRegExp( config.thousandSeparator );
+
+ // Regex to match strictly numbers with arbitrary thousands and decimal separators.
+ // Example: /^\s*(\d+|\d{1,3}(?:,\d{3})*)(?:\.\d+)?\s*$/ for default config.
+ const regex = new RegExp(
+ `^\\s*(\\d+|\\d{1,3}(?:${ comma }\\d{3})*)(?:${ dot }\\d+)?\\s*$`
+ );
+
+ return number.replace( regex, ( n ) => {
+ const parsed = parseNumber( config, n );
+ return numberFormat( config, parsed );
+ } );
+ }
+
+ return number;
+};
+
+export const unformatLocalisedMonetaryValue = (
+ config: NumberConfig,
+ inputNumber: number | string | unknown
+) => {
+ if ( ! inputNumber ) {
+ throw new Error( 'Input value is undefined' );
+ }
+
+ if ( Number.isFinite( inputNumber ) ) {
+ return inputNumber;
+ }
+
+ if ( typeof inputNumber !== 'string' ) {
+ throw new Error( 'Input value is not a number or a numeric string' );
+ }
+
+ // Brackets signal a formula. Avoid unformatting these values.
+ if ( inputNumber.includes( '[' ) && inputNumber.includes( ']' ) ) {
+ throw new Error( 'Input value contains formula' );
+ }
+
+ // Check if the string contains any non-numeric characters except allowed separators and whitespace
+ const allowedChars = new RegExp(
+ `^\\s*[0-9${ escapeRegExp( config.thousandSeparator ) }${ escapeRegExp(
+ config.decimalSeparator
+ ) }]+\\s*$`
+ );
+
+ if ( ! allowedChars.test( inputNumber ) ) {
+ throw new Error(
+ 'Input value contains non-numeric characters and is not a formula'
+ );
+ }
+
+ if (
+ // check that there is only 1 decimal separator and it is not to the left of
+ // the thousands separator if there is a thousands separator in the value
+ inputNumber.split( config.decimalSeparator ).length > 2 ||
+ ( inputNumber.includes( config.thousandSeparator ) &&
+ inputNumber.includes( config.decimalSeparator ) &&
+ inputNumber.indexOf( config.decimalSeparator ) <=
+ inputNumber.indexOf( config.thousandSeparator ) )
+ ) {
+ throw new Error( 'Invalid decimal separator' );
+ }
+
+ const unformattedValue = inputNumber
+ .replace(
+ new RegExp( escapeRegExp( config.thousandSeparator ), 'g' ),
+ ''
+ )
+ .replace( config.decimalSeparator, '.' );
+
+ return Number( unformattedValue );
+};
diff --git a/.ai/review/evals/js-public-api-contract/fixtures/G30B/before.tsx b/.ai/review/evals/js-public-api-contract/fixtures/G30B/before.tsx
new file mode 100644
index 00000000000..a2db8c7b617
--- /dev/null
+++ b/.ai/review/evals/js-public-api-contract/fixtures/G30B/before.tsx
@@ -0,0 +1,506 @@
+/**
+ * External dependencies
+ */
+import { createElement } from '@wordpress/element';
+import { decodeEntities } from '@wordpress/html-entities';
+import { sprintf } from '@wordpress/i18n';
+import { NumberConfig, numberFormat, parseNumber } from '@woocommerce/number';
+import deprecated from '@wordpress/deprecated';
+
+/**
+ * @typedef {import('@woocommerce/number').NumberConfig} NumberConfig
+ */
+/**
+ * @typedef {Object} CurrencyProps
+ * @property {string} code Currency ISO code.
+ * @property {string} symbol Symbol, can be multi-character. Should be in plain text, w/o HTML markup. HTML entities will be decoded.
+ * @property {string} symbolPosition Where the symbol should be relative to the amount. One of `'left' | 'right' | 'left_space | 'right_space'`.
+ * @typedef {NumberConfig & CurrencyProps} CurrencyConfig
+ */
+
+export type SymbolPosition = 'left' | 'right' | 'left_space' | 'right_space';
+
+export type CurrencyProps = {
+ code: string;
+ symbol: string;
+ symbolPosition: SymbolPosition;
+ priceFormat?: string;
+};
+
+export type CurrencyConfig = Partial< NumberConfig & CurrencyProps >;
+
+export type Currency = {
+ code: string;
+ symbol: string;
+ symbolPosition: string;
+ decimalSeparator: string;
+ priceFormat: string;
+ thousandSeparator: string;
+ precision: number;
+};
+
+export type CountryInfo = {
+ // https://github.com/woocommerce/woocommerce/blob/trunk/plugins/woocommerce/i18n/locale-info.php#L15-L28
+ currency_code: string;
+ currency_pos: SymbolPosition;
+ thousand_sep: string;
+ decimal_sep: string;
+ num_decimals: number;
+ weight_unit: string;
+ dimension_unit: string;
+ direction: string;
+ default_locale: string;
+ name: string;
+ singular: string;
+ plural: string;
+ short_symbol: string;
+ locales: string[];
+};
+
+/**
+ *
+ * @param {CurrencyConfig} currencySetting
+ * @return {Object} currency object
+ */
+const CurrencyFactoryBase = function ( currencySetting?: CurrencyConfig ) {
+ let currency: Currency;
+
+ function stripTags( str: string ) {
+ // sanitize Polyfill - see https://github.com/WordPress/WordPress/blob/master/wp-includes/js/wp-sanitize.js
+ const strippedStr = str
+ .replace( /<!--[\s\S]*?(-->|$)/g, '' )
+ .replace( /<(script|style)[^>]*>[\s\S]*?(<\/\1>|$)/gi, '' )
+ .replace( /<\/?[a-z][\s\S]*?(>|$)/gi, '' );
+
+ if ( strippedStr !== str ) {
+ return stripTags( strippedStr );
+ }
+
+ return strippedStr;
+ }
+
+ /**
+ * Get the default price format from a currency.
+ *
+ * @param {CurrencyConfig} config Currency configuration.
+ * @return {string} Price format.
+ */
+ function getPriceFormat( config: CurrencyConfig ) {
+ if ( config.priceFormat ) {
+ return stripTags( config.priceFormat.toString() );
+ }
+
+ switch ( config.symbolPosition ) {
+ case 'left':
+ return '%1$s%2$s';
+ case 'right':
+ return '%2$s%1$s';
+ case 'left_space':
+ return '%1$s %2$s';
+ case 'right_space':
+ return '%2$s %1$s';
+ }
+
+ return '%1$s%2$s';
+ }
+
+ function setCurrency( setting?: CurrencyConfig ) {
+ const defaultCurrency = {
+ code: 'USD',
+ symbol: '$',
+ symbolPosition: 'left' as const,
+ thousandSeparator: ',',
+ decimalSeparator: '.',
+ precision: 2,
+ };
+ const config = { ...defaultCurrency, ...setting };
+
+ let precision = config.precision;
+ if ( precision === null ) {
+ // eslint-disable-next-line no-console
+ console.warn( 'Currency precision is null' );
+ // eslint-enable-next-line no-console
+
+ precision = NaN;
+ } else if ( typeof precision === 'string' ) {
+ precision = parseInt( precision, 10 );
+ }
+
+ currency = {
+ code: config.code.toString(),
+ symbol: decodeEntities( config.symbol.toString() ),
+ symbolPosition: config.symbolPosition.toString(),
+ decimalSeparator: config.decimalSeparator.toString(),
+ priceFormat: getPriceFormat( config ),
+ thousandSeparator: config.thousandSeparator.toString(),
+ precision,
+ };
+ }
+
+ /**
+ * Formats money value.
+ *
+ * @param {number|string} number number to format
+ * @param {boolean} [useCode=false] Set to `true` to use the currency code instead of the symbol.
+ * @return {?string} A formatted string.
+ */
+ function formatAmount( number: number | string, useCode = false ) {
+ const formattedNumber = numberFormat( currency, number );
+
+ if ( formattedNumber === '' ) {
+ return formattedNumber;
+ }
+
+ const { priceFormat, symbol, code } = currency;
+
+ // @ts-expect-error priceFormat is dynamic, but is expected to include placeholders for the currency and amount.
+
+ return sprintf( priceFormat, useCode ? code : symbol, formattedNumber );
+ }
+
+ /**
+ * Formats money value.
+ *
+ * @deprecated
+ * @param {number|string} number number to format
+ * @return {?string} A formatted string.
+ */
+ function formatCurrency( number: number | string ) {
+ deprecated( 'Currency().formatCurrency', {
+ version: '5.0.0',
+ alternative: 'Currency().formatAmount',
+ plugin: 'WooCommerce',
+ hint: '`formatAmount` accepts the same arguments as formatCurrency',
+ } );
+ return formatAmount( number );
+ }
+
+ /**
+ * Get formatted data for a country from supplied locale and symbol info.
+ *
+ * @param {string} countryCode Country code.
+ * @param {Object} localeInfo Locale info by country code.
+ * @param {Object} currencySymbols Currency symbols by symbol code. HTML entities will be decoded.
+ * @return {CurrencyConfig | {}} Formatted currency data for country.
+ */
+ function getDataForCountry(
+ countryCode: string,
+ localeInfo: Record< string, CountryInfo | undefined > = {},
+ currencySymbols: Record< string, string | undefined > = {}
+ ): CurrencyConfig | Record< string, never > {
+ const countryInfo = localeInfo[ countryCode ];
+ if ( ! countryInfo ) {
+ return {};
+ }
+
+ const symbol = currencySymbols[ countryInfo.currency_code ];
+ if ( ! symbol ) {
+ return {};
+ }
+
+ return {
+ code: countryInfo.currency_code,
+ symbol: decodeEntities( symbol ),
+ symbolPosition: countryInfo.currency_pos,
+ thousandSeparator: countryInfo.thousand_sep,
+ decimalSeparator: countryInfo.decimal_sep,
+ precision: countryInfo.num_decimals,
+ };
+ }
+
+ setCurrency( currencySetting );
+
+ return {
+ getCurrencyConfig: () => {
+ return { ...currency };
+ },
+ getDataForCountry,
+ setCurrency,
+ formatAmount,
+ formatCurrency,
+ getPriceFormat,
+
+ /**
+ * Get the rounded decimal value of a number at the precision used for the current currency.
+ * This is a work-around for fraction-cents, meant to be used like `wc_format_decimal`
+ *
+ * @param {number|string} number A floating point number (or integer), or string that converts to a number
+ * @return {number} The original number rounded to a decimal point
+ */
+ formatDecimal( number: number | string ) {
+ if ( typeof number !== 'number' ) {
+ number = parseFloat( number );
+ }
+ if ( Number.isNaN( number ) ) {
+ return 0;
+ }
+ const { precision } = currency;
+ return (
+ Math.round( number * Math.pow( 10, precision ) ) /
+ Math.pow( 10, precision )
+ );
+ },
+
+ /**
+ * Get the string representation of a floating point number to the precision used by the current currency.
+ * This is different from `formatAmount` by not returning the currency symbol.
+ *
+ * @param {number|string} number A floating point number (or integer), or string that converts to a number
+ * @return {string} The original number rounded to a decimal point
+ */
+ formatDecimalString( number: number | string ) {
+ if ( typeof number !== 'number' ) {
+ number = parseFloat( number );
+ }
+ if ( Number.isNaN( number ) ) {
+ return '';
+ }
+ const { precision } = currency;
+ return number.toFixed( precision );
+ },
+
+ /**
+ * Render a currency for display in a component.
+ *
+ * @param {number|string} number A floating point number (or integer), or string that converts to a number
+ * @return {Node|string} The number formatted as currency and rendered for display.
+ */
+ render( number: number | string ) {
+ if ( typeof number !== 'number' ) {
+ number = parseFloat( number );
+ }
+ if ( number < 0 ) {
+ return (
+ <span className="is-negative">
+ { formatAmount( number ) }
+ </span>
+ );
+ }
+ return formatAmount( number );
+ },
+ };
+};
+
+export const CurrencyFactory = CurrencyFactoryBase;
+
+/**
+ * Returns currency data by country/region. Contains code, symbol, position, thousands separator, decimal separator, and precision.
+ *
+ * Dev Note: When adding new currencies below, the exchange rate array should also be updated in WooCommerce Admin's `business-details.js`.
+ *
+ * @deprecated
+ * @return {Object} Currency data.
+ */
+export function getCurrencyData() {
+ deprecated( 'getCurrencyData', {
+ version: '3.1.0',
+ alternative: 'CurrencyFactory.getDataForCountry',
+ plugin: 'WooCommerce Admin',
+ hint: 'Pass in the country, locale data, and symbol info to use getDataForCountry',
+ } );
+
+ // See https://github.com/woocommerce/woocommerce-admin/issues/3101.
+ return {
+ US: {
+ code: 'USD',
+ symbol: '$',
+ symbolPosition: 'left',
+ thousandSeparator: ',',
+ decimalSeparator: '.',
+ precision: 2,
+ },
+ EU: {
+ code: 'EUR',
+ symbol: '€',
+ symbolPosition: 'left',
+ thousandSeparator: '.',
+ decimalSeparator: ',',
+ precision: 2,
+ },
+ IN: {
+ code: 'INR',
+ symbol: '₹',
+ symbolPosition: 'left',
+ thousandSeparator: ',',
+ decimalSeparator: '.',
+ precision: 2,
+ },
+ GB: {
+ code: 'GBP',
+ symbol: '£',
+ symbolPosition: 'left',
+ thousandSeparator: ',',
+ decimalSeparator: '.',
+ precision: 2,
+ },
+ BR: {
+ code: 'BRL',
+ symbol: 'R$',
+ symbolPosition: 'left',
+ thousandSeparator: '.',
+ decimalSeparator: ',',
+ precision: 2,
+ },
+ VN: {
+ code: 'VND',
+ symbol: '₫',
+ symbolPosition: 'right',
+ thousandSeparator: '.',
+ decimalSeparator: ',',
+ precision: 1,
+ },
+ ID: {
+ code: 'IDR',
+ symbol: 'Rp',
+ symbolPosition: 'left',
+ thousandSeparator: '.',
+ decimalSeparator: ',',
+ precision: 0,
+ },
+ BD: {
+ code: 'BDT',
+ symbol: '৳',
+ symbolPosition: 'left',
+ thousandSeparator: ',',
+ decimalSeparator: '.',
+ precision: 0,
+ },
+ PK: {
+ code: 'PKR',
+ symbol: '₨',
+ symbolPosition: 'left',
+ thousandSeparator: ',',
+ decimalSeparator: '.',
+ precision: 2,
+ },
+ RU: {
+ code: 'RUB',
+ symbol: '₽',
+ symbolPosition: 'right',
+ thousandSeparator: ' ',
+ decimalSeparator: ',',
+ precision: 2,
+ },
+ TR: {
+ code: 'TRY',
+ symbol: '₺',
+ symbolPosition: 'left',
+ thousandSeparator: '.',
+ decimalSeparator: ',',
+ precision: 2,
+ },
+ MX: {
+ code: 'MXN',
+ symbol: '$',
+ symbolPosition: 'left',
+ thousandSeparator: ',',
+ decimalSeparator: '.',
+ precision: 2,
+ },
+ CA: {
+ code: 'CAD',
+ symbol: '$',
+ symbolPosition: 'left',
+ thousandSeparator: ',',
+ decimalSeparator: '.',
+ precision: 2,
+ },
+ };
+}
+
+/**
+ * Escape special characters for user input in regex.
+ *
+ * @param {string} string
+ * @return {string} string
+ */
+const escapeRegExp = ( string: string ) => {
+ return string.replace( /[.*+?^${}()|[\]\\]/g, '\\$&' );
+};
+
+/**
+ * Localises a number or numeric string for display, adding the appropriate thousands and decimal separators.
+ * For compatibility reasons, it returns the input if it's not a number or a string of numbers.
+ */
+export const localiseMonetaryValue = (
+ config: NumberConfig,
+ number: number | string | unknown
+) => {
+ if ( typeof number === 'number' ) {
+ return numberFormat( config, number );
+ }
+
+ if ( typeof number === 'string' ) {
+ const dot = escapeRegExp( config.decimalSeparator );
+ const comma = escapeRegExp( config.thousandSeparator );
+
+ // Regex to match strictly numbers with arbitrary thousands and decimal separators.
+ // Example: /^\s*(\d+|\d{1,3}(?:,\d{3})*)(?:\.\d+)?\s*$/ for default config.
+ const regex = new RegExp(
+ `^\\s*(\\d+|\\d{1,3}(?:${ comma }\\d{3})*)(?:${ dot }\\d+)?\\s*$`
+ );
+
+ return number.replace( regex, ( n ) => {
+ const parsed = parseNumber( config, n );
+ return numberFormat( config, parsed );
+ } );
+ }
+
+ return number;
+};
+
+export const unformatLocalisedMonetaryValue = (
+ config: NumberConfig,
+ inputNumber: number | string | unknown
+) => {
+ if ( ! inputNumber ) {
+ throw new Error( 'Input value is undefined' );
+ }
+
+ if ( Number.isFinite( inputNumber ) ) {
+ return inputNumber;
+ }
+
+ if ( typeof inputNumber !== 'string' ) {
+ throw new Error( 'Input value is not a number or a numeric string' );
+ }
+
+ // Brackets signal a formula. Avoid unformatting these values.
+ if ( inputNumber.includes( '[' ) && inputNumber.includes( ']' ) ) {
+ throw new Error( 'Input value contains formula' );
+ }
+
+ // Check if the string contains any non-numeric characters except allowed separators and whitespace
+ const allowedChars = new RegExp(
+ `^\\s*[0-9${ escapeRegExp( config.thousandSeparator ) }${ escapeRegExp(
+ config.decimalSeparator
+ ) }]+\\s*$`
+ );
+
+ if ( ! allowedChars.test( inputNumber ) ) {
+ throw new Error(
+ 'Input value contains non-numeric characters and is not a formula'
+ );
+ }
+
+ if (
+ // check that there is only 1 decimal separator and it is not to the left of
+ // the thousands separator if there is a thousands separator in the value
+ inputNumber.split( config.decimalSeparator ).length > 2 ||
+ ( inputNumber.includes( config.thousandSeparator ) &&
+ inputNumber.includes( config.decimalSeparator ) &&
+ inputNumber.indexOf( config.decimalSeparator ) <=
+ inputNumber.indexOf( config.thousandSeparator ) )
+ ) {
+ throw new Error( 'Invalid decimal separator' );
+ }
+
+ const unformattedValue = inputNumber
+ .replace(
+ new RegExp( escapeRegExp( config.thousandSeparator ), 'g' ),
+ ''
+ )
+ .replace( config.decimalSeparator, '.' );
+
+ return Number( unformattedValue );
+};
diff --git a/.ai/review/evals/js-public-api-contract/fixtures/G30C/after.ts b/.ai/review/evals/js-public-api-contract/fixtures/G30C/after.ts
new file mode 100644
index 00000000000..1e752304c0e
--- /dev/null
+++ b/.ai/review/evals/js-public-api-contract/fixtures/G30C/after.ts
@@ -0,0 +1,11 @@
+/**
+ * Internal dependencies
+ */
+import { CurrencyFactory } from './utils';
+
+export default CurrencyFactory;
+
+export * from './utils';
+export * from './currency-context';
+
+export { CurrencyFactory as createCurrency } from './utils';
diff --git a/.ai/review/evals/js-public-api-contract/fixtures/G30C/before.ts b/.ai/review/evals/js-public-api-contract/fixtures/G30C/before.ts
new file mode 100644
index 00000000000..6a8854f4516
--- /dev/null
+++ b/.ai/review/evals/js-public-api-contract/fixtures/G30C/before.ts
@@ -0,0 +1,9 @@
+/**
+ * Internal dependencies
+ */
+import { CurrencyFactory } from './utils';
+
+export default CurrencyFactory;
+
+export * from './utils';
+export * from './currency-context';
diff --git a/.ai/review/evals/js-public-api-contract/fixtures/G30D/after.tsx b/.ai/review/evals/js-public-api-contract/fixtures/G30D/after.tsx
new file mode 100644
index 00000000000..d149bebe4ca
--- /dev/null
+++ b/.ai/review/evals/js-public-api-contract/fixtures/G30D/after.tsx
@@ -0,0 +1,506 @@
+/**
+ * External dependencies
+ */
+import { createElement } from '@wordpress/element';
+import { decodeEntities } from '@wordpress/html-entities';
+import { sprintf } from '@wordpress/i18n';
+import { NumberConfig, numberFormat, parseNumber } from '@woocommerce/number';
+import deprecated from '@wordpress/deprecated';
+
+/**
+ * @typedef {import('@woocommerce/number').NumberConfig} NumberConfig
+ */
+/**
+ * @typedef {Object} CurrencyProps
+ * @property {string} code Currency ISO code.
+ * @property {string} symbol Symbol, can be multi-character. Should be in plain text, w/o HTML markup. HTML entities will be decoded.
+ * @property {string} symbolPosition Where the symbol should be relative to the amount. One of `'left' | 'right' | 'left_space | 'right_space'`.
+ * @typedef {NumberConfig & CurrencyProps} CurrencyConfig
+ */
+
+export type SymbolPosition = 'left' | 'right' | 'left_space' | 'right_space';
+
+export type CurrencyProps = {
+ code: string;
+ symbol: string;
+ symbolPosition: SymbolPosition;
+ priceFormat?: string;
+};
+
+export type CurrencyConfig = Partial< NumberConfig & CurrencyProps >;
+
+export type Currency = {
+ code: string;
+ symbol: string;
+ symbolPosition: string;
+ decimalSeparator: string;
+ priceFormat: string;
+ thousandSeparator: string;
+ precision: number;
+};
+
+export type CountryInfo = {
+ // https://github.com/woocommerce/woocommerce/blob/trunk/plugins/woocommerce/i18n/locale-info.php#L15-L28
+ currency_code: string;
+ currency_pos: SymbolPosition;
+ thousand_sep: string;
+ decimal_sep: string;
+ num_decimals: number;
+ weight_unit: string;
+ dimension_unit: string;
+ direction: string;
+ default_locale: string;
+ name: string;
+ singular: string;
+ plural: string;
+ short_symbol: string;
+ locales: string[];
+};
+
+/**
+ *
+ * @param {CurrencyConfig} currencySetting
+ * @return {Object} currency object
+ */
+const CurrencyFactoryBase = function ( currencySetting?: CurrencyConfig ) {
+ let currency: Currency;
+
+ function stripCurrencyTags( str: string ) {
+ // sanitize Polyfill - see https://github.com/WordPress/WordPress/blob/master/wp-includes/js/wp-sanitize.js
+ const strippedStr = str
+ .replace( /<!--[\s\S]*?(-->|$)/g, '' )
+ .replace( /<(script|style)[^>]*>[\s\S]*?(<\/\1>|$)/gi, '' )
+ .replace( /<\/?[a-z][\s\S]*?(>|$)/gi, '' );
+
+ if ( strippedStr !== str ) {
+ return stripCurrencyTags( strippedStr );
+ }
+
+ return strippedStr;
+ }
+
+ /**
+ * Get the default price format from a currency.
+ *
+ * @param {CurrencyConfig} config Currency configuration.
+ * @return {string} Price format.
+ */
+ function getPriceFormat( config: CurrencyConfig ) {
+ if ( config.priceFormat ) {
+ return stripCurrencyTags( config.priceFormat.toString() );
+ }
+
+ switch ( config.symbolPosition ) {
+ case 'left':
+ return '%1$s%2$s';
+ case 'right':
+ return '%2$s%1$s';
+ case 'left_space':
+ return '%1$s %2$s';
+ case 'right_space':
+ return '%2$s %1$s';
+ }
+
+ return '%1$s%2$s';
+ }
+
+ function setCurrency( setting?: CurrencyConfig ) {
+ const defaultCurrency = {
+ code: 'USD',
+ symbol: '$',
+ symbolPosition: 'left' as const,
+ thousandSeparator: ',',
+ decimalSeparator: '.',
+ precision: 2,
+ };
+ const config = { ...defaultCurrency, ...setting };
+
+ let precision = config.precision;
+ if ( precision === null ) {
+ // eslint-disable-next-line no-console
+ console.warn( 'Currency precision is null' );
+ // eslint-enable-next-line no-console
+
+ precision = NaN;
+ } else if ( typeof precision === 'string' ) {
+ precision = parseInt( precision, 10 );
+ }
+
+ currency = {
+ code: config.code.toString(),
+ symbol: decodeEntities( config.symbol.toString() ),
+ symbolPosition: config.symbolPosition.toString(),
+ decimalSeparator: config.decimalSeparator.toString(),
+ priceFormat: getPriceFormat( config ),
+ thousandSeparator: config.thousandSeparator.toString(),
+ precision,
+ };
+ }
+
+ /**
+ * Formats money value.
+ *
+ * @param {number|string} number number to format
+ * @param {boolean} [useCode=false] Set to `true` to use the currency code instead of the symbol.
+ * @return {?string} A formatted string.
+ */
+ function formatAmount( number: number | string, useCode = false ) {
+ const formattedNumber = numberFormat( currency, number );
+
+ if ( formattedNumber === '' ) {
+ return formattedNumber;
+ }
+
+ const { priceFormat, symbol, code } = currency;
+
+ // @ts-expect-error priceFormat is dynamic, but is expected to include placeholders for the currency and amount.
+
+ return sprintf( priceFormat, useCode ? code : symbol, formattedNumber );
+ }
+
+ /**
+ * Formats money value.
+ *
+ * @deprecated
+ * @param {number|string} number number to format
+ * @return {?string} A formatted string.
+ */
+ function formatCurrency( number: number | string ) {
+ deprecated( 'Currency().formatCurrency', {
+ version: '5.0.0',
+ alternative: 'Currency().formatAmount',
+ plugin: 'WooCommerce',
+ hint: '`formatAmount` accepts the same arguments as formatCurrency',
+ } );
+ return formatAmount( number );
+ }
+
+ /**
+ * Get formatted data for a country from supplied locale and symbol info.
+ *
+ * @param {string} countryCode Country code.
+ * @param {Object} localeInfo Locale info by country code.
+ * @param {Object} currencySymbols Currency symbols by symbol code. HTML entities will be decoded.
+ * @return {CurrencyConfig | {}} Formatted currency data for country.
+ */
+ function getDataForCountry(
+ countryCode: string,
+ localeInfo: Record< string, CountryInfo | undefined > = {},
+ currencySymbols: Record< string, string | undefined > = {}
+ ): CurrencyConfig | Record< string, never > {
+ const countryInfo = localeInfo[ countryCode ];
+ if ( ! countryInfo ) {
+ return {};
+ }
+
+ const symbol = currencySymbols[ countryInfo.currency_code ];
+ if ( ! symbol ) {
+ return {};
+ }
+
+ return {
+ code: countryInfo.currency_code,
+ symbol: decodeEntities( symbol ),
+ symbolPosition: countryInfo.currency_pos,
+ thousandSeparator: countryInfo.thousand_sep,
+ decimalSeparator: countryInfo.decimal_sep,
+ precision: countryInfo.num_decimals,
+ };
+ }
+
+ setCurrency( currencySetting );
+
+ return {
+ getCurrencyConfig: () => {
+ return { ...currency };
+ },
+ getDataForCountry,
+ setCurrency,
+ formatAmount,
+ formatCurrency,
+ getPriceFormat,
+
+ /**
+ * Get the rounded decimal value of a number at the precision used for the current currency.
+ * This is a work-around for fraction-cents, meant to be used like `wc_format_decimal`
+ *
+ * @param {number|string} number A floating point number (or integer), or string that converts to a number
+ * @return {number} The original number rounded to a decimal point
+ */
+ formatDecimal( number: number | string ) {
+ if ( typeof number !== 'number' ) {
+ number = parseFloat( number );
+ }
+ if ( Number.isNaN( number ) ) {
+ return 0;
+ }
+ const { precision } = currency;
+ return (
+ Math.round( number * Math.pow( 10, precision ) ) /
+ Math.pow( 10, precision )
+ );
+ },
+
+ /**
+ * Get the string representation of a floating point number to the precision used by the current currency.
+ * This is different from `formatAmount` by not returning the currency symbol.
+ *
+ * @param {number|string} number A floating point number (or integer), or string that converts to a number
+ * @return {string} The original number rounded to a decimal point
+ */
+ formatDecimalString( number: number | string ) {
+ if ( typeof number !== 'number' ) {
+ number = parseFloat( number );
+ }
+ if ( Number.isNaN( number ) ) {
+ return '';
+ }
+ const { precision } = currency;
+ return number.toFixed( precision );
+ },
+
+ /**
+ * Render a currency for display in a component.
+ *
+ * @param {number|string} number A floating point number (or integer), or string that converts to a number
+ * @return {Node|string} The number formatted as currency and rendered for display.
+ */
+ render( number: number | string ) {
+ if ( typeof number !== 'number' ) {
+ number = parseFloat( number );
+ }
+ if ( number < 0 ) {
+ return (
+ <span className="is-negative">
+ { formatAmount( number ) }
+ </span>
+ );
+ }
+ return formatAmount( number );
+ },
+ };
+};
+
+export const CurrencyFactory = CurrencyFactoryBase;
+
+/**
+ * Returns currency data by country/region. Contains code, symbol, position, thousands separator, decimal separator, and precision.
+ *
+ * Dev Note: When adding new currencies below, the exchange rate array should also be updated in WooCommerce Admin's `business-details.js`.
+ *
+ * @deprecated
+ * @return {Object} Currency data.
+ */
+export function getCurrencyData() {
+ deprecated( 'getCurrencyData', {
+ version: '3.1.0',
+ alternative: 'CurrencyFactory.getDataForCountry',
+ plugin: 'WooCommerce Admin',
+ hint: 'Pass in the country, locale data, and symbol info to use getDataForCountry',
+ } );
+
+ // See https://github.com/woocommerce/woocommerce-admin/issues/3101.
+ return {
+ US: {
+ code: 'USD',
+ symbol: '$',
+ symbolPosition: 'left',
+ thousandSeparator: ',',
+ decimalSeparator: '.',
+ precision: 2,
+ },
+ EU: {
+ code: 'EUR',
+ symbol: '€',
+ symbolPosition: 'left',
+ thousandSeparator: '.',
+ decimalSeparator: ',',
+ precision: 2,
+ },
+ IN: {
+ code: 'INR',
+ symbol: '₹',
+ symbolPosition: 'left',
+ thousandSeparator: ',',
+ decimalSeparator: '.',
+ precision: 2,
+ },
+ GB: {
+ code: 'GBP',
+ symbol: '£',
+ symbolPosition: 'left',
+ thousandSeparator: ',',
+ decimalSeparator: '.',
+ precision: 2,
+ },
+ BR: {
+ code: 'BRL',
+ symbol: 'R$',
+ symbolPosition: 'left',
+ thousandSeparator: '.',
+ decimalSeparator: ',',
+ precision: 2,
+ },
+ VN: {
+ code: 'VND',
+ symbol: '₫',
+ symbolPosition: 'right',
+ thousandSeparator: '.',
+ decimalSeparator: ',',
+ precision: 1,
+ },
+ ID: {
+ code: 'IDR',
+ symbol: 'Rp',
+ symbolPosition: 'left',
+ thousandSeparator: '.',
+ decimalSeparator: ',',
+ precision: 0,
+ },
+ BD: {
+ code: 'BDT',
+ symbol: '৳',
+ symbolPosition: 'left',
+ thousandSeparator: ',',
+ decimalSeparator: '.',
+ precision: 0,
+ },
+ PK: {
+ code: 'PKR',
+ symbol: '₨',
+ symbolPosition: 'left',
+ thousandSeparator: ',',
+ decimalSeparator: '.',
+ precision: 2,
+ },
+ RU: {
+ code: 'RUB',
+ symbol: '₽',
+ symbolPosition: 'right',
+ thousandSeparator: ' ',
+ decimalSeparator: ',',
+ precision: 2,
+ },
+ TR: {
+ code: 'TRY',
+ symbol: '₺',
+ symbolPosition: 'left',
+ thousandSeparator: '.',
+ decimalSeparator: ',',
+ precision: 2,
+ },
+ MX: {
+ code: 'MXN',
+ symbol: '$',
+ symbolPosition: 'left',
+ thousandSeparator: ',',
+ decimalSeparator: '.',
+ precision: 2,
+ },
+ CA: {
+ code: 'CAD',
+ symbol: '$',
+ symbolPosition: 'left',
+ thousandSeparator: ',',
+ decimalSeparator: '.',
+ precision: 2,
+ },
+ };
+}
+
+/**
+ * Escape special characters for user input in regex.
+ *
+ * @param {string} string
+ * @return {string} string
+ */
+const escapeRegExp = ( string: string ) => {
+ return string.replace( /[.*+?^${}()|[\]\\]/g, '\\$&' );
+};
+
+/**
+ * Localises a number or numeric string for display, adding the appropriate thousands and decimal separators.
+ * For compatibility reasons, it returns the input if it's not a number or a string of numbers.
+ */
+export const localiseMonetaryValue = (
+ config: NumberConfig,
+ number: number | string | unknown
+) => {
+ if ( typeof number === 'number' ) {
+ return numberFormat( config, number );
+ }
+
+ if ( typeof number === 'string' ) {
+ const dot = escapeRegExp( config.decimalSeparator );
+ const comma = escapeRegExp( config.thousandSeparator );
+
+ // Regex to match strictly numbers with arbitrary thousands and decimal separators.
+ // Example: /^\s*(\d+|\d{1,3}(?:,\d{3})*)(?:\.\d+)?\s*$/ for default config.
+ const regex = new RegExp(
+ `^\\s*(\\d+|\\d{1,3}(?:${ comma }\\d{3})*)(?:${ dot }\\d+)?\\s*$`
+ );
+
+ return number.replace( regex, ( n ) => {
+ const parsed = parseNumber( config, n );
+ return numberFormat( config, parsed );
+ } );
+ }
+
+ return number;
+};
+
+export const unformatLocalisedMonetaryValue = (
+ config: NumberConfig,
+ inputNumber: number | string | unknown
+) => {
+ if ( ! inputNumber ) {
+ throw new Error( 'Input value is undefined' );
+ }
+
+ if ( Number.isFinite( inputNumber ) ) {
+ return inputNumber;
+ }
+
+ if ( typeof inputNumber !== 'string' ) {
+ throw new Error( 'Input value is not a number or a numeric string' );
+ }
+
+ // Brackets signal a formula. Avoid unformatting these values.
+ if ( inputNumber.includes( '[' ) && inputNumber.includes( ']' ) ) {
+ throw new Error( 'Input value contains formula' );
+ }
+
+ // Check if the string contains any non-numeric characters except allowed separators and whitespace
+ const allowedChars = new RegExp(
+ `^\\s*[0-9${ escapeRegExp( config.thousandSeparator ) }${ escapeRegExp(
+ config.decimalSeparator
+ ) }]+\\s*$`
+ );
+
+ if ( ! allowedChars.test( inputNumber ) ) {
+ throw new Error(
+ 'Input value contains non-numeric characters and is not a formula'
+ );
+ }
+
+ if (
+ // check that there is only 1 decimal separator and it is not to the left of
+ // the thousands separator if there is a thousands separator in the value
+ inputNumber.split( config.decimalSeparator ).length > 2 ||
+ ( inputNumber.includes( config.thousandSeparator ) &&
+ inputNumber.includes( config.decimalSeparator ) &&
+ inputNumber.indexOf( config.decimalSeparator ) <=
+ inputNumber.indexOf( config.thousandSeparator ) )
+ ) {
+ throw new Error( 'Invalid decimal separator' );
+ }
+
+ const unformattedValue = inputNumber
+ .replace(
+ new RegExp( escapeRegExp( config.thousandSeparator ), 'g' ),
+ ''
+ )
+ .replace( config.decimalSeparator, '.' );
+
+ return Number( unformattedValue );
+};
diff --git a/.ai/review/evals/js-public-api-contract/fixtures/G30D/before.tsx b/.ai/review/evals/js-public-api-contract/fixtures/G30D/before.tsx
new file mode 100644
index 00000000000..a2db8c7b617
--- /dev/null
+++ b/.ai/review/evals/js-public-api-contract/fixtures/G30D/before.tsx
@@ -0,0 +1,506 @@
+/**
+ * External dependencies
+ */
+import { createElement } from '@wordpress/element';
+import { decodeEntities } from '@wordpress/html-entities';
+import { sprintf } from '@wordpress/i18n';
+import { NumberConfig, numberFormat, parseNumber } from '@woocommerce/number';
+import deprecated from '@wordpress/deprecated';
+
+/**
+ * @typedef {import('@woocommerce/number').NumberConfig} NumberConfig
+ */
+/**
+ * @typedef {Object} CurrencyProps
+ * @property {string} code Currency ISO code.
+ * @property {string} symbol Symbol, can be multi-character. Should be in plain text, w/o HTML markup. HTML entities will be decoded.
+ * @property {string} symbolPosition Where the symbol should be relative to the amount. One of `'left' | 'right' | 'left_space | 'right_space'`.
+ * @typedef {NumberConfig & CurrencyProps} CurrencyConfig
+ */
+
+export type SymbolPosition = 'left' | 'right' | 'left_space' | 'right_space';
+
+export type CurrencyProps = {
+ code: string;
+ symbol: string;
+ symbolPosition: SymbolPosition;
+ priceFormat?: string;
+};
+
+export type CurrencyConfig = Partial< NumberConfig & CurrencyProps >;
+
+export type Currency = {
+ code: string;
+ symbol: string;
+ symbolPosition: string;
+ decimalSeparator: string;
+ priceFormat: string;
+ thousandSeparator: string;
+ precision: number;
+};
+
+export type CountryInfo = {
+ // https://github.com/woocommerce/woocommerce/blob/trunk/plugins/woocommerce/i18n/locale-info.php#L15-L28
+ currency_code: string;
+ currency_pos: SymbolPosition;
+ thousand_sep: string;
+ decimal_sep: string;
+ num_decimals: number;
+ weight_unit: string;
+ dimension_unit: string;
+ direction: string;
+ default_locale: string;
+ name: string;
+ singular: string;
+ plural: string;
+ short_symbol: string;
+ locales: string[];
+};
+
+/**
+ *
+ * @param {CurrencyConfig} currencySetting
+ * @return {Object} currency object
+ */
+const CurrencyFactoryBase = function ( currencySetting?: CurrencyConfig ) {
+ let currency: Currency;
+
+ function stripTags( str: string ) {
+ // sanitize Polyfill - see https://github.com/WordPress/WordPress/blob/master/wp-includes/js/wp-sanitize.js
+ const strippedStr = str
+ .replace( /<!--[\s\S]*?(-->|$)/g, '' )
+ .replace( /<(script|style)[^>]*>[\s\S]*?(<\/\1>|$)/gi, '' )
+ .replace( /<\/?[a-z][\s\S]*?(>|$)/gi, '' );
+
+ if ( strippedStr !== str ) {
+ return stripTags( strippedStr );
+ }
+
+ return strippedStr;
+ }
+
+ /**
+ * Get the default price format from a currency.
+ *
+ * @param {CurrencyConfig} config Currency configuration.
+ * @return {string} Price format.
+ */
+ function getPriceFormat( config: CurrencyConfig ) {
+ if ( config.priceFormat ) {
+ return stripTags( config.priceFormat.toString() );
+ }
+
+ switch ( config.symbolPosition ) {
+ case 'left':
+ return '%1$s%2$s';
+ case 'right':
+ return '%2$s%1$s';
+ case 'left_space':
+ return '%1$s %2$s';
+ case 'right_space':
+ return '%2$s %1$s';
+ }
+
+ return '%1$s%2$s';
+ }
+
+ function setCurrency( setting?: CurrencyConfig ) {
+ const defaultCurrency = {
+ code: 'USD',
+ symbol: '$',
+ symbolPosition: 'left' as const,
+ thousandSeparator: ',',
+ decimalSeparator: '.',
+ precision: 2,
+ };
+ const config = { ...defaultCurrency, ...setting };
+
+ let precision = config.precision;
+ if ( precision === null ) {
+ // eslint-disable-next-line no-console
+ console.warn( 'Currency precision is null' );
+ // eslint-enable-next-line no-console
+
+ precision = NaN;
+ } else if ( typeof precision === 'string' ) {
+ precision = parseInt( precision, 10 );
+ }
+
+ currency = {
+ code: config.code.toString(),
+ symbol: decodeEntities( config.symbol.toString() ),
+ symbolPosition: config.symbolPosition.toString(),
+ decimalSeparator: config.decimalSeparator.toString(),
+ priceFormat: getPriceFormat( config ),
+ thousandSeparator: config.thousandSeparator.toString(),
+ precision,
+ };
+ }
+
+ /**
+ * Formats money value.
+ *
+ * @param {number|string} number number to format
+ * @param {boolean} [useCode=false] Set to `true` to use the currency code instead of the symbol.
+ * @return {?string} A formatted string.
+ */
+ function formatAmount( number: number | string, useCode = false ) {
+ const formattedNumber = numberFormat( currency, number );
+
+ if ( formattedNumber === '' ) {
+ return formattedNumber;
+ }
+
+ const { priceFormat, symbol, code } = currency;
+
+ // @ts-expect-error priceFormat is dynamic, but is expected to include placeholders for the currency and amount.
+
+ return sprintf( priceFormat, useCode ? code : symbol, formattedNumber );
+ }
+
+ /**
+ * Formats money value.
+ *
+ * @deprecated
+ * @param {number|string} number number to format
+ * @return {?string} A formatted string.
+ */
+ function formatCurrency( number: number | string ) {
+ deprecated( 'Currency().formatCurrency', {
+ version: '5.0.0',
+ alternative: 'Currency().formatAmount',
+ plugin: 'WooCommerce',
+ hint: '`formatAmount` accepts the same arguments as formatCurrency',
+ } );
+ return formatAmount( number );
+ }
+
+ /**
+ * Get formatted data for a country from supplied locale and symbol info.
+ *
+ * @param {string} countryCode Country code.
+ * @param {Object} localeInfo Locale info by country code.
+ * @param {Object} currencySymbols Currency symbols by symbol code. HTML entities will be decoded.
+ * @return {CurrencyConfig | {}} Formatted currency data for country.
+ */
+ function getDataForCountry(
+ countryCode: string,
+ localeInfo: Record< string, CountryInfo | undefined > = {},
+ currencySymbols: Record< string, string | undefined > = {}
+ ): CurrencyConfig | Record< string, never > {
+ const countryInfo = localeInfo[ countryCode ];
+ if ( ! countryInfo ) {
+ return {};
+ }
+
+ const symbol = currencySymbols[ countryInfo.currency_code ];
+ if ( ! symbol ) {
+ return {};
+ }
+
+ return {
+ code: countryInfo.currency_code,
+ symbol: decodeEntities( symbol ),
+ symbolPosition: countryInfo.currency_pos,
+ thousandSeparator: countryInfo.thousand_sep,
+ decimalSeparator: countryInfo.decimal_sep,
+ precision: countryInfo.num_decimals,
+ };
+ }
+
+ setCurrency( currencySetting );
+
+ return {
+ getCurrencyConfig: () => {
+ return { ...currency };
+ },
+ getDataForCountry,
+ setCurrency,
+ formatAmount,
+ formatCurrency,
+ getPriceFormat,
+
+ /**
+ * Get the rounded decimal value of a number at the precision used for the current currency.
+ * This is a work-around for fraction-cents, meant to be used like `wc_format_decimal`
+ *
+ * @param {number|string} number A floating point number (or integer), or string that converts to a number
+ * @return {number} The original number rounded to a decimal point
+ */
+ formatDecimal( number: number | string ) {
+ if ( typeof number !== 'number' ) {
+ number = parseFloat( number );
+ }
+ if ( Number.isNaN( number ) ) {
+ return 0;
+ }
+ const { precision } = currency;
+ return (
+ Math.round( number * Math.pow( 10, precision ) ) /
+ Math.pow( 10, precision )
+ );
+ },
+
+ /**
+ * Get the string representation of a floating point number to the precision used by the current currency.
+ * This is different from `formatAmount` by not returning the currency symbol.
+ *
+ * @param {number|string} number A floating point number (or integer), or string that converts to a number
+ * @return {string} The original number rounded to a decimal point
+ */
+ formatDecimalString( number: number | string ) {
+ if ( typeof number !== 'number' ) {
+ number = parseFloat( number );
+ }
+ if ( Number.isNaN( number ) ) {
+ return '';
+ }
+ const { precision } = currency;
+ return number.toFixed( precision );
+ },
+
+ /**
+ * Render a currency for display in a component.
+ *
+ * @param {number|string} number A floating point number (or integer), or string that converts to a number
+ * @return {Node|string} The number formatted as currency and rendered for display.
+ */
+ render( number: number | string ) {
+ if ( typeof number !== 'number' ) {
+ number = parseFloat( number );
+ }
+ if ( number < 0 ) {
+ return (
+ <span className="is-negative">
+ { formatAmount( number ) }
+ </span>
+ );
+ }
+ return formatAmount( number );
+ },
+ };
+};
+
+export const CurrencyFactory = CurrencyFactoryBase;
+
+/**
+ * Returns currency data by country/region. Contains code, symbol, position, thousands separator, decimal separator, and precision.
+ *
+ * Dev Note: When adding new currencies below, the exchange rate array should also be updated in WooCommerce Admin's `business-details.js`.
+ *
+ * @deprecated
+ * @return {Object} Currency data.
+ */
+export function getCurrencyData() {
+ deprecated( 'getCurrencyData', {
+ version: '3.1.0',
+ alternative: 'CurrencyFactory.getDataForCountry',
+ plugin: 'WooCommerce Admin',
+ hint: 'Pass in the country, locale data, and symbol info to use getDataForCountry',
+ } );
+
+ // See https://github.com/woocommerce/woocommerce-admin/issues/3101.
+ return {
+ US: {
+ code: 'USD',
+ symbol: '$',
+ symbolPosition: 'left',
+ thousandSeparator: ',',
+ decimalSeparator: '.',
+ precision: 2,
+ },
+ EU: {
+ code: 'EUR',
+ symbol: '€',
+ symbolPosition: 'left',
+ thousandSeparator: '.',
+ decimalSeparator: ',',
+ precision: 2,
+ },
+ IN: {
+ code: 'INR',
+ symbol: '₹',
+ symbolPosition: 'left',
+ thousandSeparator: ',',
+ decimalSeparator: '.',
+ precision: 2,
+ },
+ GB: {
+ code: 'GBP',
+ symbol: '£',
+ symbolPosition: 'left',
+ thousandSeparator: ',',
+ decimalSeparator: '.',
+ precision: 2,
+ },
+ BR: {
+ code: 'BRL',
+ symbol: 'R$',
+ symbolPosition: 'left',
+ thousandSeparator: '.',
+ decimalSeparator: ',',
+ precision: 2,
+ },
+ VN: {
+ code: 'VND',
+ symbol: '₫',
+ symbolPosition: 'right',
+ thousandSeparator: '.',
+ decimalSeparator: ',',
+ precision: 1,
+ },
+ ID: {
+ code: 'IDR',
+ symbol: 'Rp',
+ symbolPosition: 'left',
+ thousandSeparator: '.',
+ decimalSeparator: ',',
+ precision: 0,
+ },
+ BD: {
+ code: 'BDT',
+ symbol: '৳',
+ symbolPosition: 'left',
+ thousandSeparator: ',',
+ decimalSeparator: '.',
+ precision: 0,
+ },
+ PK: {
+ code: 'PKR',
+ symbol: '₨',
+ symbolPosition: 'left',
+ thousandSeparator: ',',
+ decimalSeparator: '.',
+ precision: 2,
+ },
+ RU: {
+ code: 'RUB',
+ symbol: '₽',
+ symbolPosition: 'right',
+ thousandSeparator: ' ',
+ decimalSeparator: ',',
+ precision: 2,
+ },
+ TR: {
+ code: 'TRY',
+ symbol: '₺',
+ symbolPosition: 'left',
+ thousandSeparator: '.',
+ decimalSeparator: ',',
+ precision: 2,
+ },
+ MX: {
+ code: 'MXN',
+ symbol: '$',
+ symbolPosition: 'left',
+ thousandSeparator: ',',
+ decimalSeparator: '.',
+ precision: 2,
+ },
+ CA: {
+ code: 'CAD',
+ symbol: '$',
+ symbolPosition: 'left',
+ thousandSeparator: ',',
+ decimalSeparator: '.',
+ precision: 2,
+ },
+ };
+}
+
+/**
+ * Escape special characters for user input in regex.
+ *
+ * @param {string} string
+ * @return {string} string
+ */
+const escapeRegExp = ( string: string ) => {
+ return string.replace( /[.*+?^${}()|[\]\\]/g, '\\$&' );
+};
+
+/**
+ * Localises a number or numeric string for display, adding the appropriate thousands and decimal separators.
+ * For compatibility reasons, it returns the input if it's not a number or a string of numbers.
+ */
+export const localiseMonetaryValue = (
+ config: NumberConfig,
+ number: number | string | unknown
+) => {
+ if ( typeof number === 'number' ) {
+ return numberFormat( config, number );
+ }
+
+ if ( typeof number === 'string' ) {
+ const dot = escapeRegExp( config.decimalSeparator );
+ const comma = escapeRegExp( config.thousandSeparator );
+
+ // Regex to match strictly numbers with arbitrary thousands and decimal separators.
+ // Example: /^\s*(\d+|\d{1,3}(?:,\d{3})*)(?:\.\d+)?\s*$/ for default config.
+ const regex = new RegExp(
+ `^\\s*(\\d+|\\d{1,3}(?:${ comma }\\d{3})*)(?:${ dot }\\d+)?\\s*$`
+ );
+
+ return number.replace( regex, ( n ) => {
+ const parsed = parseNumber( config, n );
+ return numberFormat( config, parsed );
+ } );
+ }
+
+ return number;
+};
+
+export const unformatLocalisedMonetaryValue = (
+ config: NumberConfig,
+ inputNumber: number | string | unknown
+) => {
+ if ( ! inputNumber ) {
+ throw new Error( 'Input value is undefined' );
+ }
+
+ if ( Number.isFinite( inputNumber ) ) {
+ return inputNumber;
+ }
+
+ if ( typeof inputNumber !== 'string' ) {
+ throw new Error( 'Input value is not a number or a numeric string' );
+ }
+
+ // Brackets signal a formula. Avoid unformatting these values.
+ if ( inputNumber.includes( '[' ) && inputNumber.includes( ']' ) ) {
+ throw new Error( 'Input value contains formula' );
+ }
+
+ // Check if the string contains any non-numeric characters except allowed separators and whitespace
+ const allowedChars = new RegExp(
+ `^\\s*[0-9${ escapeRegExp( config.thousandSeparator ) }${ escapeRegExp(
+ config.decimalSeparator
+ ) }]+\\s*$`
+ );
+
+ if ( ! allowedChars.test( inputNumber ) ) {
+ throw new Error(
+ 'Input value contains non-numeric characters and is not a formula'
+ );
+ }
+
+ if (
+ // check that there is only 1 decimal separator and it is not to the left of
+ // the thousands separator if there is a thousands separator in the value
+ inputNumber.split( config.decimalSeparator ).length > 2 ||
+ ( inputNumber.includes( config.thousandSeparator ) &&
+ inputNumber.includes( config.decimalSeparator ) &&
+ inputNumber.indexOf( config.decimalSeparator ) <=
+ inputNumber.indexOf( config.thousandSeparator ) )
+ ) {
+ throw new Error( 'Invalid decimal separator' );
+ }
+
+ const unformattedValue = inputNumber
+ .replace(
+ new RegExp( escapeRegExp( config.thousandSeparator ), 'g' ),
+ ''
+ )
+ .replace( config.decimalSeparator, '.' );
+
+ return Number( unformattedValue );
+};
diff --git a/.ai/review/evals/js-request-failure-handling/cases.json b/.ai/review/evals/js-request-failure-handling/cases.json
new file mode 100644
index 00000000000..c9ebb3826cf
--- /dev/null
+++ b/.ai/review/evals/js-request-failure-handling/cases.json
@@ -0,0 +1,77 @@
+{
+ "version": 1,
+ "cases": [
+ {
+ "id": "G33A-v3",
+ "path": "plugins/woocommerce/client/blocks/assets/js/blocks/checkout/address-suggestions.js",
+ "before": {
+ "file": "fixtures/G33A/before.js"
+ },
+ "after": {
+ "file": "fixtures/G33A/after.js"
+ },
+ "expect": {
+ "outcome": "finding",
+ "locations": [
+ {
+ "file": "plugins/woocommerce/client/blocks/assets/js/blocks/checkout/address-suggestions.js",
+ "lines": [
+ 2,
+ 2
+ ],
+ "anchor": "export async function loadSuggestions( url, setSuggestions, restoreManualEntry ) { try { const response = await fetch( url ); if ( ! response.ok ) { throw new Error( 'Request failed' ); } setSuggestions( await response.json() ); } finally { /* request settled */ } }"
+ }
+ ]
+ }
+ },
+ {
+ "id": "G33C-v3",
+ "path": "plugins/woocommerce/client/blocks/assets/js/blocks/checkout/address-suggestions.js",
+ "before": {
+ "file": "fixtures/G33C/before.js"
+ },
+ "after": {
+ "file": "fixtures/G33C/after.js"
+ },
+ "expect": {
+ "outcome": "quiet"
+ }
+ },
+ {
+ "id": "G33D-v3",
+ "path": "plugins/woocommerce/client/blocks/assets/js/blocks/checkout/address-suggestions.js",
+ "before": {
+ "file": "fixtures/G33D/before.js"
+ },
+ "after": {
+ "file": "fixtures/G33D/after.js"
+ },
+ "expect": {
+ "outcome": "quiet"
+ }
+ },
+ {
+ "id": "G33B-v5",
+ "path": "plugins/woocommerce/client/blocks/assets/js/blocks/checkout/address-suggestions.js",
+ "before": {
+ "code": "/* Cart reads this localStorage cache before initial render; a cache miss is refreshed from the server. */\nexport function readCartCache( storage ) { try { const value = JSON.parse( storage.getItem( 'wc-cart' ) ); return value && Array.isArray( value.items ) ? value.items : []; } catch ( error ) { return []; } }\n"
+ },
+ "after": {
+ "code": "/* Cart reads this localStorage cache before initial render; a cache miss is refreshed from the server. */\nexport function readCartCache( storage ) { const value = JSON.parse( storage.getItem( 'wc-cart' ) ); return value && Array.isArray( value.items ) ? value.items : []; }\n"
+ },
+ "expect": {
+ "outcome": "finding",
+ "locations": [
+ {
+ "file": "plugins/woocommerce/client/blocks/assets/js/blocks/checkout/address-suggestions.js",
+ "lines": [
+ 2,
+ 2
+ ],
+ "anchor": "export function readCartCache( storage ) { const value = JSON.parse( storage.getItem( 'wc-cart' ) ); return value && Array.isArray( value.items ) ? value.items : []; }"
+ }
+ ]
+ }
+ }
+ ]
+}
diff --git a/.ai/review/evals/js-request-failure-handling/fixtures/G33A/after.js b/.ai/review/evals/js-request-failure-handling/fixtures/G33A/after.js
new file mode 100644
index 00000000000..b366d4254f8
--- /dev/null
+++ b/.ai/review/evals/js-request-failure-handling/fixtures/G33A/after.js
@@ -0,0 +1,2 @@
+/* Address suggestion widget hides the manual-entry panel while loading. Caller starts loadSuggestions without awaiting it. */
+export async function loadSuggestions( url, setSuggestions, restoreManualEntry ) { try { const response = await fetch( url ); if ( ! response.ok ) { throw new Error( 'Request failed' ); } setSuggestions( await response.json() ); } finally { /* request settled */ } }
diff --git a/.ai/review/evals/js-request-failure-handling/fixtures/G33A/before.js b/.ai/review/evals/js-request-failure-handling/fixtures/G33A/before.js
new file mode 100644
index 00000000000..8b6b9f817c4
--- /dev/null
+++ b/.ai/review/evals/js-request-failure-handling/fixtures/G33A/before.js
@@ -0,0 +1,2 @@
+/* Address suggestion widget hides the manual-entry panel while loading. Caller starts loadSuggestions without awaiting it. */
+export async function loadSuggestions( url, setSuggestions, restoreManualEntry ) { try { const response = await fetch( url ); if ( ! response.ok ) { throw new Error( 'Request failed' ); } setSuggestions( await response.json() ); } catch ( error ) { restoreManualEntry(); } }
diff --git a/.ai/review/evals/js-request-failure-handling/fixtures/G33C/after.js b/.ai/review/evals/js-request-failure-handling/fixtures/G33C/after.js
new file mode 100644
index 00000000000..1796b81d6b6
--- /dev/null
+++ b/.ai/review/evals/js-request-failure-handling/fixtures/G33C/after.js
@@ -0,0 +1,2 @@
+/* Aggregate best-effort telemetry; fixed trusted URL. Callers do not consume a result. */
+export function recordSuggestionOpened( url ) { void fetch( url, { method: 'POST', keepalive: true } ).catch( () => {} ); }
diff --git a/.ai/review/evals/js-request-failure-handling/fixtures/G33C/before.js b/.ai/review/evals/js-request-failure-handling/fixtures/G33C/before.js
new file mode 100644
index 00000000000..c7cea84b774
--- /dev/null
+++ b/.ai/review/evals/js-request-failure-handling/fixtures/G33C/before.js
@@ -0,0 +1,2 @@
+/* Aggregate best-effort telemetry; fixed trusted URL. Callers do not consume a result. */
+export function recordSuggestionOpened( url ) { return fetch( url, { method: 'POST' } ).catch( () => {} ); }
diff --git a/.ai/review/evals/js-request-failure-handling/fixtures/G33D/after.js b/.ai/review/evals/js-request-failure-handling/fixtures/G33D/after.js
new file mode 100644
index 00000000000..e41ec1e49b4
--- /dev/null
+++ b/.ai/review/evals/js-request-failure-handling/fixtures/G33D/after.js
@@ -0,0 +1,3 @@
+/* Private request helper; loadSuggestions is its only caller. URL is a fixed same-origin endpoint. */
+async function requestSuggestions( url ) { const response = await fetch( url ); if ( ! response.ok ) { throw new Error( 'Request failed' ); } return response.json(); }
+export async function loadSuggestions( url, setSuggestions, restoreManualEntry ) { try { setSuggestions( await requestSuggestions( url ) ); } catch ( error ) { restoreManualEntry(); } }
diff --git a/.ai/review/evals/js-request-failure-handling/fixtures/G33D/before.js b/.ai/review/evals/js-request-failure-handling/fixtures/G33D/before.js
new file mode 100644
index 00000000000..ab6334d66e1
--- /dev/null
+++ b/.ai/review/evals/js-request-failure-handling/fixtures/G33D/before.js
@@ -0,0 +1,2 @@
+/* Private request helper; loadSuggestions is its only caller. URL is a fixed same-origin endpoint. */
+export async function loadSuggestions( url, setSuggestions, restoreManualEntry ) { try { const response = await fetch( url ); if ( ! response.ok ) { throw new Error( 'Request failed' ); } setSuggestions( await response.json() ); } catch ( error ) { restoreManualEntry(); } }
diff --git a/.ai/review/evals/meta-option-value-semantics/cases.json b/.ai/review/evals/meta-option-value-semantics/cases.json
new file mode 100644
index 00000000000..29e7b864a80
--- /dev/null
+++ b/.ai/review/evals/meta-option-value-semantics/cases.json
@@ -0,0 +1,77 @@
+{
+ "version": 1,
+ "cases": [
+ {
+ "id": "G09A-v3",
+ "path": "plugins/woocommerce/includes/class-wc-meta-option-value-semantics.php",
+ "before": {
+ "file": "fixtures/G09A/before.php"
+ },
+ "after": {
+ "file": "fixtures/G09A/after.php"
+ },
+ "expect": {
+ "outcome": "finding",
+ "locations": [
+ {
+ "file": "plugins/woocommerce/includes/class-wc-meta-option-value-semantics.php",
+ "lines": [
+ 4,
+ 4
+ ],
+ "anchor": " update_post_meta( $id, '_export_shipping_cost', (string) (float) $cost );"
+ }
+ ]
+ }
+ },
+ {
+ "id": "G09B-v3",
+ "path": "plugins/woocommerce/includes/class-wc-meta-option-value-semantics.php",
+ "before": {
+ "file": "fixtures/G09B/before.php"
+ },
+ "after": {
+ "file": "fixtures/G09B/after.php"
+ },
+ "expect": {
+ "outcome": "finding",
+ "locations": [
+ {
+ "file": "plugins/woocommerce/includes/class-wc-meta-option-value-semantics.php",
+ "lines": [
+ 4,
+ 4
+ ],
+ "anchor": " update_option( 'woocommerce_export_settings', array( 'enabled' => $enabled ) );"
+ }
+ ]
+ }
+ },
+ {
+ "id": "G09C-v3",
+ "path": "plugins/woocommerce/includes/class-wc-meta-option-value-semantics.php",
+ "before": {
+ "file": "fixtures/G09C/before.php"
+ },
+ "after": {
+ "file": "fixtures/G09C/after.php"
+ },
+ "expect": {
+ "outcome": "quiet"
+ }
+ },
+ {
+ "id": "G09D-v3",
+ "path": "plugins/woocommerce/includes/class-wc-meta-option-value-semantics.php",
+ "before": {
+ "file": "fixtures/G09D/before.php"
+ },
+ "after": {
+ "file": "fixtures/G09D/after.php"
+ },
+ "expect": {
+ "outcome": "quiet"
+ }
+ }
+ ]
+}
diff --git a/.ai/review/evals/meta-option-value-semantics/fixtures/G09A/after.php b/.ai/review/evals/meta-option-value-semantics/fixtures/G09A/after.php
new file mode 100644
index 00000000000..ffb032fb82e
--- /dev/null
+++ b/.ai/review/evals/meta-option-value-semantics/fixtures/G09A/after.php
@@ -0,0 +1,9 @@
+<?php
+/* Shipping export configuration. */
+function wc_store_shipping_cost( $id, $cost ) {
+ update_post_meta( $id, '_export_shipping_cost', (string) (float) $cost );
+}
+function wc_shipping_cost_or_default( $id ) {
+ $cost = get_post_meta( $id, '_export_shipping_cost', true );
+ return '' === $cost ? 'inherit' : $cost;
+}
diff --git a/.ai/review/evals/meta-option-value-semantics/fixtures/G09A/before.php b/.ai/review/evals/meta-option-value-semantics/fixtures/G09A/before.php
new file mode 100644
index 00000000000..c5659656a9a
--- /dev/null
+++ b/.ai/review/evals/meta-option-value-semantics/fixtures/G09A/before.php
@@ -0,0 +1,9 @@
+<?php
+/* Shipping export configuration. */
+function wc_store_shipping_cost( $id, $cost ) {
+ update_post_meta( $id, '_export_shipping_cost', $cost );
+}
+function wc_shipping_cost_or_default( $id ) {
+ $cost = get_post_meta( $id, '_export_shipping_cost', true );
+ return '' === $cost ? 'inherit' : $cost;
+}
diff --git a/.ai/review/evals/meta-option-value-semantics/fixtures/G09B/after.php b/.ai/review/evals/meta-option-value-semantics/fixtures/G09B/after.php
new file mode 100644
index 00000000000..83f358ffb4f
--- /dev/null
+++ b/.ai/review/evals/meta-option-value-semantics/fixtures/G09B/after.php
@@ -0,0 +1,9 @@
+<?php
+/* Export destination configuration. */
+function wc_save_export_settings( $url, $enabled ) {
+ update_option( 'woocommerce_export_settings', array( 'enabled' => $enabled ) );
+}
+function wc_export_destination() {
+ $settings = get_option( 'woocommerce_export_settings', array() );
+ return $settings['url'] ?? '';
+}
diff --git a/.ai/review/evals/meta-option-value-semantics/fixtures/G09B/before.php b/.ai/review/evals/meta-option-value-semantics/fixtures/G09B/before.php
new file mode 100644
index 00000000000..fd357c5cffd
--- /dev/null
+++ b/.ai/review/evals/meta-option-value-semantics/fixtures/G09B/before.php
@@ -0,0 +1,9 @@
+<?php
+/* Export destination configuration. */
+function wc_save_export_settings( $url, $enabled ) {
+ update_option( 'woocommerce_export_settings', array( 'url' => $url, 'enabled' => $enabled ) );
+}
+function wc_export_destination() {
+ $settings = get_option( 'woocommerce_export_settings', array() );
+ return $settings['url'] ?? '';
+}
diff --git a/.ai/review/evals/meta-option-value-semantics/fixtures/G09C/after.php b/.ai/review/evals/meta-option-value-semantics/fixtures/G09C/after.php
new file mode 100644
index 00000000000..9b9109f3eed
--- /dev/null
+++ b/.ai/review/evals/meta-option-value-semantics/fixtures/G09C/after.php
@@ -0,0 +1,10 @@
+<?php
+/* Export destination configuration. */
+function wc_save_export_settings( $url, $enabled ) {
+ update_option( 'woocommerce_export_settings', array( 'url' => $url, 'enabled' => $enabled, 'schema_version' => 1 ) );
+}
+
+function wc_export_destination() {
+ $settings = get_option( 'woocommerce_export_settings', array() );
+ return ! empty( $settings['enabled'] ) ? $settings['url'] : '';
+}
diff --git a/.ai/review/evals/meta-option-value-semantics/fixtures/G09C/before.php b/.ai/review/evals/meta-option-value-semantics/fixtures/G09C/before.php
new file mode 100644
index 00000000000..b6630f996bb
--- /dev/null
+++ b/.ai/review/evals/meta-option-value-semantics/fixtures/G09C/before.php
@@ -0,0 +1,10 @@
+<?php
+/* Export destination configuration. */
+function wc_save_export_settings( $url, $enabled ) {
+ update_option( 'woocommerce_export_settings', array( 'url' => $url, 'enabled' => $enabled ) );
+}
+
+function wc_export_destination() {
+ $settings = get_option( 'woocommerce_export_settings', array() );
+ return ! empty( $settings['enabled'] ) ? $settings['url'] : '';
+}
diff --git a/.ai/review/evals/meta-option-value-semantics/fixtures/G09D/after.php b/.ai/review/evals/meta-option-value-semantics/fixtures/G09D/after.php
new file mode 100644
index 00000000000..579520913f4
--- /dev/null
+++ b/.ai/review/evals/meta-option-value-semantics/fixtures/G09D/after.php
@@ -0,0 +1,7 @@
+<?php
+/* Legacy export settings can be scalar or array. */
+function wc_export_timeout() {
+ $settings = get_option( 'woocommerce_export_settings', array() );
+ if ( ! is_array( $settings ) ) { $settings = array(); }
+ return isset( $settings['timeout'] ) && is_numeric( $settings['timeout'] ) ? (int) $settings['timeout'] : 30;
+}
diff --git a/.ai/review/evals/meta-option-value-semantics/fixtures/G09D/before.php b/.ai/review/evals/meta-option-value-semantics/fixtures/G09D/before.php
new file mode 100644
index 00000000000..4c7a1c7c43b
--- /dev/null
+++ b/.ai/review/evals/meta-option-value-semantics/fixtures/G09D/before.php
@@ -0,0 +1,7 @@
+<?php
+/* Legacy export settings can be scalar or array. */
+function wc_export_timeout() {
+ $settings = get_option( 'woocommerce_export_settings', array() );
+ if ( ! is_array( $settings ) ) { return 30; }
+ return isset( $settings['timeout'] ) && is_numeric( $settings['timeout'] ) ? (int) $settings['timeout'] : 30;
+}
diff --git a/.ai/review/evals/migration-upgrade-state/cases.json b/.ai/review/evals/migration-upgrade-state/cases.json
new file mode 100644
index 00000000000..9d24e675055
--- /dev/null
+++ b/.ai/review/evals/migration-upgrade-state/cases.json
@@ -0,0 +1,77 @@
+{
+ "version": 1,
+ "cases": [
+ {
+ "id": "G11A-v3",
+ "path": "plugins/woocommerce/includes/class-wc-install.php",
+ "before": {
+ "file": "fixtures/G11A/before.php"
+ },
+ "after": {
+ "file": "fixtures/G11A/after.php"
+ },
+ "expect": {
+ "outcome": "finding",
+ "locations": [
+ {
+ "file": "plugins/woocommerce/includes/class-wc-install.php",
+ "lines": [
+ 4,
+ 4
+ ],
+ "anchor": " $db_updates = array( '11.0.0' => array( 'rebuild_shipping_index', 'backfill_zone_codes' ) );"
+ }
+ ]
+ }
+ },
+ {
+ "id": "G11B-v3",
+ "path": "plugins/woocommerce/includes/wc-update-functions.php",
+ "before": {
+ "file": "fixtures/G11B/before.php"
+ },
+ "after": {
+ "file": "fixtures/G11B/after.php"
+ },
+ "expect": {
+ "outcome": "finding",
+ "locations": [
+ {
+ "file": "plugins/woocommerce/includes/wc-update-functions.php",
+ "lines": [
+ 6,
+ 6
+ ],
+ "anchor": " $order->update_meta_data( '_refund_migrated', 'yes' );"
+ }
+ ]
+ }
+ },
+ {
+ "id": "G11C-v3",
+ "path": "plugins/woocommerce/includes/wc-update-functions.php",
+ "before": {
+ "file": "fixtures/G11C/before.php"
+ },
+ "after": {
+ "file": "fixtures/G11C/after.php"
+ },
+ "expect": {
+ "outcome": "quiet"
+ }
+ },
+ {
+ "id": "G11D-v3",
+ "path": "plugins/woocommerce/includes/class-wc-install.php",
+ "before": {
+ "file": "fixtures/G11D/before.php"
+ },
+ "after": {
+ "file": "fixtures/G11D/after.php"
+ },
+ "expect": {
+ "outcome": "quiet"
+ }
+ }
+ ]
+}
diff --git a/.ai/review/evals/migration-upgrade-state/fixtures/G11A/after.php b/.ai/review/evals/migration-upgrade-state/fixtures/G11A/after.php
new file mode 100644
index 00000000000..fae5db1f8e2
--- /dev/null
+++ b/.ai/review/evals/migration-upgrade-state/fixtures/G11A/after.php
@@ -0,0 +1,13 @@
+<?php
+/* Migration registry for release 11.1.0. Version 11.0.0 has shipped. */
+function run_pending_migrations( $completed, $run ) {
+ $db_updates = array( '11.0.0' => array( 'rebuild_shipping_index', 'backfill_zone_codes' ) );
+ foreach ( $db_updates as $version => $callbacks ) {
+ if ( in_array( $version, $completed, true ) ) { continue; }
+ foreach ( $callbacks as $callback ) { $run( $callback ); }
+ }
+}
+
+function upgrade_existing_store( $run ) {
+ run_pending_migrations( array( '11.0.0' ), $run );
+}
diff --git a/.ai/review/evals/migration-upgrade-state/fixtures/G11A/before.php b/.ai/review/evals/migration-upgrade-state/fixtures/G11A/before.php
new file mode 100644
index 00000000000..300dd348602
--- /dev/null
+++ b/.ai/review/evals/migration-upgrade-state/fixtures/G11A/before.php
@@ -0,0 +1,13 @@
+<?php
+/* Migration registry for release 11.1.0. Version 11.0.0 has shipped. */
+function run_pending_migrations( $completed, $run ) {
+ $db_updates = array( '11.0.0' => array( 'rebuild_shipping_index' ) );
+ foreach ( $db_updates as $version => $callbacks ) {
+ if ( in_array( $version, $completed, true ) ) { continue; }
+ foreach ( $callbacks as $callback ) { $run( $callback ); }
+ }
+}
+
+function upgrade_existing_store( $run ) {
+ run_pending_migrations( array( '11.0.0' ), $run );
+}
diff --git a/.ai/review/evals/migration-upgrade-state/fixtures/G11B/after.php b/.ai/review/evals/migration-upgrade-state/fixtures/G11B/after.php
new file mode 100644
index 00000000000..420322b2c5c
--- /dev/null
+++ b/.ai/review/evals/migration-upgrade-state/fixtures/G11B/after.php
@@ -0,0 +1,10 @@
+<?php
+/* Upgrade worker for legacy refund records. */
+function wc_update_refund_parents( $refunds, $load_order, $mark_complete ) {
+ foreach ( $refunds as $refund ) {
+ $order = $load_order( $refund['parent_id'] );
+ $order->update_meta_data( '_refund_migrated', 'yes' );
+ $order->save();
+ }
+ $mark_complete();
+}
diff --git a/.ai/review/evals/migration-upgrade-state/fixtures/G11B/before.php b/.ai/review/evals/migration-upgrade-state/fixtures/G11B/before.php
new file mode 100644
index 00000000000..fae8af2750e
--- /dev/null
+++ b/.ai/review/evals/migration-upgrade-state/fixtures/G11B/before.php
@@ -0,0 +1,11 @@
+<?php
+/* Upgrade worker for legacy refund records. */
+function wc_update_refund_parents( $refunds, $load_order, $mark_complete ) {
+ foreach ( $refunds as $refund ) {
+ $order = $load_order( $refund['parent_id'] );
+ if ( ! $order ) { continue; }
+ $order->update_meta_data( '_refund_migrated', 'yes' );
+ $order->save();
+ }
+ $mark_complete();
+}
diff --git a/.ai/review/evals/migration-upgrade-state/fixtures/G11C/after.php b/.ai/review/evals/migration-upgrade-state/fixtures/G11C/after.php
new file mode 100644
index 00000000000..4ce851d9bc5
--- /dev/null
+++ b/.ai/review/evals/migration-upgrade-state/fixtures/G11C/after.php
@@ -0,0 +1,11 @@
+<?php
+/* Upgrade worker for legacy refund records. */
+function wc_update_refund_parents( $refunds, $load_order, $mark_complete ) {
+ foreach ( $refunds as $refund ) {
+ $order = $load_order( $refund['parent_id'] );
+ if ( ! $order || 'yes' === $order->get_meta( '_refund_migrated' ) ) { continue; }
+ $order->update_meta_data( '_refund_migrated', 'yes' );
+ $order->save();
+ }
+ $mark_complete();
+}
diff --git a/.ai/review/evals/migration-upgrade-state/fixtures/G11C/before.php b/.ai/review/evals/migration-upgrade-state/fixtures/G11C/before.php
new file mode 100644
index 00000000000..fae8af2750e
--- /dev/null
+++ b/.ai/review/evals/migration-upgrade-state/fixtures/G11C/before.php
@@ -0,0 +1,11 @@
+<?php
+/* Upgrade worker for legacy refund records. */
+function wc_update_refund_parents( $refunds, $load_order, $mark_complete ) {
+ foreach ( $refunds as $refund ) {
+ $order = $load_order( $refund['parent_id'] );
+ if ( ! $order ) { continue; }
+ $order->update_meta_data( '_refund_migrated', 'yes' );
+ $order->save();
+ }
+ $mark_complete();
+}
diff --git a/.ai/review/evals/migration-upgrade-state/fixtures/G11D/after.php b/.ai/review/evals/migration-upgrade-state/fixtures/G11D/after.php
new file mode 100644
index 00000000000..82d55b957ea
--- /dev/null
+++ b/.ai/review/evals/migration-upgrade-state/fixtures/G11D/after.php
@@ -0,0 +1,13 @@
+<?php
+/* Migration registry for release 11.1.0. Version 11.0.0 has shipped. */
+function run_pending_migrations( $completed, $run ) {
+ $db_updates = array( '11.0.0' => array( 'rebuild_shipping_index' ), '11.1.0' => array( 'backfill_zone_codes' ) );
+ foreach ( $db_updates as $version => $callbacks ) {
+ if ( in_array( $version, $completed, true ) ) { continue; }
+ foreach ( $callbacks as $callback ) { $run( $callback ); }
+ }
+}
+
+function upgrade_existing_store( $run ) {
+ run_pending_migrations( array( '11.0.0' ), $run );
+}
diff --git a/.ai/review/evals/migration-upgrade-state/fixtures/G11D/before.php b/.ai/review/evals/migration-upgrade-state/fixtures/G11D/before.php
new file mode 100644
index 00000000000..300dd348602
--- /dev/null
+++ b/.ai/review/evals/migration-upgrade-state/fixtures/G11D/before.php
@@ -0,0 +1,13 @@
+<?php
+/* Migration registry for release 11.1.0. Version 11.0.0 has shipped. */
+function run_pending_migrations( $completed, $run ) {
+ $db_updates = array( '11.0.0' => array( 'rebuild_shipping_index' ) );
+ foreach ( $db_updates as $version => $callbacks ) {
+ if ( in_array( $version, $completed, true ) ) { continue; }
+ foreach ( $callbacks as $callback ) { $run( $callback ); }
+ }
+}
+
+function upgrade_existing_store( $run ) {
+ run_pending_migrations( array( '11.0.0' ), $run );
+}
diff --git a/.ai/review/evals/missing-object-deref/cases.json b/.ai/review/evals/missing-object-deref/cases.json
new file mode 100644
index 00000000000..8f2991e962a
--- /dev/null
+++ b/.ai/review/evals/missing-object-deref/cases.json
@@ -0,0 +1,77 @@
+{
+ "version": 1,
+ "cases": [
+ {
+ "id": "G08A-v3",
+ "path": "plugins/woocommerce/includes/class-wc-missing-object-deref.php",
+ "before": {
+ "file": "fixtures/G08A/before.php"
+ },
+ "after": {
+ "file": "fixtures/G08A/after.php"
+ },
+ "expect": {
+ "outcome": "finding",
+ "locations": [
+ {
+ "file": "plugins/woocommerce/includes/class-wc-missing-object-deref.php",
+ "lines": [
+ 5,
+ 5
+ ],
+ "anchor": " return $product->get_sku();"
+ }
+ ]
+ }
+ },
+ {
+ "id": "G08B-v3",
+ "path": "plugins/woocommerce/includes/class-wc-missing-object-deref.php",
+ "before": {
+ "file": "fixtures/G08B/before.php"
+ },
+ "after": {
+ "file": "fixtures/G08B/after.php"
+ },
+ "expect": {
+ "outcome": "finding",
+ "locations": [
+ {
+ "file": "plugins/woocommerce/includes/class-wc-missing-object-deref.php",
+ "lines": [
+ 5,
+ 6
+ ],
+ "anchor": " if ( ! $variation->get_parent_id() ) { return ''; }"
+ }
+ ]
+ }
+ },
+ {
+ "id": "G08C-v3",
+ "path": "plugins/woocommerce/includes/class-wc-missing-object-deref.php",
+ "before": {
+ "file": "fixtures/G08C/before.php"
+ },
+ "after": {
+ "file": "fixtures/G08C/after.php"
+ },
+ "expect": {
+ "outcome": "quiet"
+ }
+ },
+ {
+ "id": "G08D-v3",
+ "path": "plugins/woocommerce/includes/class-wc-missing-object-deref.php",
+ "before": {
+ "file": "fixtures/G08D/before.php"
+ },
+ "after": {
+ "file": "fixtures/G08D/after.php"
+ },
+ "expect": {
+ "outcome": "quiet"
+ }
+ }
+ ]
+}
diff --git a/.ai/review/evals/missing-object-deref/fixtures/G08A/after.php b/.ai/review/evals/missing-object-deref/fixtures/G08A/after.php
new file mode 100644
index 00000000000..10e8258e91e
--- /dev/null
+++ b/.ai/review/evals/missing-object-deref/fixtures/G08A/after.php
@@ -0,0 +1,6 @@
+<?php
+/* Order line-item export. */
+function wc_admin_item_sku( $item ) {
+ $product = $item->get_product();
+ return $product->get_sku();
+}
diff --git a/.ai/review/evals/missing-object-deref/fixtures/G08A/before.php b/.ai/review/evals/missing-object-deref/fixtures/G08A/before.php
new file mode 100644
index 00000000000..e362c7218d8
--- /dev/null
+++ b/.ai/review/evals/missing-object-deref/fixtures/G08A/before.php
@@ -0,0 +1,7 @@
+<?php
+/* Order line-item export. */
+function wc_admin_item_sku( $item ) {
+ $product = $item->get_product();
+ if ( ! $product ) { return ''; }
+ return $product->get_sku();
+}
diff --git a/.ai/review/evals/missing-object-deref/fixtures/G08B/after.php b/.ai/review/evals/missing-object-deref/fixtures/G08B/after.php
new file mode 100644
index 00000000000..51d344cc5f1
--- /dev/null
+++ b/.ai/review/evals/missing-object-deref/fixtures/G08B/after.php
@@ -0,0 +1,7 @@
+<?php
+/* Variation catalog export. */
+function wc_variation_parent_name( $variation ) {
+ $parent = wc_get_product( $variation->get_parent_id() );
+ if ( ! $variation->get_parent_id() ) { return ''; }
+ return $parent->get_name();
+}
diff --git a/.ai/review/evals/missing-object-deref/fixtures/G08B/before.php b/.ai/review/evals/missing-object-deref/fixtures/G08B/before.php
new file mode 100644
index 00000000000..ca96ab58397
--- /dev/null
+++ b/.ai/review/evals/missing-object-deref/fixtures/G08B/before.php
@@ -0,0 +1,7 @@
+<?php
+/* Variation catalog export. */
+function wc_variation_parent_name( $variation ) {
+ $parent = wc_get_product( $variation->get_parent_id() );
+ if ( ! $parent ) { return ''; }
+ return $parent->get_name();
+}
diff --git a/.ai/review/evals/missing-object-deref/fixtures/G08C/after.php b/.ai/review/evals/missing-object-deref/fixtures/G08C/after.php
new file mode 100644
index 00000000000..e69afca4fe0
--- /dev/null
+++ b/.ai/review/evals/missing-object-deref/fixtures/G08C/after.php
@@ -0,0 +1,11 @@
+<?php
+final class Order_Item_Export {
+ public function render( $item ) {
+ $product = $item->get_product();
+ if ( ! $product instanceof WC_Product ) { return ''; }
+ return $this->sku( $product );
+ }
+ private function sku( $product ) {
+ return $product->get_sku();
+ }
+}
diff --git a/.ai/review/evals/missing-object-deref/fixtures/G08C/before.php b/.ai/review/evals/missing-object-deref/fixtures/G08C/before.php
new file mode 100644
index 00000000000..cc55b9d0941
--- /dev/null
+++ b/.ai/review/evals/missing-object-deref/fixtures/G08C/before.php
@@ -0,0 +1,12 @@
+<?php
+final class Order_Item_Export {
+ public function render( $item ) {
+ $product = $item->get_product();
+ if ( ! $product instanceof WC_Product ) { return ''; }
+ return $this->sku( $product );
+ }
+ private function sku( $product ) {
+ if ( ! $product ) { return ''; }
+ return $product->get_sku();
+ }
+}
diff --git a/.ai/review/evals/missing-object-deref/fixtures/G08D/after.php b/.ai/review/evals/missing-object-deref/fixtures/G08D/after.php
new file mode 100644
index 00000000000..8d07f01d762
--- /dev/null
+++ b/.ai/review/evals/missing-object-deref/fixtures/G08D/after.php
@@ -0,0 +1,5 @@
+<?php
+function wc_new_product_label() {
+ $product = new WC_Product_Simple();
+ return $product->get_name();
+}
diff --git a/.ai/review/evals/missing-object-deref/fixtures/G08D/before.php b/.ai/review/evals/missing-object-deref/fixtures/G08D/before.php
new file mode 100644
index 00000000000..7640bd8028e
--- /dev/null
+++ b/.ai/review/evals/missing-object-deref/fixtures/G08D/before.php
@@ -0,0 +1,6 @@
+<?php
+function wc_new_product_label() {
+ $product = new WC_Product_Simple();
+ if ( ! $product ) { return ''; }
+ return $product->get_name();
+}
diff --git a/.ai/review/evals/order-totals-recalc/cases.json b/.ai/review/evals/order-totals-recalc/cases.json
new file mode 100644
index 00000000000..b0766800f13
--- /dev/null
+++ b/.ai/review/evals/order-totals-recalc/cases.json
@@ -0,0 +1,77 @@
+{
+ "version": 1,
+ "cases": [
+ {
+ "id": "G19B-v3",
+ "path": "plugins/woocommerce/includes/class-wc-cart-totals.php",
+ "before": {
+ "file": "fixtures/G19B/before.php"
+ },
+ "after": {
+ "file": "fixtures/G19B/after.php"
+ },
+ "expect": {
+ "outcome": "finding",
+ "locations": [
+ {
+ "file": "plugins/woocommerce/includes/class-wc-cart-totals.php",
+ "lines": [
+ 4,
+ 4
+ ],
+ "anchor": " $tax = $net * $tax_rate;"
+ }
+ ]
+ }
+ },
+ {
+ "id": "G19D-v3",
+ "path": "plugins/woocommerce/includes/wc-order-functions.php",
+ "before": {
+ "file": "fixtures/G19D/before.php"
+ },
+ "after": {
+ "file": "fixtures/G19D/after.php"
+ },
+ "expect": {
+ "outcome": "quiet"
+ }
+ },
+ {
+ "id": "G19A-v5",
+ "path": "plugins/woocommerce/includes/wc-order-functions.php",
+ "before": {
+ "code": "<?php\n/* Admin note editor for saved orders. */\nfunction save_order_note( $order, $note ) {\n $order->set_customer_note( $note );\n $order->save();\n}\n\nfunction edit_saved_order_note( $order_id, $note ) {\n $order = wc_get_order( $order_id );\n if ( ! $order ) { return; }\n save_order_note( $order, $note );\n}\n"
+ },
+ "after": {
+ "code": "<?php\n/* Admin note editor for saved orders. */\nfunction save_order_note( $order, $note ) {\n $order->set_customer_note( $note );\n $order->calculate_totals();\n $order->save();\n}\n\nfunction edit_saved_order_note( $order_id, $note ) {\n $order = wc_get_order( $order_id );\n if ( ! $order ) { return; }\n save_order_note( $order, $note );\n}\n"
+ },
+ "expect": {
+ "outcome": "finding",
+ "locations": [
+ {
+ "file": "plugins/woocommerce/includes/wc-order-functions.php",
+ "lines": [
+ 5,
+ 5
+ ],
+ "anchor": " $order->calculate_totals();"
+ }
+ ]
+ }
+ },
+ {
+ "id": "G19C-v4",
+ "path": "plugins/woocommerce/includes/wc-order-functions.php",
+ "before": {
+ "code": "<?php\n/* Synthetic order-draft composer. For an opted-in new order, computing totals from\n * its current items, taxes, fees and shipping is the intended initialization step;\n * this is not an import of authoritative precomputed monetary totals.\n * Existing orders retain their supplied totals. The fixture uses the ordinary\n * WC_Order::calculate_totals() implementation, which persists the order itself.\n */\nfinal class WC_Order_Total_Controller {\n public function persist( WC_Order $order ) {\n $order->save();\n }\n}\n"
+ },
+ "after": {
+ "code": "<?php\n/* Synthetic order-draft composer. For an opted-in new order, computing totals from\n * its current items, taxes, fees and shipping is the intended initialization step;\n * this is not an import of authoritative precomputed monetary totals.\n * Existing orders retain their supplied totals. The fixture uses the ordinary\n * WC_Order::calculate_totals() implementation, which persists the order itself.\n */\nfinal class WC_Order_Total_Controller {\n public function persist( WC_Order $order ) {\n if ( 0 === $order->get_id() && 'yes' === get_option( 'woocommerce_calculate_new_order_totals', 'no' ) ) {\n $order->calculate_totals();\n return;\n }\n $order->save();\n }\n}\n"
+ },
+ "expect": {
+ "outcome": "quiet"
+ }
+ }
+ ]
+}
diff --git a/.ai/review/evals/order-totals-recalc/fixtures/G19B/after.php b/.ai/review/evals/order-totals-recalc/fixtures/G19B/after.php
new file mode 100644
index 00000000000..bce5c4c1c26
--- /dev/null
+++ b/.ai/review/evals/order-totals-recalc/fixtures/G19B/after.php
@@ -0,0 +1,6 @@
+<?php
+/* Checkout line calculation in a two-decimal currency. net is discounted net price and tax_rate is a decimal fraction. */
+function calculate_line_total( $net, $tax_rate, $is_vat_exempt ) {
+ $tax = $net * $tax_rate;
+ return round( $net + $tax, 2 );
+}
diff --git a/.ai/review/evals/order-totals-recalc/fixtures/G19B/before.php b/.ai/review/evals/order-totals-recalc/fixtures/G19B/before.php
new file mode 100644
index 00000000000..d90cc2d1d83
--- /dev/null
+++ b/.ai/review/evals/order-totals-recalc/fixtures/G19B/before.php
@@ -0,0 +1,6 @@
+<?php
+/* Checkout line calculation in a two-decimal currency. net is discounted net price and tax_rate is a decimal fraction. */
+function calculate_line_total( $net, $tax_rate, $is_vat_exempt ) {
+ $tax = $is_vat_exempt ? 0 : $net * $tax_rate;
+ return round( $net + $tax, 2 );
+}
diff --git a/.ai/review/evals/order-totals-recalc/fixtures/G19D/after.php b/.ai/review/evals/order-totals-recalc/fixtures/G19D/after.php
new file mode 100644
index 00000000000..5a1c12dc9a5
--- /dev/null
+++ b/.ai/review/evals/order-totals-recalc/fixtures/G19D/after.php
@@ -0,0 +1,6 @@
+<?php
+/* Read-only order summary in a two-decimal currency. */
+function formatted_order_total( $order ) {
+ $display_total = round( $order->get_total(), 2 );
+ return number_format( $display_total, 2, '.', '' );
+}
diff --git a/.ai/review/evals/order-totals-recalc/fixtures/G19D/before.php b/.ai/review/evals/order-totals-recalc/fixtures/G19D/before.php
new file mode 100644
index 00000000000..c72a4d3e8d8
--- /dev/null
+++ b/.ai/review/evals/order-totals-recalc/fixtures/G19D/before.php
@@ -0,0 +1,5 @@
+<?php
+/* Read-only order summary in a two-decimal currency. */
+function formatted_order_total( $order ) {
+ return number_format( $order->get_total(), 2, '.', '' );
+}
diff --git a/.ai/review/evals/perf-cache-option-priming/cases.json b/.ai/review/evals/perf-cache-option-priming/cases.json
new file mode 100644
index 00000000000..bbd0a41df27
--- /dev/null
+++ b/.ai/review/evals/perf-cache-option-priming/cases.json
@@ -0,0 +1,77 @@
+{
+ "version": 1,
+ "cases": [
+ {
+ "id": "G26A-v3",
+ "path": "plugins/woocommerce/includes/class-wc-eval-26.php",
+ "before": {
+ "file": "fixtures/G26A/before.php"
+ },
+ "after": {
+ "file": "fixtures/G26A/after.php"
+ },
+ "expect": {
+ "outcome": "finding",
+ "locations": [
+ {
+ "file": "plugins/woocommerce/includes/class-wc-eval-26.php",
+ "lines": [
+ 3,
+ 3
+ ],
+ "anchor": "function wc_render_listing_names( $ids ) { $names = array(); foreach ( $ids as $id ) { $p = wc_get_product( $id ); if ( $p ) { $names[] = $p->get_name(); } } return $names; }"
+ }
+ ]
+ }
+ },
+ {
+ "id": "G26C-v3",
+ "path": "plugins/woocommerce/includes/class-wc-eval-26.php",
+ "before": {
+ "file": "fixtures/G26C-v2/before.php"
+ },
+ "after": {
+ "file": "fixtures/G26C-v2/after.php"
+ },
+ "expect": {
+ "outcome": "quiet"
+ }
+ },
+ {
+ "id": "G26D-v3",
+ "path": "plugins/woocommerce/includes/class-wc-eval-26.php",
+ "before": {
+ "file": "fixtures/G26D-v2/before.php"
+ },
+ "after": {
+ "file": "fixtures/G26D-v2/after.php"
+ },
+ "expect": {
+ "outcome": "quiet"
+ }
+ },
+ {
+ "id": "G26B-v6",
+ "path": "plugins/woocommerce/includes/class-wc-eval-26.php",
+ "before": {
+ "code": "<?php\n/* Checkout on a new store: gateway settings have not been saved; object cache and notoptions start empty. */\nfunction wc_checkout_gateway_settings( $gateway_ids ) { $keys = array_map( static function( $id ) { return 'woocommerce_' . $id . '_settings'; }, $gateway_ids ); wp_prime_option_caches( $keys ); $settings = array(); foreach ( $keys as $key ) { $settings[$key] = get_option( $key, array() ); } return $settings; }\n"
+ },
+ "after": {
+ "code": "<?php\n/* Checkout on a new store: gateway settings have not been saved; object cache and notoptions start empty. */\nfunction wc_checkout_gateway_settings( $gateway_ids ) { $keys = array_map( static function( $id ) { return 'woocommerce_' . $id . '_settings'; }, $gateway_ids ); $settings = array(); foreach ( $keys as $key ) { $settings[$key] = get_option( $key, array() ); } return $settings; }\n"
+ },
+ "expect": {
+ "outcome": "finding",
+ "locations": [
+ {
+ "file": "plugins/woocommerce/includes/class-wc-eval-26.php",
+ "lines": [
+ 3,
+ 3
+ ],
+ "anchor": "function wc_checkout_gateway_settings( $gateway_ids ) { $keys = array_map( static function( $id ) { return 'woocommerce_' . $id . '_settings'; }, $gateway_ids ); $settings = array(); foreach ( $keys as $key ) { $settings[$key] = get_option( $key, array() ); } return $settings; }"
+ }
+ ]
+ }
+ }
+ ]
+}
diff --git a/.ai/review/evals/perf-cache-option-priming/fixtures/G26A/after.php b/.ai/review/evals/perf-cache-option-priming/fixtures/G26A/after.php
new file mode 100644
index 00000000000..2ce5031883f
--- /dev/null
+++ b/.ai/review/evals/perf-cache-option-priming/fixtures/G26A/after.php
@@ -0,0 +1,3 @@
+<?php
+/* Listing receives raw IDs from a fields=ids query with cold product caches. It renders names only. */
+function wc_render_listing_names( $ids ) { $names = array(); foreach ( $ids as $id ) { $p = wc_get_product( $id ); if ( $p ) { $names[] = $p->get_name(); } } return $names; }
diff --git a/.ai/review/evals/perf-cache-option-priming/fixtures/G26A/before.php b/.ai/review/evals/perf-cache-option-priming/fixtures/G26A/before.php
new file mode 100644
index 00000000000..0f8b1b6e553
--- /dev/null
+++ b/.ai/review/evals/perf-cache-option-priming/fixtures/G26A/before.php
@@ -0,0 +1,3 @@
+<?php
+/* Listing receives raw IDs from a fields=ids query with cold product caches. It renders names only. */
+function wc_render_listing_names( $ids ) { _prime_post_caches( $ids ); $names = array(); foreach ( $ids as $id ) { $p = wc_get_product( $id ); if ( $p ) { $names[] = $p->get_name(); } } return $names; }
diff --git a/.ai/review/evals/perf-cache-option-priming/fixtures/G26C-v2/after.php b/.ai/review/evals/perf-cache-option-priming/fixtures/G26C-v2/after.php
new file mode 100644
index 00000000000..4ae4ad4567d
--- /dev/null
+++ b/.ai/review/evals/perf-cache-option-priming/fixtures/G26C-v2/after.php
@@ -0,0 +1,13 @@
+<?php
+/* Currency options are autoloaded and immutable for the request. Amount is finite; decimals is a nonnegative integer. */
+final class WC_Checkout_Total_Label {
+ private function settings() {
+ return array( get_option( 'woocommerce_currency' ), get_option( 'woocommerce_price_num_decimals' ), get_option( 'woocommerce_currency_pos' ) );
+ }
+ public function format( $amount ) {
+ $settings = $this->settings();
+ $position = $settings[2];
+ $number = number_format( $amount, (int) $settings[1], '.', '' );
+ return 'right' === $position ? $number . ' ' . $settings[0] : $settings[0] . ' ' . $number;
+ }
+}
diff --git a/.ai/review/evals/perf-cache-option-priming/fixtures/G26C-v2/before.php b/.ai/review/evals/perf-cache-option-priming/fixtures/G26C-v2/before.php
new file mode 100644
index 00000000000..ad299b73bb7
--- /dev/null
+++ b/.ai/review/evals/perf-cache-option-priming/fixtures/G26C-v2/before.php
@@ -0,0 +1,13 @@
+<?php
+/* Currency options are autoloaded and immutable for the request. Amount is finite; decimals is a nonnegative integer. */
+final class WC_Checkout_Total_Label {
+ private function settings() {
+ return array( get_option( 'woocommerce_currency' ), get_option( 'woocommerce_price_num_decimals' ) );
+ }
+ public function format( $amount ) {
+ $settings = $this->settings();
+ $position = get_option( 'woocommerce_currency_pos' );
+ $number = number_format( $amount, (int) $settings[1], '.', '' );
+ return 'right' === $position ? $number . ' ' . $settings[0] : $settings[0] . ' ' . $number;
+ }
+}
diff --git a/.ai/review/evals/perf-cache-option-priming/fixtures/G26D-v2/after.php b/.ai/review/evals/perf-cache-option-priming/fixtures/G26D-v2/after.php
new file mode 100644
index 00000000000..0fb683df355
--- /dev/null
+++ b/.ai/review/evals/perf-cache-option-priming/fixtures/G26D-v2/after.php
@@ -0,0 +1,17 @@
+<?php
+/* Order line items are first loaded here through native WC_Order::get_items. The fixture’s product filter is shown. */
+add_filter( 'woocommerce_order_item_product', static function ( $product, $item ) {
+ if ( 103 === $item->get_id() ) { return array( 'legacy_reference' => true ); }
+ return $product;
+}, 10, 2 );
+final class WC_Order_Catalog_Names {
+ public function names( $order ) {
+ $items = $order->get_items();
+ $names = array();
+ foreach ( $items as $item ) {
+ $product = $item->get_product();
+ $names[] = $product instanceof WC_Product ? $product->get_name() : $item->get_name();
+ }
+ return $names;
+ }
+}
diff --git a/.ai/review/evals/perf-cache-option-priming/fixtures/G26D-v2/before.php b/.ai/review/evals/perf-cache-option-priming/fixtures/G26D-v2/before.php
new file mode 100644
index 00000000000..14066f5e859
--- /dev/null
+++ b/.ai/review/evals/perf-cache-option-priming/fixtures/G26D-v2/before.php
@@ -0,0 +1,22 @@
+<?php
+/* Order line items are first loaded here through native WC_Order::get_items. The fixture’s product filter is shown. */
+add_filter( 'woocommerce_order_item_product', static function ( $product, $item ) {
+ if ( 103 === $item->get_id() ) { return array( 'legacy_reference' => true ); }
+ return $product;
+}, 10, 2 );
+final class WC_Order_Catalog_Names {
+ public function names( $order ) {
+ $items = $order->get_items();
+ $ids = array();
+ foreach ( $items as $item ) {
+ $ids[] = $item->get_variation_id() ?: $item->get_product_id();
+ }
+ _prime_post_caches( array_unique( array_filter( $ids ) ) );
+ $names = array();
+ foreach ( $items as $item ) {
+ $product = $item->get_product();
+ $names[] = $product instanceof WC_Product ? $product->get_name() : $item->get_name();
+ }
+ return $names;
+ }
+}
diff --git a/.ai/review/evals/perf-hot-path-eager-work/cases.json b/.ai/review/evals/perf-hot-path-eager-work/cases.json
new file mode 100644
index 00000000000..6cd15de2b7b
--- /dev/null
+++ b/.ai/review/evals/perf-hot-path-eager-work/cases.json
@@ -0,0 +1,77 @@
+{
+ "version": 1,
+ "cases": [
+ {
+ "id": "G27A-v4",
+ "path": "plugins/woocommerce/includes/class-wc-eval-27.php",
+ "before": {
+ "file": "fixtures/G27A/before.php"
+ },
+ "after": {
+ "file": "fixtures/G27A/after.php"
+ },
+ "expect": {
+ "outcome": "finding",
+ "locations": [
+ {
+ "file": "plugins/woocommerce/includes/class-wc-eval-27.php",
+ "lines": [
+ 4,
+ 4
+ ],
+ "anchor": " $ids = get_posts( array( 'post_type' => 'product', 'post_status' => 'publish', 'numberposts' => -1, 'fields' => 'ids' ) );"
+ }
+ ]
+ }
+ },
+ {
+ "id": "G27B-v4",
+ "path": "plugins/woocommerce/includes/class-wc-eval-27.php",
+ "before": {
+ "file": "fixtures/G27B/before.php"
+ },
+ "after": {
+ "file": "fixtures/G27B/after.php"
+ },
+ "expect": {
+ "outcome": "finding",
+ "locations": [
+ {
+ "file": "plugins/woocommerce/includes/class-wc-eval-27.php",
+ "lines": [
+ 4,
+ 5
+ ],
+ "anchor": " if ( ! is_checkout() ) { return; }"
+ }
+ ]
+ }
+ },
+ {
+ "id": "G27C-v4",
+ "path": "plugins/woocommerce/includes/class-wc-eval-27.php",
+ "before": {
+ "code": "<?php\n/* Authenticated asset-inventory admin screen. The shipped asset directories contain release files; renderer accepts CSS and JS paths. */\nfunction wc_asset_inventory_screen() { $files = glob( WC_ABSPATH . 'assets/css/*.css' ) ?: array(); render_inventory_table( $files ); } add_action( 'load-woocommerce_page_asset-inventory', 'wc_asset_inventory_screen' );\n"
+ },
+ "after": {
+ "code": "<?php\n/* Authenticated asset-inventory admin screen. The shipped asset directories contain release files; renderer accepts CSS and JS paths. */\nfunction wc_asset_inventory_screen() { $files = glob( WC_ABSPATH . 'assets/css/*.css' ) ?: array(); $files = array_merge( $files, glob( WC_ABSPATH . 'assets/js/*.js' ) ?: array() ); render_inventory_table( $files ); } add_action( 'load-woocommerce_page_asset-inventory', 'wc_asset_inventory_screen' );\n"
+ },
+ "expect": {
+ "outcome": "quiet"
+ }
+ },
+ {
+ "id": "G27D-v4",
+ "path": "plugins/woocommerce/includes/class-wc-eval-27.php",
+ "before": {
+ "code": "<?php\nfinal class WC_Eval_Mailer_Service {\n private $emails = null;\n public function get_emails() {\n if ( null === $this->emails ) { $this->emails = WC()->mailer()->get_emails(); }\n return $this->emails;\n }\n}\nadd_action( 'init', static function() { $GLOBALS['wc_eval_mailer'] = new WC_Eval_Mailer_Service(); } );\n"
+ },
+ "after": {
+ "code": "<?php\nfinal class WC_Eval_Mailer_Service {\n private $load;\n private $emails = null;\n public function __construct() {\n $this->load = 'load_emails';\n }\n private function load_emails() {\n if ( null === $this->emails ) { $this->emails = WC()->mailer()->get_emails(); }\n return $this->emails;\n }\n public function get_emails() { return $this->{$this->load}(); }\n}\nadd_action( 'init', static function() { $GLOBALS['wc_eval_mailer'] = new WC_Eval_Mailer_Service(); } );\n"
+ },
+ "expect": {
+ "outcome": "quiet"
+ }
+ }
+ ]
+}
diff --git a/.ai/review/evals/perf-hot-path-eager-work/fixtures/G27A/after.php b/.ai/review/evals/perf-hot-path-eager-work/fixtures/G27A/after.php
new file mode 100644
index 00000000000..d262f4ed4cc
--- /dev/null
+++ b/.ai/review/evals/perf-hot-path-eager-work/fixtures/G27A/after.php
@@ -0,0 +1,9 @@
+<?php
+/* Catalog index for the current request. */
+function wc_optional_catalog_index() {
+ $ids = get_posts( array( 'post_type' => 'product', 'post_status' => 'publish', 'numberposts' => -1, 'fields' => 'ids' ) );
+ store_request_index( $ids );
+}
+add_action( 'wp', 'wc_optional_catalog_index' );
+
+function store_request_index( $ids ) { $GLOBALS['wc_request_catalog_index'] = $ids; }
diff --git a/.ai/review/evals/perf-hot-path-eager-work/fixtures/G27A/before.php b/.ai/review/evals/perf-hot-path-eager-work/fixtures/G27A/before.php
new file mode 100644
index 00000000000..ce4aeb8811b
--- /dev/null
+++ b/.ai/review/evals/perf-hot-path-eager-work/fixtures/G27A/before.php
@@ -0,0 +1,10 @@
+<?php
+/* Catalog index for the current request. */
+function wc_optional_catalog_index() {
+ if ( ! is_shop() ) { return; }
+ $ids = get_posts( array( 'post_type' => 'product', 'post_status' => 'publish', 'numberposts' => -1, 'fields' => 'ids' ) );
+ store_request_index( $ids );
+}
+add_action( 'wp', 'wc_optional_catalog_index' );
+
+function store_request_index( $ids ) { $GLOBALS['wc_request_catalog_index'] = $ids; }
diff --git a/.ai/review/evals/perf-hot-path-eager-work/fixtures/G27B/after.php b/.ai/review/evals/perf-hot-path-eager-work/fixtures/G27B/after.php
new file mode 100644
index 00000000000..4b38d607604
--- /dev/null
+++ b/.ai/review/evals/perf-hot-path-eager-work/fixtures/G27B/after.php
@@ -0,0 +1,10 @@
+<?php
+/* Checkout inventory diagnostics. */
+function wc_checkout_inventory_snapshot() {
+ if ( ! is_checkout() ) { return; }
+ $products = wc_get_products( array( 'status' => 'publish', 'limit' => -1 ) );
+ foreach ( $products as $product ) { wc_get_logger()->debug( $product->get_id() . ':' . $product->get_stock_quantity() ); }
+}
+add_action( 'wp', 'wc_checkout_inventory_snapshot' );
+
+function wc_inventory_debug_enabled() { return 'yes' === get_option( 'woocommerce_inventory_debug', 'no' ); }
diff --git a/.ai/review/evals/perf-hot-path-eager-work/fixtures/G27B/before.php b/.ai/review/evals/perf-hot-path-eager-work/fixtures/G27B/before.php
new file mode 100644
index 00000000000..93b7a4f4d7b
--- /dev/null
+++ b/.ai/review/evals/perf-hot-path-eager-work/fixtures/G27B/before.php
@@ -0,0 +1,10 @@
+<?php
+/* Checkout inventory diagnostics. */
+function wc_checkout_inventory_snapshot() {
+ if ( ! is_checkout() || ! wc_inventory_debug_enabled() ) { return; }
+ $products = wc_get_products( array( 'status' => 'publish', 'limit' => -1 ) );
+ foreach ( $products as $product ) { wc_get_logger()->debug( $product->get_id() . ':' . $product->get_stock_quantity() ); }
+}
+add_action( 'wp', 'wc_checkout_inventory_snapshot' );
+
+function wc_inventory_debug_enabled() { return 'yes' === get_option( 'woocommerce_inventory_debug', 'no' ); }
diff --git a/.ai/review/evals/perf-refactor-semantic-drift/cases.json b/.ai/review/evals/perf-refactor-semantic-drift/cases.json
new file mode 100644
index 00000000000..b91787e0904
--- /dev/null
+++ b/.ai/review/evals/perf-refactor-semantic-drift/cases.json
@@ -0,0 +1,77 @@
+{
+ "version": 1,
+ "cases": [
+ {
+ "id": "G23C-v3",
+ "path": "plugins/woocommerce/includes/class-wc-eval-23.php",
+ "before": {
+ "file": "fixtures/G23C/before.php"
+ },
+ "after": {
+ "file": "fixtures/G23C/after.php"
+ },
+ "expect": {
+ "outcome": "quiet"
+ }
+ },
+ {
+ "id": "G23D-v3",
+ "path": "plugins/woocommerce/includes/class-wc-eval-23.php",
+ "before": {
+ "file": "fixtures/G23D/before.php"
+ },
+ "after": {
+ "file": "fixtures/G23D/after.php"
+ },
+ "expect": {
+ "outcome": "quiet"
+ }
+ },
+ {
+ "id": "G23A-v6",
+ "path": "plugins/woocommerce/includes/class-wc-eval-23.php",
+ "before": {
+ "code": "<?php\n/* Variation children are ordered. Caller has already primed their posts. */\nfunction wc_first_buyable_child( $parent ) {\n foreach ( $parent->get_children() as $id ) {\n $child = wc_get_product( $id );\n if ( $child && $child->is_purchasable() && $child->is_in_stock() ) { return $id; }\n }\n return 0;\n}\n"
+ },
+ "after": {
+ "code": "<?php\n/* Variation children are ordered. Caller has already primed their posts. */\nfunction wc_first_buyable_child( $parent ) {\n foreach ( array_slice( $parent->get_children(), 0, 1 ) as $id ) {\n $child = wc_get_product( $id );\n if ( $child && $child->is_purchasable() && $child->is_in_stock() ) { return $id; }\n }\n return 0;\n}\n"
+ },
+ "expect": {
+ "outcome": "finding",
+ "locations": [
+ {
+ "file": "plugins/woocommerce/includes/class-wc-eval-23.php",
+ "lines": [
+ 4,
+ 4
+ ],
+ "anchor": " foreach ( array_slice( $parent->get_children(), 0, 1 ) as $id ) {"
+ }
+ ]
+ }
+ },
+ {
+ "id": "G23B-v4",
+ "path": "plugins/woocommerce/includes/class-wc-eval-23.php",
+ "before": {
+ "code": "<?php\n/* Catalog visibility extension callbacks are registered before rendering. */\nfunction wc_first_visible_id( $ids ) {\n $visible = apply_filters( 'woocommerce_visible_candidate_ids', $ids );\n return array_slice( $visible, 0, 1 );\n}\n\nadd_filter( 'woocommerce_visible_candidate_ids', static function( $ids ) { return array_values( array_diff( $ids, array( 10 ) ) ); } );\n"
+ },
+ "after": {
+ "code": "<?php\n/* Catalog visibility extension callbacks are registered before rendering. */\nfunction wc_first_visible_id( $ids ) { $ids = array_slice( $ids, 0, 1 ); return array_slice( apply_filters( 'woocommerce_visible_candidate_ids', $ids ), 0, 1 ); }\n\nadd_filter( 'woocommerce_visible_candidate_ids', static function( $ids ) { return array_values( array_diff( $ids, array( 10 ) ) ); } );\n"
+ },
+ "expect": {
+ "outcome": "finding",
+ "locations": [
+ {
+ "file": "plugins/woocommerce/includes/class-wc-eval-23.php",
+ "lines": [
+ 3,
+ 3
+ ],
+ "anchor": "function wc_first_visible_id( $ids ) { $ids = array_slice( $ids, 0, 1 ); return array_slice( apply_filters( 'woocommerce_visible_candidate_ids', $ids ), 0, 1 ); }"
+ }
+ ]
+ }
+ }
+ ]
+}
diff --git a/.ai/review/evals/perf-refactor-semantic-drift/fixtures/G23C/after.php b/.ai/review/evals/perf-refactor-semantic-drift/fixtures/G23C/after.php
new file mode 100644
index 00000000000..bb0f92c1a04
--- /dev/null
+++ b/.ai/review/evals/perf-refactor-semantic-drift/fixtures/G23C/after.php
@@ -0,0 +1,10 @@
+<?php
+function wc_listing_names( $ids ) {
+ _prime_post_caches( $ids );
+ $names = array();
+ foreach ( $ids as $id ) {
+ $product = wc_get_product( $id );
+ if ( $product ) { $names[$id] = $product->get_name(); }
+ }
+ return $names;
+}
diff --git a/.ai/review/evals/perf-refactor-semantic-drift/fixtures/G23C/before.php b/.ai/review/evals/perf-refactor-semantic-drift/fixtures/G23C/before.php
new file mode 100644
index 00000000000..b603b0722e6
--- /dev/null
+++ b/.ai/review/evals/perf-refactor-semantic-drift/fixtures/G23C/before.php
@@ -0,0 +1,9 @@
+<?php
+function wc_listing_names( $ids ) {
+ $names = array();
+ foreach ( $ids as $id ) {
+ $product = wc_get_product( $id );
+ if ( $product ) { $names[$id] = $product->get_name(); }
+ }
+ return $names;
+}
diff --git a/.ai/review/evals/perf-refactor-semantic-drift/fixtures/G23D/after.php b/.ai/review/evals/perf-refactor-semantic-drift/fixtures/G23D/after.php
new file mode 100644
index 00000000000..ab38e2a0acc
--- /dev/null
+++ b/.ai/review/evals/perf-refactor-semantic-drift/fixtures/G23D/after.php
@@ -0,0 +1,3 @@
+<?php
+/* Private selector receives the numerically indexed result of the caller’s eligibility filtering. */
+function wc_first_ready_child( $ready_ids ) { return array_slice( $ready_ids, 0, 1 ); }
diff --git a/.ai/review/evals/perf-refactor-semantic-drift/fixtures/G23D/before.php b/.ai/review/evals/perf-refactor-semantic-drift/fixtures/G23D/before.php
new file mode 100644
index 00000000000..0584fdbfbe1
--- /dev/null
+++ b/.ai/review/evals/perf-refactor-semantic-drift/fixtures/G23D/before.php
@@ -0,0 +1,3 @@
+<?php
+/* Private selector receives the numerically indexed result of the caller’s eligibility filtering. */
+function wc_first_ready_child( $ready_ids ) { $first = array(); foreach ( $ready_ids as $id ) { $first[] = $id; break; } return $first; }
diff --git a/.ai/review/evals/perf-sql-query-shape/cases.json b/.ai/review/evals/perf-sql-query-shape/cases.json
new file mode 100644
index 00000000000..331d31d9d94
--- /dev/null
+++ b/.ai/review/evals/perf-sql-query-shape/cases.json
@@ -0,0 +1,90 @@
+{
+ "version": 1,
+ "cases": [
+ {
+ "id": "G25A-v4",
+ "path": "plugins/woocommerce/includes/class-wc-eval-25.php",
+ "before": {
+ "file": "fixtures/G25A/before.php"
+ },
+ "after": {
+ "file": "fixtures/G25A/after.php"
+ },
+ "expect": {
+ "outcome": "finding",
+ "locations": [
+ {
+ "file": "plugins/woocommerce/includes/class-wc-eval-25.php",
+ "lines": [
+ 6,
+ 7
+ ],
+ "anchor": " foreach ( array_map( 'absint', $ids ) as $id ) {"
+ }
+ ]
+ }
+ },
+ {
+ "id": "G25B-v3",
+ "path": "plugins/woocommerce/includes/class-wc-eval-25.php",
+ "before": {
+ "file": "fixtures/G25B/before.php"
+ },
+ "after": {
+ "file": "fixtures/G25B/after.php"
+ },
+ "expect": {
+ "outcome": "finding",
+ "locations": [
+ {
+ "file": "plugins/woocommerce/includes/class-wc-eval-25.php",
+ "lines": [
+ 3,
+ 3
+ ],
+ "anchor": "function wc_listing_published_stock( $ids ) { global $wpdb; $ids = array_map( 'absint', $ids ); if ( ! $ids ) { return array(); } return $wpdb->get_col( \"SELECT l.stock_quantity FROM {$wpdb->prefix}wc_product_meta_lookup l WHERE l.product_id IN (\" . implode( ',', $ids ) . ')' ); }"
+ }
+ ]
+ }
+ },
+ {
+ "id": "G25D-v3",
+ "path": "plugins/woocommerce/includes/class-wc-eval-25.php",
+ "before": {
+ "file": "fixtures/G25D/before.php"
+ },
+ "after": {
+ "file": "fixtures/G25D/after.php"
+ },
+ "expect": {
+ "outcome": "quiet"
+ }
+ },
+ {
+ "id": "G25C-v8",
+ "path": "plugins/woocommerce/includes/class-wc-eval-25.php",
+ "before": {
+ "code": "<?php\nfunction wc_listing_ids( $ids ) {\n return array_map( 'absint', $ids );\n}\n\n/* Product stock report returns a map keyed by ID. Map iteration order is not specified. */\nfunction wc_listing_totals( $ids ) {\n global $wpdb;\n $out = array();\n foreach ( wc_listing_ids( $ids ) as $id ) {\n $row = $wpdb->get_row( $wpdb->prepare( \"SELECT product_id, stock_quantity FROM {$wpdb->prefix}wc_product_meta_lookup WHERE product_id = %d\", $id ), ARRAY_A );\n if ( $row ) { $out[$row['product_id']] = $row['stock_quantity']; }\n }\n return $out;\n}\n"
+ },
+ "after": {
+ "code": "<?php\nfunction wc_listing_ids( $ids ) {\n $normalized_ids = array_map( 'absint', $ids );\n return $normalized_ids;\n}\n\n/* Product stock report returns a map keyed by ID. Map iteration order is not specified. */\nfunction wc_listing_totals( $ids ) {\n global $wpdb;\n $out = array();\n foreach ( wc_listing_ids( $ids ) as $id ) {\n $row = $wpdb->get_row( $wpdb->prepare( \"SELECT product_id, stock_quantity FROM {$wpdb->prefix}wc_product_meta_lookup WHERE product_id = %d\", $id ), ARRAY_A );\n if ( $row ) { $out[$row['product_id']] = $row['stock_quantity']; }\n }\n return $out;\n}\n"
+ },
+ "expect": {
+ "outcome": "quiet"
+ }
+ },
+ {
+ "id": "G25E-v1",
+ "path": "plugins/woocommerce/includes/class-wc-eval-25.php",
+ "before": {
+ "file": "fixtures/G25E/before.php"
+ },
+ "after": {
+ "file": "fixtures/G25E/after.php"
+ },
+ "expect": {
+ "outcome": "quiet"
+ }
+ }
+ ]
+}
diff --git a/.ai/review/evals/perf-sql-query-shape/fixtures/G25A/after.php b/.ai/review/evals/perf-sql-query-shape/fixtures/G25A/after.php
new file mode 100644
index 00000000000..5025ca33e00
--- /dev/null
+++ b/.ai/review/evals/perf-sql-query-shape/fixtures/G25A/after.php
@@ -0,0 +1,11 @@
+<?php
+/* Product-list stock renderer. IDs are unique product IDs; page size can be changed by extensions. */
+function wc_listing_totals( $ids ) {
+ global $wpdb;
+ $out = array();
+ foreach ( array_map( 'absint', $ids ) as $id ) {
+ $row = $wpdb->get_row( $wpdb->prepare( "SELECT product_id, stock_quantity FROM {$wpdb->prefix}wc_product_meta_lookup WHERE product_id = %d", $id ), ARRAY_A );
+ if ( $row ) { $out[$row['product_id']] = $row['stock_quantity']; }
+ }
+ return $out;
+}
diff --git a/.ai/review/evals/perf-sql-query-shape/fixtures/G25A/before.php b/.ai/review/evals/perf-sql-query-shape/fixtures/G25A/before.php
new file mode 100644
index 00000000000..4104eea5039
--- /dev/null
+++ b/.ai/review/evals/perf-sql-query-shape/fixtures/G25A/before.php
@@ -0,0 +1,9 @@
+<?php
+/* Product-list stock renderer. IDs are unique product IDs; page size can be changed by extensions. */
+function wc_listing_totals( $ids ) {
+ global $wpdb;
+ $ids = array_map( 'absint', $ids );
+ if ( ! $ids ) { return array(); }
+ $rows = $wpdb->get_results( "SELECT product_id, stock_quantity FROM {$wpdb->prefix}wc_product_meta_lookup WHERE product_id IN (" . implode( ',', $ids ) . ')', ARRAY_A );
+ return array_column( $rows, 'stock_quantity', 'product_id' );
+}
diff --git a/.ai/review/evals/perf-sql-query-shape/fixtures/G25B/after.php b/.ai/review/evals/perf-sql-query-shape/fixtures/G25B/after.php
new file mode 100644
index 00000000000..8a8b5ae8f18
--- /dev/null
+++ b/.ai/review/evals/perf-sql-query-shape/fixtures/G25B/after.php
@@ -0,0 +1,3 @@
+<?php
+/* Product stock report receives IDs from a post_status=any query. */
+function wc_listing_published_stock( $ids ) { global $wpdb; $ids = array_map( 'absint', $ids ); if ( ! $ids ) { return array(); } return $wpdb->get_col( "SELECT l.stock_quantity FROM {$wpdb->prefix}wc_product_meta_lookup l WHERE l.product_id IN (" . implode( ',', $ids ) . ')' ); }
diff --git a/.ai/review/evals/perf-sql-query-shape/fixtures/G25B/before.php b/.ai/review/evals/perf-sql-query-shape/fixtures/G25B/before.php
new file mode 100644
index 00000000000..c3cb44acfa6
--- /dev/null
+++ b/.ai/review/evals/perf-sql-query-shape/fixtures/G25B/before.php
@@ -0,0 +1,3 @@
+<?php
+/* Product stock report receives IDs from a post_status=any query. */
+function wc_listing_published_stock( $ids ) { global $wpdb; $ids = array_map( 'absint', $ids ); if ( ! $ids ) { return array(); } return $wpdb->get_col( "SELECT l.stock_quantity FROM {$wpdb->prefix}wc_product_meta_lookup l INNER JOIN {$wpdb->posts} p ON p.ID=l.product_id WHERE p.post_type='product' AND p.post_status='publish' AND l.product_id IN (" . implode( ',', $ids ) . ')' ); }
diff --git a/.ai/review/evals/perf-sql-query-shape/fixtures/G25D/after.php b/.ai/review/evals/perf-sql-query-shape/fixtures/G25D/after.php
new file mode 100644
index 00000000000..0bf6a0e0883
--- /dev/null
+++ b/.ai/review/evals/perf-sql-query-shape/fixtures/G25D/after.php
@@ -0,0 +1,3 @@
+<?php
+/* Product stock report receives IDs from a post_status=any query. */
+function wc_published_stock( $ids ) { global $wpdb; $ids = array_map( 'absint', $ids ); if ( ! $ids ) { return array(); } return $wpdb->get_col( "SELECT l.stock_quantity FROM {$wpdb->prefix}wc_product_meta_lookup l WHERE EXISTS (SELECT 1 FROM {$wpdb->posts} p WHERE p.ID=l.product_id AND p.post_type='product' AND p.post_status='publish') AND l.product_id IN (" . implode( ',', $ids ) . ')' ); }
diff --git a/.ai/review/evals/perf-sql-query-shape/fixtures/G25D/before.php b/.ai/review/evals/perf-sql-query-shape/fixtures/G25D/before.php
new file mode 100644
index 00000000000..26d844bf382
--- /dev/null
+++ b/.ai/review/evals/perf-sql-query-shape/fixtures/G25D/before.php
@@ -0,0 +1,3 @@
+<?php
+/* Product stock report receives IDs from a post_status=any query. */
+function wc_published_stock( $ids ) { global $wpdb; $ids = array_map( 'absint', $ids ); if ( ! $ids ) { return array(); } return $wpdb->get_col( "SELECT l.stock_quantity FROM {$wpdb->prefix}wc_product_meta_lookup l INNER JOIN {$wpdb->posts} p ON p.ID=l.product_id WHERE p.post_type='product' AND p.post_status='publish' AND l.product_id IN (" . implode( ',', $ids ) . ')' ); }
diff --git a/.ai/review/evals/perf-sql-query-shape/fixtures/G25E/after.php b/.ai/review/evals/perf-sql-query-shape/fixtures/G25E/after.php
new file mode 100644
index 00000000000..ce47f2c7900
--- /dev/null
+++ b/.ai/review/evals/perf-sql-query-shape/fixtures/G25E/after.php
@@ -0,0 +1,9 @@
+<?php
+/* This report receives ordinary product IDs in the non-negative PHP integer range. Map iteration order is not specified. */
+function wc_listing_totals( $ids ) {
+ global $wpdb;
+ $ids = array_map( 'absint', $ids );
+ if ( ! $ids ) { return array(); }
+ $rows = $wpdb->get_results( "SELECT product_id, stock_quantity FROM {$wpdb->prefix}wc_product_meta_lookup WHERE product_id IN (" . implode( ',', $ids ) . ')', ARRAY_A );
+ return array_column( $rows, 'stock_quantity', 'product_id' );
+}
diff --git a/.ai/review/evals/perf-sql-query-shape/fixtures/G25E/before.php b/.ai/review/evals/perf-sql-query-shape/fixtures/G25E/before.php
new file mode 100644
index 00000000000..f9b63adddc2
--- /dev/null
+++ b/.ai/review/evals/perf-sql-query-shape/fixtures/G25E/before.php
@@ -0,0 +1,3 @@
+<?php
+/* This report receives ordinary product IDs in the non-negative PHP integer range. Map iteration order is not specified. */
+function wc_listing_totals( $ids ) { global $wpdb; $out = array(); foreach ( array_map( 'absint', $ids ) as $id ) { $row = $wpdb->get_row( $wpdb->prepare( "SELECT product_id, stock_quantity FROM {$wpdb->prefix}wc_product_meta_lookup WHERE product_id = %d", $id ), ARRAY_A ); if ( $row ) { $out[$row['product_id']] = $row['stock_quantity']; } } return $out; }
diff --git a/.ai/review/evals/php-loose-type-juggling/cases.json b/.ai/review/evals/php-loose-type-juggling/cases.json
new file mode 100644
index 00000000000..61f5c70dde3
--- /dev/null
+++ b/.ai/review/evals/php-loose-type-juggling/cases.json
@@ -0,0 +1,77 @@
+{
+ "version": 1,
+ "cases": [
+ {
+ "id": "G03A-v3",
+ "path": "plugins/woocommerce/includes/class-wc-php-loose-type-juggling.php",
+ "before": {
+ "file": "fixtures/G03A/before.php"
+ },
+ "after": {
+ "file": "fixtures/G03A/after.php"
+ },
+ "expect": {
+ "outcome": "finding",
+ "locations": [
+ {
+ "file": "plugins/woocommerce/includes/class-wc-php-loose-type-juggling.php",
+ "lines": [
+ 5,
+ 5
+ ],
+ "anchor": " return array_map( static function ( $key ) { return wc_status_label( $key ); }, array_keys( $statuses ) );"
+ }
+ ]
+ }
+ },
+ {
+ "id": "G03B-v3",
+ "path": "plugins/woocommerce/includes/class-wc-php-loose-type-juggling.php",
+ "before": {
+ "file": "fixtures/G03B/before.php"
+ },
+ "after": {
+ "file": "fixtures/G03B/after.php"
+ },
+ "expect": {
+ "outcome": "finding",
+ "locations": [
+ {
+ "file": "plugins/woocommerce/includes/class-wc-php-loose-type-juggling.php",
+ "lines": [
+ 3,
+ 4
+ ],
+ "anchor": " $per_page = absint( $per_page );"
+ }
+ ]
+ }
+ },
+ {
+ "id": "G03C-v3",
+ "path": "plugins/woocommerce/includes/class-wc-php-loose-type-juggling.php",
+ "before": {
+ "file": "fixtures/G03C/before.php"
+ },
+ "after": {
+ "file": "fixtures/G03C/after.php"
+ },
+ "expect": {
+ "outcome": "quiet"
+ }
+ },
+ {
+ "id": "G03D-v3",
+ "path": "plugins/woocommerce/includes/class-wc-php-loose-type-juggling.php",
+ "before": {
+ "file": "fixtures/G03D/before.php"
+ },
+ "after": {
+ "file": "fixtures/G03D/after.php"
+ },
+ "expect": {
+ "outcome": "quiet"
+ }
+ }
+ ]
+}
diff --git a/.ai/review/evals/php-loose-type-juggling/fixtures/G03A/after.php b/.ai/review/evals/php-loose-type-juggling/fixtures/G03A/after.php
new file mode 100644
index 00000000000..0c3c111b59b
--- /dev/null
+++ b/.ai/review/evals/php-loose-type-juggling/fixtures/G03A/after.php
@@ -0,0 +1,7 @@
+<?php
+declare(strict_types=1);
+function wc_status_label( string $status ): string { return 'Status: ' . $status; }
+function wc_status_labels( array $statuses ): array {
+ return array_map( static function ( $key ) { return wc_status_label( $key ); }, array_keys( $statuses ) );
+}
+$labels = wc_status_labels( array( '42' => 'Imported', 'pending' => 'Pending' ) );
diff --git a/.ai/review/evals/php-loose-type-juggling/fixtures/G03A/before.php b/.ai/review/evals/php-loose-type-juggling/fixtures/G03A/before.php
new file mode 100644
index 00000000000..fa25dc8f8f4
--- /dev/null
+++ b/.ai/review/evals/php-loose-type-juggling/fixtures/G03A/before.php
@@ -0,0 +1,7 @@
+<?php
+declare(strict_types=1);
+function wc_status_label( string $status ): string { return 'Status: ' . $status; }
+function wc_status_labels( array $statuses ): array {
+ return array_map( static function ( $key ) { return wc_status_label( (string) $key ); }, array_keys( $statuses ) );
+}
+$labels = wc_status_labels( array( '42' => 'Imported', 'pending' => 'Pending' ) );
diff --git a/.ai/review/evals/php-loose-type-juggling/fixtures/G03B/after.php b/.ai/review/evals/php-loose-type-juggling/fixtures/G03B/after.php
new file mode 100644
index 00000000000..54423f97050
--- /dev/null
+++ b/.ai/review/evals/php-loose-type-juggling/fixtures/G03B/after.php
@@ -0,0 +1,5 @@
+<?php
+function wc_admin_page_count( $total, $per_page ) {
+ $per_page = absint( $per_page );
+ return (int) ceil( $total / $per_page );
+}
diff --git a/.ai/review/evals/php-loose-type-juggling/fixtures/G03B/before.php b/.ai/review/evals/php-loose-type-juggling/fixtures/G03B/before.php
new file mode 100644
index 00000000000..31babc3bd91
--- /dev/null
+++ b/.ai/review/evals/php-loose-type-juggling/fixtures/G03B/before.php
@@ -0,0 +1,5 @@
+<?php
+function wc_admin_page_count( $total, $per_page ) {
+ $per_page = max( 1, absint( $per_page ) );
+ return (int) ceil( $total / $per_page );
+}
diff --git a/.ai/review/evals/php-loose-type-juggling/fixtures/G03C/after.php b/.ai/review/evals/php-loose-type-juggling/fixtures/G03C/after.php
new file mode 100644
index 00000000000..d1517a7443f
--- /dev/null
+++ b/.ai/review/evals/php-loose-type-juggling/fixtures/G03C/after.php
@@ -0,0 +1,7 @@
+<?php
+declare(strict_types=1);
+function wc_status_label( string $status ): string { return 'Status: ' . $status; }
+function wc_status_labels( array $statuses ): array {
+ return array_map( 'wc_status_label', array_keys( $statuses ) );
+}
+$labels = wc_status_labels( array( '42' => 'Imported', 'pending' => 'Pending' ) );
diff --git a/.ai/review/evals/php-loose-type-juggling/fixtures/G03C/before.php b/.ai/review/evals/php-loose-type-juggling/fixtures/G03C/before.php
new file mode 100644
index 00000000000..fa25dc8f8f4
--- /dev/null
+++ b/.ai/review/evals/php-loose-type-juggling/fixtures/G03C/before.php
@@ -0,0 +1,7 @@
+<?php
+declare(strict_types=1);
+function wc_status_label( string $status ): string { return 'Status: ' . $status; }
+function wc_status_labels( array $statuses ): array {
+ return array_map( static function ( $key ) { return wc_status_label( (string) $key ); }, array_keys( $statuses ) );
+}
+$labels = wc_status_labels( array( '42' => 'Imported', 'pending' => 'Pending' ) );
diff --git a/.ai/review/evals/php-loose-type-juggling/fixtures/G03D/after.php b/.ai/review/evals/php-loose-type-juggling/fixtures/G03D/after.php
new file mode 100644
index 00000000000..086408c5f04
--- /dev/null
+++ b/.ai/review/evals/php-loose-type-juggling/fixtures/G03D/after.php
@@ -0,0 +1,5 @@
+<?php
+class WC_Status_Text {
+ private function prefix( string $status ): string { return 'Status: ' . $status; }
+ public function pending_label() { return $this->prefix( 'pending' ); }
+}
diff --git a/.ai/review/evals/php-loose-type-juggling/fixtures/G03D/before.php b/.ai/review/evals/php-loose-type-juggling/fixtures/G03D/before.php
new file mode 100644
index 00000000000..d1566e0bdcc
--- /dev/null
+++ b/.ai/review/evals/php-loose-type-juggling/fixtures/G03D/before.php
@@ -0,0 +1,5 @@
+<?php
+class WC_Status_Text {
+ private function prefix( $status ) { return 'Status: ' . $status; }
+ public function pending_label() { return $this->prefix( 'pending' ); }
+}
diff --git a/.ai/review/evals/proxy-predicate-inference/cases.json b/.ai/review/evals/proxy-predicate-inference/cases.json
new file mode 100644
index 00000000000..e658d9ad313
--- /dev/null
+++ b/.ai/review/evals/proxy-predicate-inference/cases.json
@@ -0,0 +1,77 @@
+{
+ "version": 1,
+ "cases": [
+ {
+ "id": "G12A-v4",
+ "path": "plugins/woocommerce/includes/address-editor.php",
+ "before": {
+ "file": "fixtures/G12A/before.php"
+ },
+ "after": {
+ "file": "fixtures/G12A/after.php"
+ },
+ "expect": {
+ "outcome": "finding",
+ "locations": [
+ {
+ "file": "plugins/woocommerce/includes/address-editor.php",
+ "lines": [
+ 4,
+ 4
+ ],
+ "anchor": " return $order->get_address( 'billing' ) === $order->get_address( 'shipping' ) ? 'linked' : 'independent';"
+ }
+ ]
+ }
+ },
+ {
+ "id": "G12B-v3",
+ "path": "plugins/woocommerce/includes/receipt-audience.php",
+ "before": {
+ "file": "fixtures/G12B/before.php"
+ },
+ "after": {
+ "file": "fixtures/G12B/after.php"
+ },
+ "expect": {
+ "outcome": "finding",
+ "locations": [
+ {
+ "file": "plugins/woocommerce/includes/receipt-audience.php",
+ "lines": [
+ 4,
+ 4
+ ],
+ "anchor": " return ! $order->get_customer_id() ? 'guest' : 'account';"
+ }
+ ]
+ }
+ },
+ {
+ "id": "G12C-v3",
+ "path": "plugins/woocommerce/includes/address-editor.php",
+ "before": {
+ "file": "fixtures/G12C/before.php"
+ },
+ "after": {
+ "file": "fixtures/G12C/after.php"
+ },
+ "expect": {
+ "outcome": "quiet"
+ }
+ },
+ {
+ "id": "G12D-v8",
+ "path": "plugins/woocommerce/includes/address-editor.php",
+ "before": {
+ "code": "<?php\nfinal class Order_Address_Editor {\n public function preview( WC_Order $order, array $shipping ) {\n $edited = clone $order;\n foreach ( $shipping as $key => $value ) {\n $setter = 'set_shipping_' . $key;\n if ( is_callable( array( $edited, $setter ) ) ) {\n $edited->{$setter}( $value );\n }\n }\n return $this->review_notice( $order, $edited );\n }\n\n private function review_notice( WC_Order $before, WC_Order $after ) {\n $notice = 'yes' === $after->get_meta( '_copy_billing_to_shipping' )\n ? __( 'Billing and shipping edits are linked.', 'woocommerce' )\n : __( 'Billing and shipping edits are independent.', 'woocommerce' );\n return array( 'notice' => $notice, 'shipping_changed' => false );\n }\n}\n\n// Colocated review-fixture tests activate only under an already loaded WC unit-test bootstrap.\nif ( class_exists( 'WC_Unit_Test_Case', false ) ) {\n\n/** Regression coverage for previewed shipping changes. */\nclass OrderAddressEditorTest extends WC_Unit_Test_Case {\n public function test_linked_unchanged() { $this->assert_preview( 'yes', false ); }\n public function test_linked_changed() { $this->assert_preview( 'yes', true ); }\n public function test_independent_unchanged() { $this->assert_preview( 'no', false ); }\n public function test_independent_changed() { $this->assert_preview( 'no', true ); }\n\n private function assert_preview( $linked, $changed ) {\n $order = new WC_Order();\n $order->set_shipping_city( 'Colombo' );\n $order->set_shipping_postcode( '00100' );\n $order->update_meta_data( '_copy_billing_to_shipping', $linked );\n $original = $order->get_address( 'shipping' );\n $requested = $original;\n if ( $changed ) { $requested['postcode'] = '20000'; }\n\n $actual = ( new Order_Address_Editor() )->preview( $order, $requested );\n\n $this->assertSame( $original, $order->get_address( 'shipping' ) );\n $this->assertSame( $changed, $actual['shipping_changed'] );\n $this->assertSame(\n 'yes' === $linked\n ? __( 'Billing and shipping edits are linked.', 'woocommerce' )\n : __( 'Billing and shipping edits are independent.', 'woocommerce' ),\n $actual['notice']\n );\n }\n\n}\n}\n"
+ },
+ "after": {
+ "code": "<?php\nfinal class Order_Address_Editor {\n public function preview( WC_Order $order, array $shipping ) {\n $edited = clone $order;\n foreach ( $shipping as $key => $value ) {\n $setter = 'set_shipping_' . $key;\n if ( is_callable( array( $edited, $setter ) ) ) {\n $edited->{$setter}( $value );\n }\n }\n return $this->review_notice( $order, $edited );\n }\n\n private function review_notice( WC_Order $before, WC_Order $after ) {\n $notice = 'yes' === $after->get_meta( '_copy_billing_to_shipping' )\n ? __( 'Billing and shipping edits are linked.', 'woocommerce' )\n : __( 'Billing and shipping edits are independent.', 'woocommerce' );\n return array( 'notice' => $notice, 'shipping_changed' => $this->shipping_value_changed( $before, $after ) );\n }\n private function shipping_value_changed( WC_Order $before, WC_Order $after ) {\n return $before->get_address( 'shipping' ) !== $after->get_address( 'shipping' );\n }\n}\n\n// Colocated review-fixture tests activate only under an already loaded WC unit-test bootstrap.\nif ( class_exists( 'WC_Unit_Test_Case', false ) ) {\n\n/** Regression coverage for previewed shipping changes. */\nclass OrderAddressEditorTest extends WC_Unit_Test_Case {\n public function test_linked_unchanged() { $this->assert_preview( 'yes', false ); }\n public function test_linked_changed() { $this->assert_preview( 'yes', true ); }\n public function test_independent_unchanged() { $this->assert_preview( 'no', false ); }\n public function test_independent_changed() { $this->assert_preview( 'no', true ); }\n\n private function assert_preview( $linked, $changed ) {\n $order = new WC_Order();\n $order->set_shipping_city( 'Colombo' );\n $order->set_shipping_postcode( '00100' );\n $order->update_meta_data( '_copy_billing_to_shipping', $linked );\n $original = $order->get_address( 'shipping' );\n $requested = $original;\n if ( $changed ) { $requested['postcode'] = '20000'; }\n\n $actual = ( new Order_Address_Editor() )->preview( $order, $requested );\n\n $this->assertSame( $original, $order->get_address( 'shipping' ) );\n $this->assertSame( $changed, $actual['shipping_changed'] );\n $this->assertSame(\n 'yes' === $linked\n ? __( 'Billing and shipping edits are linked.', 'woocommerce' )\n : __( 'Billing and shipping edits are independent.', 'woocommerce' ),\n $actual['notice']\n );\n }\n\n}\n}\n"
+ },
+ "expect": {
+ "outcome": "quiet"
+ }
+ }
+ ]
+}
diff --git a/.ai/review/evals/proxy-predicate-inference/fixtures/G12A/after.php b/.ai/review/evals/proxy-predicate-inference/fixtures/G12A/after.php
new file mode 100644
index 00000000000..bdfbd5d2ca0
--- /dev/null
+++ b/.ai/review/evals/proxy-predicate-inference/fixtures/G12A/after.php
@@ -0,0 +1,11 @@
+<?php
+/* Extension address editor. */
+function address_edit_mode( $order ) {
+ return $order->get_address( 'billing' ) === $order->get_address( 'shipping' ) ? 'linked' : 'independent';
+}
+
+function import_address_choice( $order, $billing, $shipping, $link_addresses ) {
+ $order->set_address( $billing, 'billing' );
+ $order->set_address( $shipping, 'shipping' );
+ $order->update_meta_data( '_copy_billing_to_shipping', $link_addresses ? 'yes' : 'no' );
+}
diff --git a/.ai/review/evals/proxy-predicate-inference/fixtures/G12A/before.php b/.ai/review/evals/proxy-predicate-inference/fixtures/G12A/before.php
new file mode 100644
index 00000000000..e4064ccdc4f
--- /dev/null
+++ b/.ai/review/evals/proxy-predicate-inference/fixtures/G12A/before.php
@@ -0,0 +1,11 @@
+<?php
+/* Extension address editor. */
+function address_edit_mode( $order ) {
+ return $order->get_meta( '_copy_billing_to_shipping' ) === 'yes' ? 'linked' : 'independent';
+}
+
+function import_address_choice( $order, $billing, $shipping, $link_addresses ) {
+ $order->set_address( $billing, 'billing' );
+ $order->set_address( $shipping, 'shipping' );
+ $order->update_meta_data( '_copy_billing_to_shipping', $link_addresses ? 'yes' : 'no' );
+}
diff --git a/.ai/review/evals/proxy-predicate-inference/fixtures/G12B/after.php b/.ai/review/evals/proxy-predicate-inference/fixtures/G12B/after.php
new file mode 100644
index 00000000000..a7559f76eab
--- /dev/null
+++ b/.ai/review/evals/proxy-predicate-inference/fixtures/G12B/after.php
@@ -0,0 +1,13 @@
+<?php
+/* Extension account-order importer. */
+function receipt_audience( $order ) {
+ return ! $order->get_customer_id() ? 'guest' : 'account';
+}
+
+function import_account_order( $order, $customer_id ) {
+ $order->set_customer_id( $customer_id );
+ $order->update_meta_data( '_placed_as_guest', 'no' );
+ return receipt_audience( $order );
+}
+// Staged import resolves the account ID after the receipt is generated.
+$audience = import_account_order( new WC_Order(), 0 );
diff --git a/.ai/review/evals/proxy-predicate-inference/fixtures/G12B/before.php b/.ai/review/evals/proxy-predicate-inference/fixtures/G12B/before.php
new file mode 100644
index 00000000000..9b03c88c3f1
--- /dev/null
+++ b/.ai/review/evals/proxy-predicate-inference/fixtures/G12B/before.php
@@ -0,0 +1,13 @@
+<?php
+/* Extension account-order importer. */
+function receipt_audience( $order ) {
+ return $order->get_meta( '_placed_as_guest' ) === 'yes' ? 'guest' : 'account';
+}
+
+function import_account_order( $order, $customer_id ) {
+ $order->set_customer_id( $customer_id );
+ $order->update_meta_data( '_placed_as_guest', 'no' );
+ return receipt_audience( $order );
+}
+// Staged import resolves the account ID after the receipt is generated.
+$audience = import_account_order( new WC_Order(), 0 );
diff --git a/.ai/review/evals/proxy-predicate-inference/fixtures/G12C/after.php b/.ai/review/evals/proxy-predicate-inference/fixtures/G12C/after.php
new file mode 100644
index 00000000000..e66f365a2d9
--- /dev/null
+++ b/.ai/review/evals/proxy-predicate-inference/fixtures/G12C/after.php
@@ -0,0 +1,12 @@
+<?php
+/* Extension address editor. */
+function address_edit_mode( $order ) {
+ $copy_billing = 'yes' === $order->get_meta( '_copy_billing_to_shipping' );
+ return $copy_billing ? 'linked' : 'independent';
+}
+
+function import_address_choice( $order, $billing, $shipping, $link_addresses ) {
+ $order->set_address( $billing, 'billing' );
+ $order->set_address( $shipping, 'shipping' );
+ $order->update_meta_data( '_copy_billing_to_shipping', $link_addresses ? 'yes' : 'no' );
+}
diff --git a/.ai/review/evals/proxy-predicate-inference/fixtures/G12C/before.php b/.ai/review/evals/proxy-predicate-inference/fixtures/G12C/before.php
new file mode 100644
index 00000000000..e4064ccdc4f
--- /dev/null
+++ b/.ai/review/evals/proxy-predicate-inference/fixtures/G12C/before.php
@@ -0,0 +1,11 @@
+<?php
+/* Extension address editor. */
+function address_edit_mode( $order ) {
+ return $order->get_meta( '_copy_billing_to_shipping' ) === 'yes' ? 'linked' : 'independent';
+}
+
+function import_address_choice( $order, $billing, $shipping, $link_addresses ) {
+ $order->set_address( $billing, 'billing' );
+ $order->set_address( $shipping, 'shipping' );
+ $order->update_meta_data( '_copy_billing_to_shipping', $link_addresses ? 'yes' : 'no' );
+}
diff --git a/.ai/review/evals/public-php-api-bc/cases.json b/.ai/review/evals/public-php-api-bc/cases.json
new file mode 100644
index 00000000000..ed175bc486e
--- /dev/null
+++ b/.ai/review/evals/public-php-api-bc/cases.json
@@ -0,0 +1,85 @@
+{
+ "version": 1,
+ "cases": [
+ {
+ "id": "G04A-v3",
+ "path": "plugins/woocommerce/includes/class-wc-public-php-api-bc.php",
+ "before": {
+ "file": "fixtures/G04A/before.php"
+ },
+ "after": {
+ "file": "fixtures/G04A/after.php"
+ },
+ "expect": {
+ "outcome": "finding",
+ "locations": [
+ {
+ "file": "plugins/woocommerce/includes/class-wc-public-php-api-bc.php",
+ "lines": [
+ 5,
+ 5
+ ],
+ "anchor": " public function get_entry_count();"
+ },
+ {
+ "file": "plugins/woocommerce/includes/class-wc-public-php-api-bc.php",
+ "lines": [
+ 13,
+ 13
+ ],
+ "anchor": "class Partner_Export_Feed implements WC_Export_Feed {"
+ }
+ ]
+ }
+ },
+ {
+ "id": "G04B-v3",
+ "path": "plugins/woocommerce/includes/class-wc-public-php-api-bc.php",
+ "before": {
+ "file": "fixtures/G04B/before.php"
+ },
+ "after": {
+ "file": "fixtures/G04B/after.php"
+ },
+ "expect": {
+ "outcome": "finding",
+ "locations": [
+ {
+ "file": "plugins/woocommerce/includes/class-wc-public-php-api-bc.php",
+ "lines": [
+ 4,
+ 4
+ ],
+ "anchor": " public function export( $id, $format = 'json' ) { return array( $id, $format ); }"
+ }
+ ]
+ }
+ },
+ {
+ "id": "G04C-v3",
+ "path": "plugins/woocommerce/includes/class-wc-public-php-api-bc.php",
+ "before": {
+ "file": "fixtures/G04C/before.php"
+ },
+ "after": {
+ "file": "fixtures/G04C/after.php"
+ },
+ "expect": {
+ "outcome": "quiet"
+ }
+ },
+ {
+ "id": "G04D-v3",
+ "path": "plugins/woocommerce/includes/class-wc-public-php-api-bc.php",
+ "before": {
+ "file": "fixtures/G04D/before.php"
+ },
+ "after": {
+ "file": "fixtures/G04D/after.php"
+ },
+ "expect": {
+ "outcome": "quiet"
+ }
+ }
+ ]
+}
diff --git a/.ai/review/evals/public-php-api-bc/fixtures/G04A/after.php b/.ai/review/evals/public-php-api-bc/fixtures/G04A/after.php
new file mode 100644
index 00000000000..01f3917c18b
--- /dev/null
+++ b/.ai/review/evals/public-php-api-bc/fixtures/G04A/after.php
@@ -0,0 +1,15 @@
+<?php
+/* Public extension interface. */
+interface WC_Export_Feed {
+ public function rows();
+ public function get_entry_count();
+}
+class WC_Core_Export_Feed implements WC_Export_Feed {
+ public function rows() { return array(); }
+ public function get_entry_count() { return 0; }
+}
+
+// Extension implementation loaded through the export-feed registry.
+class Partner_Export_Feed implements WC_Export_Feed {
+ public function rows() { return array( array( 'id' => 42 ) ); }
+}
diff --git a/.ai/review/evals/public-php-api-bc/fixtures/G04A/before.php b/.ai/review/evals/public-php-api-bc/fixtures/G04A/before.php
new file mode 100644
index 00000000000..c2e4fa68aa9
--- /dev/null
+++ b/.ai/review/evals/public-php-api-bc/fixtures/G04A/before.php
@@ -0,0 +1,13 @@
+<?php
+/* Public extension interface. */
+interface WC_Export_Feed {
+ public function rows();
+}
+class WC_Core_Export_Feed implements WC_Export_Feed {
+ public function rows() { return array(); }
+}
+
+// Extension implementation loaded through the export-feed registry.
+class Partner_Export_Feed implements WC_Export_Feed {
+ public function rows() { return array( array( 'id' => 42 ) ); }
+}
diff --git a/.ai/review/evals/public-php-api-bc/fixtures/G04B/after.php b/.ai/review/evals/public-php-api-bc/fixtures/G04B/after.php
new file mode 100644
index 00000000000..a248d3a9383
--- /dev/null
+++ b/.ai/review/evals/public-php-api-bc/fixtures/G04B/after.php
@@ -0,0 +1,9 @@
+<?php
+/* Public export handler. */
+final class WC_Export_Handler {
+ public function export( $id, $format = 'json' ) { return array( $id, $format ); }
+}
+
+function download_legacy_export( $id ) {
+ return ( new WC_Export_Handler() )->export( $id );
+}
diff --git a/.ai/review/evals/public-php-api-bc/fixtures/G04B/before.php b/.ai/review/evals/public-php-api-bc/fixtures/G04B/before.php
new file mode 100644
index 00000000000..be6a68b8cfc
--- /dev/null
+++ b/.ai/review/evals/public-php-api-bc/fixtures/G04B/before.php
@@ -0,0 +1,9 @@
+<?php
+/* Public export handler. */
+final class WC_Export_Handler {
+ public function export( $id, $format = 'csv' ) { return array( $id, $format ); }
+}
+
+function download_legacy_export( $id ) {
+ return ( new WC_Export_Handler() )->export( $id );
+}
diff --git a/.ai/review/evals/public-php-api-bc/fixtures/G04C/after.php b/.ai/review/evals/public-php-api-bc/fixtures/G04C/after.php
new file mode 100644
index 00000000000..12f1803c0b7
--- /dev/null
+++ b/.ai/review/evals/public-php-api-bc/fixtures/G04C/after.php
@@ -0,0 +1,5 @@
+<?php
+/* Public export handler. */
+final class WC_Export_Handler {
+ public function export( $id, $format = 'csv', $include_header = true ) { return array( $id, $format ); }
+}
diff --git a/.ai/review/evals/public-php-api-bc/fixtures/G04C/before.php b/.ai/review/evals/public-php-api-bc/fixtures/G04C/before.php
new file mode 100644
index 00000000000..58e9ebe09de
--- /dev/null
+++ b/.ai/review/evals/public-php-api-bc/fixtures/G04C/before.php
@@ -0,0 +1,5 @@
+<?php
+/* Public export handler. */
+final class WC_Export_Handler {
+ public function export( $id, $format = 'csv' ) { return array( $id, $format ); }
+}
diff --git a/.ai/review/evals/public-php-api-bc/fixtures/G04D/after.php b/.ai/review/evals/public-php-api-bc/fixtures/G04D/after.php
new file mode 100644
index 00000000000..fd9ad11ed96
--- /dev/null
+++ b/.ai/review/evals/public-php-api-bc/fixtures/G04D/after.php
@@ -0,0 +1,14 @@
+<?php
+interface WC_Export_Feed {
+ public function rows();
+}
+interface WC_Countable_Export_Feed extends WC_Export_Feed {
+ public function get_entry_count();
+}
+class Partner_Export_Feed implements WC_Export_Feed {
+ public function rows() { return array( array( 'id' => 42 ) ); }
+}
+function export_row_count( WC_Export_Feed $feed ) {
+ if ( $feed instanceof WC_Countable_Export_Feed ) { return $feed->get_entry_count(); }
+ return count( $feed->rows() );
+}
diff --git a/.ai/review/evals/public-php-api-bc/fixtures/G04D/before.php b/.ai/review/evals/public-php-api-bc/fixtures/G04D/before.php
new file mode 100644
index 00000000000..2e7b3eb68ce
--- /dev/null
+++ b/.ai/review/evals/public-php-api-bc/fixtures/G04D/before.php
@@ -0,0 +1,10 @@
+<?php
+interface WC_Export_Feed {
+ public function rows();
+}
+class Partner_Export_Feed implements WC_Export_Feed {
+ public function rows() { return array( array( 'id' => 42 ) ); }
+}
+function export_row_count( WC_Export_Feed $feed ) {
+ return count( $feed->rows() );
+}
diff --git a/.ai/review/evals/react-render-and-keyboard/cases.json b/.ai/review/evals/react-render-and-keyboard/cases.json
new file mode 100644
index 00000000000..8c1e0ff7d92
--- /dev/null
+++ b/.ai/review/evals/react-render-and-keyboard/cases.json
@@ -0,0 +1,77 @@
+{
+ "version": 1,
+ "cases": [
+ {
+ "id": "G34A-v3",
+ "path": "plugins/woocommerce/client/blocks/assets/js/blocks/checkout/payment-list.jsx",
+ "before": {
+ "file": "fixtures/G34A/before.jsx"
+ },
+ "after": {
+ "file": "fixtures/G34A/after.jsx"
+ },
+ "expect": {
+ "outcome": "finding",
+ "locations": [
+ {
+ "file": "plugins/woocommerce/client/blocks/assets/js/blocks/checkout/payment-list.jsx",
+ "lines": [
+ 4,
+ 4
+ ],
+ "anchor": "export function PaymentList( { methods } ) { return createElement( 'div', {}, methods.map( ( method, index ) => createElement( PaymentRow, { key: index, method } ) ) ); }"
+ }
+ ]
+ }
+ },
+ {
+ "id": "G34B-v3",
+ "path": "plugins/woocommerce/client/blocks/assets/js/blocks/checkout/payment-list.jsx",
+ "before": {
+ "file": "fixtures/G34B/before.jsx"
+ },
+ "after": {
+ "file": "fixtures/G34B/after.jsx"
+ },
+ "expect": {
+ "outcome": "finding",
+ "locations": [
+ {
+ "file": "plugins/woocommerce/client/blocks/assets/js/blocks/checkout/payment-list.jsx",
+ "lines": [
+ 3,
+ 3
+ ],
+ "anchor": "export function CouponToggle( { expanded, onToggle, label } ) { return createElement( 'div', { tabIndex: 0, 'aria-expanded': expanded, onClick: onToggle }, label ); }"
+ }
+ ]
+ }
+ },
+ {
+ "id": "G34C-v3",
+ "path": "plugins/woocommerce/client/blocks/assets/js/blocks/checkout/payment-list.jsx",
+ "before": {
+ "file": "fixtures/G34C/before.jsx"
+ },
+ "after": {
+ "file": "fixtures/G34C/after.jsx"
+ },
+ "expect": {
+ "outcome": "quiet"
+ }
+ },
+ {
+ "id": "G34D-v3",
+ "path": "plugins/woocommerce/client/blocks/assets/js/blocks/checkout/payment-list.jsx",
+ "before": {
+ "file": "fixtures/G34D/before.jsx"
+ },
+ "after": {
+ "file": "fixtures/G34D/after.jsx"
+ },
+ "expect": {
+ "outcome": "quiet"
+ }
+ }
+ ]
+}
diff --git a/.ai/review/evals/react-render-and-keyboard/fixtures/G34A/after.jsx b/.ai/review/evals/react-render-and-keyboard/fixtures/G34A/after.jsx
new file mode 100644
index 00000000000..c42c8176488
--- /dev/null
+++ b/.ai/review/evals/react-render-and-keyboard/fixtures/G34A/after.jsx
@@ -0,0 +1,4 @@
+/* Payment method IDs are stable. Extensions may change their order during a checkout session. */
+import { createElement, useState } from '@wordpress/element';
+function PaymentRow( { method } ) { const [ note, setNote ] = useState( '' ); return createElement( 'label', {}, method.label, createElement( 'input', { value: note, onChange: ( event ) => setNote( event.target.value ) } ) ); }
+export function PaymentList( { methods } ) { return createElement( 'div', {}, methods.map( ( method, index ) => createElement( PaymentRow, { key: index, method } ) ) ); }
diff --git a/.ai/review/evals/react-render-and-keyboard/fixtures/G34A/before.jsx b/.ai/review/evals/react-render-and-keyboard/fixtures/G34A/before.jsx
new file mode 100644
index 00000000000..f90f9eab456
--- /dev/null
+++ b/.ai/review/evals/react-render-and-keyboard/fixtures/G34A/before.jsx
@@ -0,0 +1,4 @@
+/* Payment method IDs are stable. Extensions may change their order during a checkout session. */
+import { createElement, useState } from '@wordpress/element';
+function PaymentRow( { method } ) { const [ note, setNote ] = useState( '' ); return createElement( 'label', {}, method.label, createElement( 'input', { value: note, onChange: ( event ) => setNote( event.target.value ) } ) ); }
+export function PaymentList( { methods } ) { return createElement( 'div', {}, methods.map( ( method, index ) => createElement( PaymentRow, { key: method.id, method } ) ) ); }
diff --git a/.ai/review/evals/react-render-and-keyboard/fixtures/G34B/after.jsx b/.ai/review/evals/react-render-and-keyboard/fixtures/G34B/after.jsx
new file mode 100644
index 00000000000..7f903852577
--- /dev/null
+++ b/.ai/review/evals/react-render-and-keyboard/fixtures/G34B/after.jsx
@@ -0,0 +1,3 @@
+/* Coupon toggle is inside a checkout form. Its translated label and onToggle handler are provided by the parent. */
+import { createElement } from '@wordpress/element';
+export function CouponToggle( { expanded, onToggle, label } ) { return createElement( 'div', { tabIndex: 0, 'aria-expanded': expanded, onClick: onToggle }, label ); }
diff --git a/.ai/review/evals/react-render-and-keyboard/fixtures/G34B/before.jsx b/.ai/review/evals/react-render-and-keyboard/fixtures/G34B/before.jsx
new file mode 100644
index 00000000000..475dab7c880
--- /dev/null
+++ b/.ai/review/evals/react-render-and-keyboard/fixtures/G34B/before.jsx
@@ -0,0 +1,3 @@
+/* Coupon toggle is inside a checkout form. Its translated label and onToggle handler are provided by the parent. */
+import { createElement } from '@wordpress/element';
+export function CouponToggle( { expanded, onToggle, label } ) { return createElement( 'button', { type: 'button', 'aria-expanded': expanded, onClick: onToggle }, label ); }
diff --git a/.ai/review/evals/react-render-and-keyboard/fixtures/G34C/after.jsx b/.ai/review/evals/react-render-and-keyboard/fixtures/G34C/after.jsx
new file mode 100644
index 00000000000..214a242614d
--- /dev/null
+++ b/.ai/review/evals/react-render-and-keyboard/fixtures/G34C/after.jsx
@@ -0,0 +1,5 @@
+/* Fixed translated checkout legend, created once at module initialization. */
+import { createElement } from '@wordpress/element';
+import { __ } from '@wordpress/i18n';
+const labels = Object.freeze( [ __( 'Required', 'woocommerce' ), __( 'Optional', 'woocommerce' ) ] );
+export function CheckoutLegend() { return createElement( 'ul', {}, labels.map( ( label, index ) => createElement( 'li', { key: index }, label ) ) ); }
diff --git a/.ai/review/evals/react-render-and-keyboard/fixtures/G34C/before.jsx b/.ai/review/evals/react-render-and-keyboard/fixtures/G34C/before.jsx
new file mode 100644
index 00000000000..689a8f2a241
--- /dev/null
+++ b/.ai/review/evals/react-render-and-keyboard/fixtures/G34C/before.jsx
@@ -0,0 +1,5 @@
+/* Fixed translated checkout legend, created once at module initialization. */
+import { createElement } from '@wordpress/element';
+import { __ } from '@wordpress/i18n';
+const labels = Object.freeze( [ __( 'Required', 'woocommerce' ), __( 'Optional', 'woocommerce' ) ] );
+export function CheckoutLegend() { return createElement( 'ul', {}, labels.map( ( label, index ) => createElement( 'li', { key: label }, label ) ) ); }
diff --git a/.ai/review/evals/react-render-and-keyboard/fixtures/G34D/after.jsx b/.ai/review/evals/react-render-and-keyboard/fixtures/G34D/after.jsx
new file mode 100644
index 00000000000..ffa8e604f48
--- /dev/null
+++ b/.ai/review/evals/react-render-and-keyboard/fixtures/G34D/after.jsx
@@ -0,0 +1,3 @@
+/* Coupon toggle is inside a checkout form. Its translated label and onToggle handler are provided by the parent. */
+import { createElement } from '@wordpress/element';
+export function CouponToggle( { expanded, onToggle, label } ) { return createElement( 'div', { className: 'wc-block-checkout__coupon-toggle' }, createElement( 'button', { type: 'button', 'aria-expanded': expanded, onClick: onToggle }, label ) ); }
diff --git a/.ai/review/evals/react-render-and-keyboard/fixtures/G34D/before.jsx b/.ai/review/evals/react-render-and-keyboard/fixtures/G34D/before.jsx
new file mode 100644
index 00000000000..475dab7c880
--- /dev/null
+++ b/.ai/review/evals/react-render-and-keyboard/fixtures/G34D/before.jsx
@@ -0,0 +1,3 @@
+/* Coupon toggle is inside a checkout form. Its translated label and onToggle handler are provided by the parent. */
+import { createElement } from '@wordpress/element';
+export function CouponToggle( { expanded, onToggle, label } ) { return createElement( 'button', { type: 'button', 'aria-expanded': expanded, onClick: onToggle }, label ); }
diff --git a/.ai/review/evals/request-input-validation/cases.json b/.ai/review/evals/request-input-validation/cases.json
new file mode 100644
index 00000000000..102f03e18e2
--- /dev/null
+++ b/.ai/review/evals/request-input-validation/cases.json
@@ -0,0 +1,77 @@
+{
+ "version": 1,
+ "cases": [
+ {
+ "id": "G07A-v4",
+ "path": "plugins/woocommerce/includes/class-wc-request-input-validation.php",
+ "before": {
+ "file": "fixtures/G07A/before.php"
+ },
+ "after": {
+ "file": "fixtures/G07A/after.php"
+ },
+ "expect": {
+ "outcome": "finding",
+ "locations": [
+ {
+ "file": "plugins/woocommerce/includes/class-wc-request-input-validation.php",
+ "lines": [
+ 5,
+ 5
+ ],
+ "anchor": " return urldecode( $term );"
+ }
+ ]
+ }
+ },
+ {
+ "id": "G07B-v3",
+ "path": "plugins/woocommerce/includes/class-wc-request-input-validation.php",
+ "before": {
+ "file": "fixtures/G07B/before.php"
+ },
+ "after": {
+ "file": "fixtures/G07B/after.php"
+ },
+ "expect": {
+ "outcome": "finding",
+ "locations": [
+ {
+ "file": "plugins/woocommerce/includes/class-wc-request-input-validation.php",
+ "lines": [
+ 4,
+ 4
+ ],
+ "anchor": " return is_string( $postcode ) && (bool) preg_match( '/^[A-Z][0-9]{4}[A-Z]{3}$/', $postcode );"
+ }
+ ]
+ }
+ },
+ {
+ "id": "G07C-v3",
+ "path": "plugins/woocommerce/includes/class-wc-request-input-validation.php",
+ "before": {
+ "file": "fixtures/G07C/before.php"
+ },
+ "after": {
+ "file": "fixtures/G07C/after.php"
+ },
+ "expect": {
+ "outcome": "quiet"
+ }
+ },
+ {
+ "id": "G07D-v4",
+ "path": "plugins/woocommerce/includes/class-wc-request-input-validation.php",
+ "before": {
+ "code": "<?php\n/* REST search endpoint; the callback is local to this route registration. */\nadd_action( 'rest_api_init', static function () {\n register_rest_route( 'wc-export/v1', '/search', array(\n 'methods' => 'GET',\n 'callback' => static function ( $request ) {\n $term = $request->get_param( 'search' );\n if ( ! is_string( $term ) ) { return ''; }\n return urldecode( $term );\n },\n 'permission_callback' => static function () { return current_user_can( 'manage_woocommerce' ); },\n 'args' => array( 'search' => array( 'type' => 'string', 'required' => true ) ),\n ) );\n} );\n"
+ },
+ "after": {
+ "code": "<?php\n/* REST search endpoint; the callback is local to this route registration. */\nadd_action( 'rest_api_init', static function () {\n register_rest_route( 'wc-export/v1', '/search', array(\n 'methods' => 'GET',\n 'callback' => static function ( $request ) {\n $term = $request->get_param( 'search' );\n return urldecode( $term );\n },\n 'permission_callback' => static function () { return current_user_can( 'manage_woocommerce' ); },\n 'args' => array( 'search' => array( 'type' => 'string', 'required' => true ) ),\n ) );\n} );\n"
+ },
+ "expect": {
+ "outcome": "quiet"
+ }
+ }
+ ]
+}
diff --git a/.ai/review/evals/request-input-validation/fixtures/G07A/after.php b/.ai/review/evals/request-input-validation/fixtures/G07A/after.php
new file mode 100644
index 00000000000..c4021ca0ee9
--- /dev/null
+++ b/.ai/review/evals/request-input-validation/fixtures/G07A/after.php
@@ -0,0 +1,6 @@
+<?php
+/* Admin export search. Output is escaped by the view. */
+function wc_export_search_term() {
+ $term = $_GET['search'] ?? '';
+ return urldecode( $term );
+}
diff --git a/.ai/review/evals/request-input-validation/fixtures/G07A/before.php b/.ai/review/evals/request-input-validation/fixtures/G07A/before.php
new file mode 100644
index 00000000000..e87e8302fe9
--- /dev/null
+++ b/.ai/review/evals/request-input-validation/fixtures/G07A/before.php
@@ -0,0 +1,7 @@
+<?php
+/* Admin export search. Output is escaped by the view. */
+function wc_export_search_term() {
+ $term = $_GET['search'] ?? '';
+ if ( ! is_string( $term ) ) { return ''; }
+ return urldecode( $term );
+}
diff --git a/.ai/review/evals/request-input-validation/fixtures/G07B/after.php b/.ai/review/evals/request-input-validation/fixtures/G07B/after.php
new file mode 100644
index 00000000000..97894345df3
--- /dev/null
+++ b/.ai/review/evals/request-input-validation/fixtures/G07B/after.php
@@ -0,0 +1,5 @@
+<?php
+/* Argentina shipping-address validator. */
+function wc_validate_argentina_postcode( $postcode ) {
+ return is_string( $postcode ) && (bool) preg_match( '/^[A-Z][0-9]{4}[A-Z]{3}$/', $postcode );
+}
diff --git a/.ai/review/evals/request-input-validation/fixtures/G07B/before.php b/.ai/review/evals/request-input-validation/fixtures/G07B/before.php
new file mode 100644
index 00000000000..177c8da0ffc
--- /dev/null
+++ b/.ai/review/evals/request-input-validation/fixtures/G07B/before.php
@@ -0,0 +1,5 @@
+<?php
+/* Argentina shipping-address validator. */
+function wc_validate_argentina_postcode( $postcode ) {
+ return is_string( $postcode ) && (bool) preg_match( '/^(?:[0-9]{4}|[A-Z][0-9]{4}[A-Z]{3})$/', $postcode );
+}
diff --git a/.ai/review/evals/request-input-validation/fixtures/G07C/after.php b/.ai/review/evals/request-input-validation/fixtures/G07C/after.php
new file mode 100644
index 00000000000..0e6e4b8f861
--- /dev/null
+++ b/.ai/review/evals/request-input-validation/fixtures/G07C/after.php
@@ -0,0 +1,8 @@
+<?php
+/* Admin export search. Output is escaped by the view. */
+function wc_export_search_term() {
+ $term = $_GET['search'] ?? '';
+ if ( ! is_scalar( $term ) || is_bool( $term ) ) { return ''; }
+ $term = (string) $term;
+ return urldecode( $term );
+}
diff --git a/.ai/review/evals/request-input-validation/fixtures/G07C/before.php b/.ai/review/evals/request-input-validation/fixtures/G07C/before.php
new file mode 100644
index 00000000000..e87e8302fe9
--- /dev/null
+++ b/.ai/review/evals/request-input-validation/fixtures/G07C/before.php
@@ -0,0 +1,7 @@
+<?php
+/* Admin export search. Output is escaped by the view. */
+function wc_export_search_term() {
+ $term = $_GET['search'] ?? '';
+ if ( ! is_string( $term ) ) { return ''; }
+ return urldecode( $term );
+}
diff --git a/.ai/review/evals/request-lifecycle-state/cases.json b/.ai/review/evals/request-lifecycle-state/cases.json
new file mode 100644
index 00000000000..e831cdede2a
--- /dev/null
+++ b/.ai/review/evals/request-lifecycle-state/cases.json
@@ -0,0 +1,77 @@
+{
+ "version": 1,
+ "cases": [
+ {
+ "id": "G14A-v3",
+ "path": "plugins/woocommerce/includes/order-fragment.php",
+ "before": {
+ "file": "fixtures/G14A/before.php"
+ },
+ "after": {
+ "file": "fixtures/G14A/after.php"
+ },
+ "expect": {
+ "outcome": "finding",
+ "locations": [
+ {
+ "file": "plugins/woocommerce/includes/order-fragment.php",
+ "lines": [
+ 5,
+ 6
+ ],
+ "anchor": " $html = $render();"
+ }
+ ]
+ }
+ },
+ {
+ "id": "G14B-v5",
+ "path": "plugins/woocommerce/includes/email-bootstrap.php",
+ "before": {
+ "file": "fixtures/G14B/before.php"
+ },
+ "after": {
+ "file": "fixtures/G14B/after.php"
+ },
+ "expect": {
+ "outcome": "finding",
+ "locations": [
+ {
+ "file": "plugins/woocommerce/includes/email-bootstrap.php",
+ "lines": [
+ 16,
+ 18
+ ],
+ "anchor": "}, 1 );"
+ }
+ ]
+ }
+ },
+ {
+ "id": "G14C-v3",
+ "path": "plugins/woocommerce/includes/order-fragment.php",
+ "before": {
+ "file": "fixtures/G14C/before.php"
+ },
+ "after": {
+ "file": "fixtures/G14C/after.php"
+ },
+ "expect": {
+ "outcome": "quiet"
+ }
+ },
+ {
+ "id": "G14D-v4",
+ "path": "plugins/woocommerce/includes/email-bootstrap.php",
+ "before": {
+ "file": "fixtures/G14D/before.php"
+ },
+ "after": {
+ "file": "fixtures/G14D/after.php"
+ },
+ "expect": {
+ "outcome": "quiet"
+ }
+ }
+ ]
+}
diff --git a/.ai/review/evals/request-lifecycle-state/fixtures/G14A/after.php b/.ai/review/evals/request-lifecycle-state/fixtures/G14A/after.php
new file mode 100644
index 00000000000..0b81a6ba512
--- /dev/null
+++ b/.ai/review/evals/request-lifecycle-state/fixtures/G14A/after.php
@@ -0,0 +1,8 @@
+<?php
+/* Fragment renderer. Locale is one of the installed locales. */
+function render_order_fragment( $locale, $render ) {
+ switch_to_locale( $locale );
+ $html = $render();
+ restore_previous_locale();
+ return $html;
+}
diff --git a/.ai/review/evals/request-lifecycle-state/fixtures/G14A/before.php b/.ai/review/evals/request-lifecycle-state/fixtures/G14A/before.php
new file mode 100644
index 00000000000..70221c44275
--- /dev/null
+++ b/.ai/review/evals/request-lifecycle-state/fixtures/G14A/before.php
@@ -0,0 +1,10 @@
+<?php
+/* Fragment renderer. Locale is one of the installed locales. */
+function render_order_fragment( $locale, $render ) {
+ switch_to_locale( $locale );
+ try {
+ return $render();
+ } finally {
+ restore_previous_locale();
+ }
+}
diff --git a/.ai/review/evals/request-lifecycle-state/fixtures/G14B/after.php b/.ai/review/evals/request-lifecycle-state/fixtures/G14B/after.php
new file mode 100644
index 00000000000..d67aa789c1c
--- /dev/null
+++ b/.ai/review/evals/request-lifecycle-state/fixtures/G14B/after.php
@@ -0,0 +1,18 @@
+<?php
+/* Request bootstrap. register_sender receives the mailer type list. */
+$email_types = array();
+$registered_email_types = array();
+$register_sender = static function ( $types ) use ( &$registered_email_types ) {
+ $registered_email_types = $types;
+};
+$build_mailer = static function () use ( &$email_types ) {
+ static $snapshot;
+ if ( null === $snapshot ) { $snapshot = $email_types; }
+ return $snapshot;
+};
+add_action( 'init', static function () use ( &$email_types ) {
+ $email_types[] = 'shipment';
+}, 5 );
+add_action( 'init', static function () use ( $build_mailer, $register_sender ) {
+ $register_sender( $build_mailer() );
+}, 1 );
diff --git a/.ai/review/evals/request-lifecycle-state/fixtures/G14B/before.php b/.ai/review/evals/request-lifecycle-state/fixtures/G14B/before.php
new file mode 100644
index 00000000000..7f82c066e8c
--- /dev/null
+++ b/.ai/review/evals/request-lifecycle-state/fixtures/G14B/before.php
@@ -0,0 +1,18 @@
+<?php
+/* Request bootstrap. register_sender receives the mailer type list. */
+$email_types = array();
+$registered_email_types = array();
+$register_sender = static function ( $types ) use ( &$registered_email_types ) {
+ $registered_email_types = $types;
+};
+$build_mailer = static function () use ( &$email_types ) {
+ static $snapshot;
+ if ( null === $snapshot ) { $snapshot = $email_types; }
+ return $snapshot;
+};
+add_action( 'init', static function () use ( &$email_types ) {
+ $email_types[] = 'shipment';
+}, 5 );
+add_action( 'init', static function () use ( $build_mailer, $register_sender ) {
+ $register_sender( $build_mailer() );
+}, 10 );
diff --git a/.ai/review/evals/request-lifecycle-state/fixtures/G14C/after.php b/.ai/review/evals/request-lifecycle-state/fixtures/G14C/after.php
new file mode 100644
index 00000000000..e1acca608ba
--- /dev/null
+++ b/.ai/review/evals/request-lifecycle-state/fixtures/G14C/after.php
@@ -0,0 +1,14 @@
+<?php
+final class Order_Fragment_Renderer {
+ public function render( $locale, $render ) {
+ $switched = switch_to_locale( $locale );
+ try {
+ return $this->render_fragment( $render );
+ } finally {
+ if ( $switched ) { restore_previous_locale(); }
+ }
+ }
+ private function render_fragment( $render ) {
+ return $render();
+ }
+}
diff --git a/.ai/review/evals/request-lifecycle-state/fixtures/G14C/before.php b/.ai/review/evals/request-lifecycle-state/fixtures/G14C/before.php
new file mode 100644
index 00000000000..fbeb943bffb
--- /dev/null
+++ b/.ai/review/evals/request-lifecycle-state/fixtures/G14C/before.php
@@ -0,0 +1,14 @@
+<?php
+final class Order_Fragment_Renderer {
+ public function render( $locale, $render ) {
+ return $this->render_fragment( $locale, $render );
+ }
+ private function render_fragment( $locale, $render ) {
+ $switched = switch_to_locale( $locale );
+ try {
+ return $render();
+ } finally {
+ if ( $switched ) { restore_previous_locale(); }
+ }
+ }
+}
diff --git a/.ai/review/evals/request-lifecycle-state/fixtures/G14D/after.php b/.ai/review/evals/request-lifecycle-state/fixtures/G14D/after.php
new file mode 100644
index 00000000000..a5b2a4f379e
--- /dev/null
+++ b/.ai/review/evals/request-lifecycle-state/fixtures/G14D/after.php
@@ -0,0 +1,19 @@
+<?php
+$email_types = array();
+$registered_email_types = array();
+$register_sender = static function ( $types ) use ( &$registered_email_types ) {
+ $registered_email_types = $types;
+};
+$build_mailer = static function () use ( &$email_types ) {
+ static $snapshot;
+ if ( null === $snapshot ) { $snapshot = $email_types; }
+ return $snapshot;
+};
+add_action( 'init', static function () use ( &$email_types ) {
+ $email_types[] = 'shipment';
+}, 5 );
+/* Request bootstrap. register_sender receives the mailer type list. */
+$register_mailer = static function () use ( $build_mailer, $register_sender ) {
+ $register_sender( $build_mailer() );
+};
+add_action( 'init', $register_mailer, 10 );
diff --git a/.ai/review/evals/request-lifecycle-state/fixtures/G14D/before.php b/.ai/review/evals/request-lifecycle-state/fixtures/G14D/before.php
new file mode 100644
index 00000000000..b47f12cadcf
--- /dev/null
+++ b/.ai/review/evals/request-lifecycle-state/fixtures/G14D/before.php
@@ -0,0 +1,18 @@
+<?php
+$email_types = array();
+$registered_email_types = array();
+$register_sender = static function ( $types ) use ( &$registered_email_types ) {
+ $registered_email_types = $types;
+};
+$build_mailer = static function () use ( &$email_types ) {
+ static $snapshot;
+ if ( null === $snapshot ) { $snapshot = $email_types; }
+ return $snapshot;
+};
+add_action( 'init', static function () use ( &$email_types ) {
+ $email_types[] = 'shipment';
+}, 5 );
+/* Request bootstrap. register_sender receives the mailer type list. */
+add_action( 'init', static function () use ( $build_mailer, $register_sender ) {
+ $register_sender( $build_mailer() );
+}, 10 );
diff --git a/.ai/review/evals/rest-response-contract/cases.json b/.ai/review/evals/rest-response-contract/cases.json
new file mode 100644
index 00000000000..962bebd1eee
--- /dev/null
+++ b/.ai/review/evals/rest-response-contract/cases.json
@@ -0,0 +1,77 @@
+{
+ "version": 1,
+ "cases": [
+ {
+ "id": "G16A-v3",
+ "path": "plugins/woocommerce/includes/rest-api/controllers/order-items.php",
+ "before": {
+ "file": "fixtures/G16A/before.php"
+ },
+ "after": {
+ "file": "fixtures/G16A/after.php"
+ },
+ "expect": {
+ "outcome": "finding",
+ "locations": [
+ {
+ "file": "plugins/woocommerce/includes/rest-api/controllers/order-items.php",
+ "lines": [
+ 4,
+ 5
+ ],
+ "anchor": " $item_data = array();"
+ }
+ ]
+ }
+ },
+ {
+ "id": "G16B-v3",
+ "path": "plugins/woocommerce/includes/rest-api/controllers/product-search.php",
+ "before": {
+ "file": "fixtures/G16B/before.php"
+ },
+ "after": {
+ "file": "fixtures/G16B/after.php"
+ },
+ "expect": {
+ "outcome": "finding",
+ "locations": [
+ {
+ "file": "plugins/woocommerce/includes/rest-api/controllers/product-search.php",
+ "lines": [
+ 4,
+ 4
+ ],
+ "anchor": " return rest_ensure_response( array( 'name' => $product['name'] ) );"
+ }
+ ]
+ }
+ },
+ {
+ "id": "G16C-v4",
+ "path": "plugins/woocommerce/includes/rest-api/controllers/product-search.php",
+ "before": {
+ "file": "fixtures/G16C/before.php"
+ },
+ "after": {
+ "file": "fixtures/G16C/after.php"
+ },
+ "expect": {
+ "outcome": "quiet"
+ }
+ },
+ {
+ "id": "G16D-v3",
+ "path": "plugins/woocommerce/src/StoreApi/Routes/ExperimentalItems.php",
+ "before": {
+ "file": "fixtures/G16D/before.php"
+ },
+ "after": {
+ "file": "fixtures/G16D/after.php"
+ },
+ "expect": {
+ "outcome": "quiet"
+ }
+ }
+ ]
+}
diff --git a/.ai/review/evals/rest-response-contract/fixtures/G16A/after.php b/.ai/review/evals/rest-response-contract/fixtures/G16A/after.php
new file mode 100644
index 00000000000..759c5a0284a
--- /dev/null
+++ b/.ai/review/evals/rest-response-contract/fixtures/G16A/after.php
@@ -0,0 +1,15 @@
+<?php
+/* Released item export response. Items contain integer id and string name. */
+function prepare_item_for_response( $items ) {
+ $item_data = array();
+ foreach ( $items as $item ) { $item_data[] = $item['name']; }
+ return rest_ensure_response( array( 'item_data' => $item_data ) );
+}
+
+function response_schema() {
+ return array( 'properties' => array( 'item_data' => array( 'type' => 'object', 'additionalProperties' => array( 'type' => 'string' ) ) ) );
+}
+function exported_item_name( $response, $item_id ) {
+ $items = (array) $response['item_data'];
+ return $items[ (string) $item_id ] ?? '';
+}
diff --git a/.ai/review/evals/rest-response-contract/fixtures/G16A/before.php b/.ai/review/evals/rest-response-contract/fixtures/G16A/before.php
new file mode 100644
index 00000000000..2e97efda142
--- /dev/null
+++ b/.ai/review/evals/rest-response-contract/fixtures/G16A/before.php
@@ -0,0 +1,15 @@
+<?php
+/* Released item export response. Items contain integer id and string name. */
+function prepare_item_for_response( $items ) {
+ $item_data = new stdClass();
+ foreach ( $items as $item ) { $item_data->{(string) $item['id']} = $item['name']; }
+ return rest_ensure_response( array( 'item_data' => $item_data ) );
+}
+
+function response_schema() {
+ return array( 'properties' => array( 'item_data' => array( 'type' => 'object', 'additionalProperties' => array( 'type' => 'string' ) ) ) );
+}
+function exported_item_name( $response, $item_id ) {
+ $items = (array) $response['item_data'];
+ return $items[ (string) $item_id ] ?? '';
+}
diff --git a/.ai/review/evals/rest-response-contract/fixtures/G16B/after.php b/.ai/review/evals/rest-response-contract/fixtures/G16B/after.php
new file mode 100644
index 00000000000..283867158bb
--- /dev/null
+++ b/.ai/review/evals/rest-response-contract/fixtures/G16B/after.php
@@ -0,0 +1,12 @@
+<?php
+/* Released search response. Product names use the Woo stored name representation. */
+function prepare_item_for_response( $product ) {
+ return rest_ensure_response( array( 'name' => $product['name'] ) );
+}
+
+function response_schema() {
+ return array( 'properties' => array( 'name' => array( 'type' => 'string', 'description' => 'Product name as plain text.' ) ) );
+}
+function search_result_label( $response ) {
+ return esc_html( $response['name'] );
+}
diff --git a/.ai/review/evals/rest-response-contract/fixtures/G16B/before.php b/.ai/review/evals/rest-response-contract/fixtures/G16B/before.php
new file mode 100644
index 00000000000..092d9477bfd
--- /dev/null
+++ b/.ai/review/evals/rest-response-contract/fixtures/G16B/before.php
@@ -0,0 +1,12 @@
+<?php
+/* Released search response. Product names use the Woo stored name representation. */
+function prepare_item_for_response( $product ) {
+ return rest_ensure_response( array( 'name' => html_entity_decode( $product['name'], ENT_QUOTES, 'UTF-8' ) ) );
+}
+
+function response_schema() {
+ return array( 'properties' => array( 'name' => array( 'type' => 'string', 'description' => 'Product name as plain text.' ) ) );
+}
+function search_result_label( $response ) {
+ return esc_html( $response['name'] );
+}
diff --git a/.ai/review/evals/rest-response-contract/fixtures/G16C/after.php b/.ai/review/evals/rest-response-contract/fixtures/G16C/after.php
new file mode 100644
index 00000000000..791bc8b78ac
--- /dev/null
+++ b/.ai/review/evals/rest-response-contract/fixtures/G16C/after.php
@@ -0,0 +1,12 @@
+<?php
+/* Released search response. Product names use the Woo stored name representation. */
+function prepare_item_for_response( $product ) {
+ return rest_ensure_response( array( 'id' => $product['id'], 'name' => html_entity_decode( $product['name'], ENT_QUOTES, 'UTF-8' ) ) );
+}
+
+function response_schema() {
+ return array( 'properties' => array( 'id' => array( 'type' => 'integer', 'description' => 'Product ID.' ), 'name' => array( 'type' => 'string', 'description' => 'Product name as plain text.' ) ) );
+}
+function search_result_label( $response ) {
+ return esc_html( $response['name'] );
+}
diff --git a/.ai/review/evals/rest-response-contract/fixtures/G16C/before.php b/.ai/review/evals/rest-response-contract/fixtures/G16C/before.php
new file mode 100644
index 00000000000..092d9477bfd
--- /dev/null
+++ b/.ai/review/evals/rest-response-contract/fixtures/G16C/before.php
@@ -0,0 +1,12 @@
+<?php
+/* Released search response. Product names use the Woo stored name representation. */
+function prepare_item_for_response( $product ) {
+ return rest_ensure_response( array( 'name' => html_entity_decode( $product['name'], ENT_QUOTES, 'UTF-8' ) ) );
+}
+
+function response_schema() {
+ return array( 'properties' => array( 'name' => array( 'type' => 'string', 'description' => 'Product name as plain text.' ) ) );
+}
+function search_result_label( $response ) {
+ return esc_html( $response['name'] );
+}
diff --git a/.ai/review/evals/rest-response-contract/fixtures/G16D/after.php b/.ai/review/evals/rest-response-contract/fixtures/G16D/after.php
new file mode 100644
index 00000000000..cb2bff75e65
--- /dev/null
+++ b/.ai/review/evals/rest-response-contract/fixtures/G16D/after.php
@@ -0,0 +1,18 @@
+<?php
+/* Route table consumed by the server bootstrap. wc/v3/items is released; the opt-in preview route is new in this change. items is an ID-keyed map. */
+function item_response_routes( $features ) {
+ $routes = array( '/wc/v3/items' => static function ( $items ) {
+ return prepare_item_for_response( $items, false );
+ } );
+ if ( true === ( $features['unreleased_items_preview'] ?? false ) ) {
+ $routes['/wc/experimental/items-preview'] = static function ( $items ) {
+ return prepare_item_for_response( $items, true );
+ };
+ }
+ return $routes;
+}
+function prepare_item_for_response( $items, $experimental ) {
+ $legacy = array( 'item_data' => (object) $items );
+ if ( ! $experimental ) { return rest_ensure_response( $legacy ); }
+ return rest_ensure_response( array( 'items' => array_values( $items ) ) );
+}
diff --git a/.ai/review/evals/rest-response-contract/fixtures/G16D/before.php b/.ai/review/evals/rest-response-contract/fixtures/G16D/before.php
new file mode 100644
index 00000000000..04b9e50604d
--- /dev/null
+++ b/.ai/review/evals/rest-response-contract/fixtures/G16D/before.php
@@ -0,0 +1,18 @@
+<?php
+/* Route table consumed by the server bootstrap. wc/v3/items is released; the opt-in preview route is new in this change. items is an ID-keyed map. */
+function item_response_routes( $features ) {
+ $routes = array( '/wc/v3/items' => static function ( $items ) {
+ return prepare_item_for_response( $items, false );
+ } );
+ if ( true === ( $features['unreleased_items_preview'] ?? false ) ) {
+ $routes['/wc/experimental/items-preview'] = static function ( $items ) {
+ return prepare_item_for_response( $items, true );
+ };
+ }
+ return $routes;
+}
+function prepare_item_for_response( $items, $experimental ) {
+ $legacy = array( 'item_data' => (object) $items );
+ if ( ! $experimental ) { return rest_ensure_response( $legacy ); }
+ return rest_ensure_response( array( 'items' => (object) $items ) );
+}
diff --git a/.ai/review/evals/script-handle-loading-contract/cases.json b/.ai/review/evals/script-handle-loading-contract/cases.json
new file mode 100644
index 00000000000..ed63ad2053f
--- /dev/null
+++ b/.ai/review/evals/script-handle-loading-contract/cases.json
@@ -0,0 +1,85 @@
+{
+ "version": 1,
+ "cases": [
+ {
+ "id": "G28A-v3",
+ "path": "plugins/woocommerce/includes/class-wc-eval-28.php",
+ "before": {
+ "file": "fixtures/G28A/before.php"
+ },
+ "after": {
+ "file": "fixtures/G28A/after.php"
+ },
+ "expect": {
+ "outcome": "finding",
+ "locations": [
+ {
+ "file": "plugins/woocommerce/includes/class-wc-eval-28.php",
+ "lines": [
+ 4,
+ 4
+ ],
+ "anchor": " if ( ! is_admin() ) { return; }"
+ }
+ ]
+ }
+ },
+ {
+ "id": "G28B-v4",
+ "path": "plugins/woocommerce/includes/class-wc-eval-28.php",
+ "before": {
+ "file": "fixtures/G28B/before.php"
+ },
+ "after": {
+ "file": "fixtures/G28B/after.php"
+ },
+ "expect": {
+ "outcome": "finding",
+ "locations": [
+ {
+ "file": "plugins/woocommerce/includes/class-wc-eval-28.php",
+ "lines": [
+ 4,
+ 4
+ ],
+ "anchor": " wp_register_script( 'wc-tokenization-ui', plugins_url( 'assets/js/frontend/tokenization-form.js', WC_PLUGIN_FILE ), array( 'jquery' ), WC()->version, false );"
+ },
+ {
+ "file": "plugins/woocommerce/includes/class-wc-eval-28.php",
+ "lines": [
+ 8,
+ 8
+ ],
+ "anchor": "array( 'jquery', 'woocommerce-tokenization-form' )"
+ }
+ ]
+ }
+ },
+ {
+ "id": "G28C-v3",
+ "path": "plugins/woocommerce/includes/class-wc-eval-28.php",
+ "before": {
+ "file": "fixtures/G28C/before.php"
+ },
+ "after": {
+ "file": "fixtures/G28C/after.php"
+ },
+ "expect": {
+ "outcome": "quiet"
+ }
+ },
+ {
+ "id": "G28D-v4",
+ "path": "plugins/woocommerce/includes/class-wc-eval-28.php",
+ "before": {
+ "code": "<?php\n/* Tokenization asset registration and an installed gateway\u2019s frontend dependency. */\nfunction wc_register_checkout_assets() {\n wp_register_script( 'woocommerce-tokenization-form', plugins_url( 'assets/js/frontend/tokenization-form.js', WC_PLUGIN_FILE ), array( 'jquery' ), WC()->version, false );\n wp_localize_script( 'woocommerce-tokenization-form', 'wc_tokenization_form_params', array( 'is_registration_required' => WC()->checkout()->is_registration_required(), 'is_logged_in' => is_user_logged_in() ) );\n}\nadd_action( 'wp_enqueue_scripts', 'wc_register_checkout_assets' );\n\nadd_action( 'wp_enqueue_scripts', static function() { wp_enqueue_script( 'gateway-ui', plugins_url( 'gateway-ui.js', __FILE__ ), array( 'jquery', 'woocommerce-tokenization-form' ), '1.0', false ); }, 20 );\n"
+ },
+ "after": {
+ "code": "<?php\n/* Tokenization asset registration and an installed gateway\u2019s frontend dependency. */\nfunction wc_register_checkout_assets() {\n wp_register_script( 'woocommerce-tokenization-form', plugins_url( 'assets/js/frontend/tokenization-form.js', WC_PLUGIN_FILE ), array( 'jquery' ), WC()->version, false );\n wp_register_script( 'wc-tokenization-ui', false, array( 'woocommerce-tokenization-form' ), WC()->version, false );\n wp_localize_script( 'woocommerce-tokenization-form', 'wc_tokenization_form_params', array( 'is_registration_required' => WC()->checkout()->is_registration_required(), 'is_logged_in' => is_user_logged_in() ) );\n}\nadd_action( 'wp_enqueue_scripts', 'wc_register_checkout_assets' );\n\nadd_action( 'wp_enqueue_scripts', static function() { wp_enqueue_script( 'gateway-ui', plugins_url( 'gateway-ui.js', __FILE__ ), array( 'jquery', 'woocommerce-tokenization-form' ), '1.0', false ); }, 20 );\n"
+ },
+ "expect": {
+ "outcome": "quiet"
+ }
+ }
+ ]
+}
diff --git a/.ai/review/evals/script-handle-loading-contract/fixtures/G28A/after.php b/.ai/review/evals/script-handle-loading-contract/fixtures/G28A/after.php
new file mode 100644
index 00000000000..4f463cfb05e
--- /dev/null
+++ b/.ai/review/evals/script-handle-loading-contract/fixtures/G28A/after.php
@@ -0,0 +1,9 @@
+<?php
+/* Tokenization asset registration and an installed gateway’s frontend dependency. */
+function wc_register_checkout_assets() {
+ if ( ! is_admin() ) { return; }
+ wp_register_script( 'woocommerce-tokenization-form', plugins_url( 'assets/js/frontend/tokenization-form.js', WC_PLUGIN_FILE ), array( 'jquery' ), WC()->version, false );
+}
+add_action( 'wp_enqueue_scripts', 'wc_register_checkout_assets' );
+
+add_action( 'wp_enqueue_scripts', static function() { wp_enqueue_script( 'gateway-ui', plugins_url( 'gateway-ui.js', __FILE__ ), array( 'jquery', 'woocommerce-tokenization-form' ), '1.0', false ); }, 20 );
diff --git a/.ai/review/evals/script-handle-loading-contract/fixtures/G28A/before.php b/.ai/review/evals/script-handle-loading-contract/fixtures/G28A/before.php
new file mode 100644
index 00000000000..b576eac1faa
--- /dev/null
+++ b/.ai/review/evals/script-handle-loading-contract/fixtures/G28A/before.php
@@ -0,0 +1,8 @@
+<?php
+/* Tokenization asset registration and an installed gateway’s frontend dependency. */
+function wc_register_checkout_assets() {
+ wp_register_script( 'woocommerce-tokenization-form', plugins_url( 'assets/js/frontend/tokenization-form.js', WC_PLUGIN_FILE ), array( 'jquery' ), WC()->version, false );
+}
+add_action( 'wp_enqueue_scripts', 'wc_register_checkout_assets' );
+
+add_action( 'wp_enqueue_scripts', static function() { wp_enqueue_script( 'gateway-ui', plugins_url( 'gateway-ui.js', __FILE__ ), array( 'jquery', 'woocommerce-tokenization-form' ), '1.0', false ); }, 20 );
diff --git a/.ai/review/evals/script-handle-loading-contract/fixtures/G28B/after.php b/.ai/review/evals/script-handle-loading-contract/fixtures/G28B/after.php
new file mode 100644
index 00000000000..7b3a64237ba
--- /dev/null
+++ b/.ai/review/evals/script-handle-loading-contract/fixtures/G28B/after.php
@@ -0,0 +1,8 @@
+<?php
+/* Tokenization asset registration and an installed gateway’s frontend dependency. */
+function wc_register_checkout_assets() {
+ wp_register_script( 'wc-tokenization-ui', plugins_url( 'assets/js/frontend/tokenization-form.js', WC_PLUGIN_FILE ), array( 'jquery' ), WC()->version, false );
+}
+add_action( 'wp_enqueue_scripts', 'wc_register_checkout_assets' );
+
+add_action( 'wp_enqueue_scripts', static function() { wp_enqueue_script( 'gateway-ui', plugins_url( 'gateway-ui.js', __FILE__ ), array( 'jquery', 'woocommerce-tokenization-form' ), '1.0', false ); }, 20 );
diff --git a/.ai/review/evals/script-handle-loading-contract/fixtures/G28B/before.php b/.ai/review/evals/script-handle-loading-contract/fixtures/G28B/before.php
new file mode 100644
index 00000000000..b576eac1faa
--- /dev/null
+++ b/.ai/review/evals/script-handle-loading-contract/fixtures/G28B/before.php
@@ -0,0 +1,8 @@
+<?php
+/* Tokenization asset registration and an installed gateway’s frontend dependency. */
+function wc_register_checkout_assets() {
+ wp_register_script( 'woocommerce-tokenization-form', plugins_url( 'assets/js/frontend/tokenization-form.js', WC_PLUGIN_FILE ), array( 'jquery' ), WC()->version, false );
+}
+add_action( 'wp_enqueue_scripts', 'wc_register_checkout_assets' );
+
+add_action( 'wp_enqueue_scripts', static function() { wp_enqueue_script( 'gateway-ui', plugins_url( 'gateway-ui.js', __FILE__ ), array( 'jquery', 'woocommerce-tokenization-form' ), '1.0', false ); }, 20 );
diff --git a/.ai/review/evals/script-handle-loading-contract/fixtures/G28C/after.php b/.ai/review/evals/script-handle-loading-contract/fixtures/G28C/after.php
new file mode 100644
index 00000000000..90bb2ddd76a
--- /dev/null
+++ b/.ai/review/evals/script-handle-loading-contract/fixtures/G28C/after.php
@@ -0,0 +1,9 @@
+<?php
+/* Optional help script is included in this release’s assets. */
+function wc_register_checkout_assets() {
+ wp_register_script( 'woocommerce-tokenization-form', plugins_url( 'assets/js/frontend/tokenization-form.js', WC_PLUGIN_FILE ), array( 'jquery' ), WC()->version, false );
+ wp_register_script( 'wc-tokenization-help', plugins_url( 'assets/js/tokenization-help.js', WC_PLUGIN_FILE ), array( 'jquery' ), WC()->version, false );
+}
+add_action( 'wp_enqueue_scripts', 'wc_register_checkout_assets' );
+
+add_action( 'wp_enqueue_scripts', static function() { wp_enqueue_script( 'gateway-ui', plugins_url( 'gateway-ui.js', __FILE__ ), array( 'jquery', 'woocommerce-tokenization-form' ), '1.0', false ); }, 20 );
diff --git a/.ai/review/evals/script-handle-loading-contract/fixtures/G28C/before.php b/.ai/review/evals/script-handle-loading-contract/fixtures/G28C/before.php
new file mode 100644
index 00000000000..93cb8a1158d
--- /dev/null
+++ b/.ai/review/evals/script-handle-loading-contract/fixtures/G28C/before.php
@@ -0,0 +1,8 @@
+<?php
+/* Optional help script is included in this release’s assets. */
+function wc_register_checkout_assets() {
+ wp_register_script( 'woocommerce-tokenization-form', plugins_url( 'assets/js/frontend/tokenization-form.js', WC_PLUGIN_FILE ), array( 'jquery' ), WC()->version, false );
+}
+add_action( 'wp_enqueue_scripts', 'wc_register_checkout_assets' );
+
+add_action( 'wp_enqueue_scripts', static function() { wp_enqueue_script( 'gateway-ui', plugins_url( 'gateway-ui.js', __FILE__ ), array( 'jquery', 'woocommerce-tokenization-form' ), '1.0', false ); }, 20 );
diff --git a/.ai/review/evals/sec-auth-tokens-and-cookies/cases.json b/.ai/review/evals/sec-auth-tokens-and-cookies/cases.json
new file mode 100644
index 00000000000..5ca7ec344c2
--- /dev/null
+++ b/.ai/review/evals/sec-auth-tokens-and-cookies/cases.json
@@ -0,0 +1,77 @@
+{
+ "version": 1,
+ "cases": [
+ {
+ "id": "G40A-v3",
+ "path": "plugins/woocommerce/includes/class-wc-download-auth.php",
+ "before": {
+ "file": "fixtures/G40A/before.php"
+ },
+ "after": {
+ "file": "fixtures/G40A/after.php"
+ },
+ "expect": {
+ "outcome": "finding",
+ "locations": [
+ {
+ "file": "plugins/woocommerce/includes/class-wc-download-auth.php",
+ "lines": [
+ 7,
+ 7
+ ],
+ "anchor": " if ( ! is_string( $stored ) || '' === $stored || $stored != $token ) { wp_send_json_error( array(), 403 ); return; }"
+ }
+ ]
+ }
+ },
+ {
+ "id": "G40B-v4",
+ "path": "plugins/woocommerce/includes/class-wc-download-auth.php",
+ "before": {
+ "file": "fixtures/G40B/before.php"
+ },
+ "after": {
+ "file": "fixtures/G40B/after.php"
+ },
+ "expect": {
+ "outcome": "finding",
+ "locations": [
+ {
+ "file": "plugins/woocommerce/includes/class-wc-download-auth.php",
+ "lines": [
+ 8,
+ 9
+ ],
+ "anchor": " hash_equals( $expected, $mac );"
+ }
+ ]
+ }
+ },
+ {
+ "id": "G40C-v3",
+ "path": "plugins/woocommerce/includes/class-wc-download-auth.php",
+ "before": {
+ "file": "fixtures/G40C/before.php"
+ },
+ "after": {
+ "file": "fixtures/G40C/after.php"
+ },
+ "expect": {
+ "outcome": "quiet"
+ }
+ },
+ {
+ "id": "G40D-v3",
+ "path": "plugins/woocommerce/includes/class-wc-download-auth.php",
+ "before": {
+ "file": "fixtures/G40D/before.php"
+ },
+ "after": {
+ "file": "fixtures/G40D/after.php"
+ },
+ "expect": {
+ "outcome": "quiet"
+ }
+ }
+ ]
+}
diff --git a/.ai/review/evals/sec-auth-tokens-and-cookies/fixtures/G40A/after.php b/.ai/review/evals/sec-auth-tokens-and-cookies/fixtures/G40A/after.php
new file mode 100644
index 00000000000..ac8e42f81ea
--- /dev/null
+++ b/.ai/review/evals/sec-auth-tokens-and-cookies/fixtures/G40A/after.php
@@ -0,0 +1,11 @@
+<?php
+/* Store snapshot: wc_download_token is '0e830400451993494058024219903391', issued by legacy provisioning. */
+add_action( 'wp_ajax_nopriv_wc_download_token', 'wc_download_token' );
+function wc_download_token() {
+ $token = isset( $_GET['token'] ) && is_string( $_GET['token'] ) ? wp_unslash( $_GET['token'] ) : '';
+ $stored = get_option( 'wc_download_token', '' );
+ if ( ! is_string( $stored ) || '' === $stored || $stored != $token ) { wp_send_json_error( array(), 403 ); return; }
+ wp_send_json( array( 'download_url' => wc_private_download_url() ) );
+}
+
+function wc_private_download_url() { return get_option( 'wc_private_download_url' ); }
diff --git a/.ai/review/evals/sec-auth-tokens-and-cookies/fixtures/G40A/before.php b/.ai/review/evals/sec-auth-tokens-and-cookies/fixtures/G40A/before.php
new file mode 100644
index 00000000000..4b1f168cf7e
--- /dev/null
+++ b/.ai/review/evals/sec-auth-tokens-and-cookies/fixtures/G40A/before.php
@@ -0,0 +1,11 @@
+<?php
+/* Store snapshot: wc_download_token is '0e830400451993494058024219903391', issued by legacy provisioning. */
+add_action( 'wp_ajax_nopriv_wc_download_token', 'wc_download_token' );
+function wc_download_token() {
+ $token = isset( $_GET['token'] ) && is_string( $_GET['token'] ) ? wp_unslash( $_GET['token'] ) : '';
+ $stored = get_option( 'wc_download_token', '' );
+ if ( ! is_string( $stored ) || '' === $stored || ! hash_equals( $stored, $token ) ) { wp_send_json_error( array(), 403 ); return; }
+ wp_send_json( array( 'download_url' => wc_private_download_url() ) );
+}
+
+function wc_private_download_url() { return get_option( 'wc_private_download_url' ); }
diff --git a/.ai/review/evals/sec-auth-tokens-and-cookies/fixtures/G40B/after.php b/.ai/review/evals/sec-auth-tokens-and-cookies/fixtures/G40B/after.php
new file mode 100644
index 00000000000..325f07bfa83
--- /dev/null
+++ b/.ai/review/evals/sec-auth-tokens-and-cookies/fixtures/G40B/after.php
@@ -0,0 +1,13 @@
+<?php
+/* Custom session cookies. wc_read_private_session loads cart and customer data by numeric ID. */
+add_action( 'wp_ajax_nopriv_wc_session_probe', 'wc_session_probe' );
+function wc_session_probe() {
+ $id = isset( $_COOKIE['wc_session_id'] ) && is_string( $_COOKIE['wc_session_id'] ) ? $_COOKIE['wc_session_id'] : '';
+ $mac = isset( $_COOKIE['wc_session_mac'] ) && is_string( $_COOKIE['wc_session_mac'] ) ? $_COOKIE['wc_session_mac'] : '';
+ $expected = hash_hmac( 'sha256', $id, wp_salt( 'auth' ) );
+ hash_equals( $expected, $mac );
+ if ( ! ctype_digit( $id ) ) { wp_send_json_error( array(), 403 ); return; }
+ wp_send_json( wc_read_private_session( $id ) );
+}
+
+function wc_read_private_session( $id ) { return get_option( 'wc_private_session_' . $id ); }
diff --git a/.ai/review/evals/sec-auth-tokens-and-cookies/fixtures/G40B/before.php b/.ai/review/evals/sec-auth-tokens-and-cookies/fixtures/G40B/before.php
new file mode 100644
index 00000000000..543d3020495
--- /dev/null
+++ b/.ai/review/evals/sec-auth-tokens-and-cookies/fixtures/G40B/before.php
@@ -0,0 +1,12 @@
+<?php
+/* Custom session cookies. wc_read_private_session loads cart and customer data by numeric ID. */
+add_action( 'wp_ajax_nopriv_wc_session_probe', 'wc_session_probe' );
+function wc_session_probe() {
+ $id = isset( $_COOKIE['wc_session_id'] ) && is_string( $_COOKIE['wc_session_id'] ) ? $_COOKIE['wc_session_id'] : '';
+ $mac = isset( $_COOKIE['wc_session_mac'] ) && is_string( $_COOKIE['wc_session_mac'] ) ? $_COOKIE['wc_session_mac'] : '';
+ $expected = hash_hmac( 'sha256', $id, wp_salt( 'auth' ) );
+ if ( ! ctype_digit( $id ) || ! hash_equals( $expected, $mac ) ) { wp_send_json_error( array(), 403 ); return; }
+ wp_send_json( wc_read_private_session( $id ) );
+}
+
+function wc_read_private_session( $id ) { return get_option( 'wc_private_session_' . $id ); }
diff --git a/.ai/review/evals/sec-auth-tokens-and-cookies/fixtures/G40C/after.php b/.ai/review/evals/sec-auth-tokens-and-cookies/fixtures/G40C/after.php
new file mode 100644
index 00000000000..0b828b5f91a
--- /dev/null
+++ b/.ai/review/evals/sec-auth-tokens-and-cookies/fixtures/G40C/after.php
@@ -0,0 +1,13 @@
+<?php
+/* Download token lookup; wc_private_download_url returns protected download data. */
+function wc_download_token_matches( $stored, $token ) { return is_string( $stored ) && '' !== $stored && is_string( $token ) && hash_equals( $stored, $token ); }
+
+add_action( 'wp_ajax_nopriv_wc_download_token', 'wc_download_token' );
+function wc_download_token() {
+ $token = isset( $_GET['token'] ) && is_string( $_GET['token'] ) ? wp_unslash( $_GET['token'] ) : '';
+ $stored = get_option( 'wc_download_token', '' );
+ if ( ! wc_download_token_matches( $stored, $token ) ) { wp_send_json_error( array(), 403 ); return; }
+ wp_send_json( array( 'download_url' => wc_private_download_url() ) );
+}
+
+function wc_private_download_url() { return get_option( 'wc_private_download_url' ); }
diff --git a/.ai/review/evals/sec-auth-tokens-and-cookies/fixtures/G40C/before.php b/.ai/review/evals/sec-auth-tokens-and-cookies/fixtures/G40C/before.php
new file mode 100644
index 00000000000..7ac8e4999f7
--- /dev/null
+++ b/.ai/review/evals/sec-auth-tokens-and-cookies/fixtures/G40C/before.php
@@ -0,0 +1,11 @@
+<?php
+/* Download token lookup; wc_private_download_url returns protected download data. */
+add_action( 'wp_ajax_nopriv_wc_download_token', 'wc_download_token' );
+function wc_download_token() {
+ $token = isset( $_GET['token'] ) && is_string( $_GET['token'] ) ? wp_unslash( $_GET['token'] ) : '';
+ $stored = get_option( 'wc_download_token', '' );
+ if ( ! is_string( $stored ) || '' === $stored || ! hash_equals( $stored, $token ) ) { wp_send_json_error( array(), 403 ); return; }
+ wp_send_json( array( 'download_url' => wc_private_download_url() ) );
+}
+
+function wc_private_download_url() { return get_option( 'wc_private_download_url' ); }
diff --git a/.ai/review/evals/sec-auth-tokens-and-cookies/fixtures/G40D/after.php b/.ai/review/evals/sec-auth-tokens-and-cookies/fixtures/G40D/after.php
new file mode 100644
index 00000000000..8147ce7225c
--- /dev/null
+++ b/.ai/review/evals/sec-auth-tokens-and-cookies/fixtures/G40D/after.php
@@ -0,0 +1,14 @@
+<?php
+/* Custom session cookies. wc_read_private_session loads cart and customer data by numeric ID. */
+add_action( 'wp_ajax_nopriv_wc_session_probe', 'wc_session_probe' );
+function wc_session_probe() {
+ $id = isset( $_COOKIE['wc_session_id'] ) && is_string( $_COOKIE['wc_session_id'] ) ? $_COOKIE['wc_session_id'] : '';
+ $mac = isset( $_COOKIE['wc_session_mac'] ) && is_string( $_COOKIE['wc_session_mac'] ) ? $_COOKIE['wc_session_mac'] : '';
+ $expected = hash_hmac( 'sha256', $id, wp_salt( 'auth' ) );
+ if ( ! ctype_digit( $id ) ) { wp_send_json_error( array(), 403 ); return; }
+ if ( ! hash_equals( $expected, $mac ) ) { wp_send_json_error( array(), 403 ); return; }
+ $session_id = (int) $id;
+ wp_send_json( wc_read_private_session( $session_id ) );
+}
+
+function wc_read_private_session( $id ) { return get_option( 'wc_private_session_' . (int) $id ); }
diff --git a/.ai/review/evals/sec-auth-tokens-and-cookies/fixtures/G40D/before.php b/.ai/review/evals/sec-auth-tokens-and-cookies/fixtures/G40D/before.php
new file mode 100644
index 00000000000..5b690869cfa
--- /dev/null
+++ b/.ai/review/evals/sec-auth-tokens-and-cookies/fixtures/G40D/before.php
@@ -0,0 +1,12 @@
+<?php
+/* Custom session cookies. wc_read_private_session loads cart and customer data by numeric ID. */
+add_action( 'wp_ajax_nopriv_wc_session_probe', 'wc_session_probe' );
+function wc_session_probe() {
+ $id = isset( $_COOKIE['wc_session_id'] ) && is_string( $_COOKIE['wc_session_id'] ) ? $_COOKIE['wc_session_id'] : '';
+ $mac = isset( $_COOKIE['wc_session_mac'] ) && is_string( $_COOKIE['wc_session_mac'] ) ? $_COOKIE['wc_session_mac'] : '';
+ $expected = hash_hmac( 'sha256', $id, wp_salt( 'auth' ) );
+ if ( ! ctype_digit( $id ) || ! hash_equals( $expected, $mac ) ) { wp_send_json_error( array(), 403 ); return; }
+ wp_send_json( wc_read_private_session( $id ) );
+}
+
+function wc_read_private_session( $id ) { return get_option( 'wc_private_session_' . (int) $id ); }
diff --git a/.ai/review/evals/sec-order-cart-ownership/cases.json b/.ai/review/evals/sec-order-cart-ownership/cases.json
new file mode 100644
index 00000000000..2417bc55b2d
--- /dev/null
+++ b/.ai/review/evals/sec-order-cart-ownership/cases.json
@@ -0,0 +1,77 @@
+{
+ "version": 1,
+ "cases": [
+ {
+ "id": "G38B-v3",
+ "path": "plugins/woocommerce/includes/class-wc-order-preview.php",
+ "before": {
+ "file": "fixtures/G38B/before.php"
+ },
+ "after": {
+ "file": "fixtures/G38B/after.php"
+ },
+ "expect": {
+ "outcome": "finding",
+ "locations": [
+ {
+ "file": "plugins/woocommerce/includes/class-wc-order-preview.php",
+ "lines": [
+ 10,
+ 10
+ ],
+ "anchor": " if ( (int) $order->get_customer_id() !== get_current_user_id() ) { wp_send_json_error( array(), 403 ); return; }"
+ }
+ ]
+ }
+ },
+ {
+ "id": "G38C-v3",
+ "path": "plugins/woocommerce/includes/class-wc-order-preview.php",
+ "before": {
+ "file": "fixtures/G38C/before.php"
+ },
+ "after": {
+ "file": "fixtures/G38C/after.php"
+ },
+ "expect": {
+ "outcome": "quiet"
+ }
+ },
+ {
+ "id": "G38D-v3",
+ "path": "plugins/woocommerce/includes/class-wc-order-preview.php",
+ "before": {
+ "file": "fixtures/G38D/before.php"
+ },
+ "after": {
+ "file": "fixtures/G38D/after.php"
+ },
+ "expect": {
+ "outcome": "quiet"
+ }
+ },
+ {
+ "id": "G38A-v5",
+ "path": "plugins/woocommerce/includes/class-wc-order-preview.php",
+ "before": {
+ "code": "<?php\n/* Customer order-email lookup through admin-ajax. */\nadd_action( 'wp_ajax_wc_private_order_email', 'wc_private_order_email' );\nfunction wc_private_order_email() {\n $id = isset( $_GET['order_id'] ) && is_scalar( $_GET['order_id'] ) ? absint( $_GET['order_id'] ) : 0;\n $order = wc_get_order( $id );\n if ( ! $order instanceof WC_Order ) { wp_send_json_error( array(), 404 ); return; }\n if ( ! current_user_can( 'view_order', $id ) ) { wp_send_json_error( array(), 403 ); return; }\n wp_send_json( array( 'billing_email' => $order->get_billing_email() ) );\n}\n"
+ },
+ "after": {
+ "code": "<?php\n/* Customer order-email lookup through admin-ajax. */\nadd_action( 'wp_ajax_wc_private_order_email', 'wc_private_order_email' );\nfunction wc_private_order_email() {\n $id = isset( $_GET['order_id'] ) && is_scalar( $_GET['order_id'] ) ? absint( $_GET['order_id'] ) : 0;\n $order = wc_get_order( $id );\n if ( ! $order instanceof WC_Order ) { wp_send_json_error( array(), 404 ); return; }\n if ( ! is_user_logged_in() ) { wp_send_json_error( array(), 403 ); return; }\n wp_send_json( array( 'billing_email' => $order->get_billing_email() ) );\n}\n"
+ },
+ "expect": {
+ "outcome": "finding",
+ "locations": [
+ {
+ "file": "plugins/woocommerce/includes/class-wc-order-preview.php",
+ "lines": [
+ 8,
+ 8
+ ],
+ "anchor": " if ( ! is_user_logged_in() ) { wp_send_json_error( array(), 403 ); return; }"
+ }
+ ]
+ }
+ }
+ ]
+}
diff --git a/.ai/review/evals/sec-order-cart-ownership/fixtures/G38B/after.php b/.ai/review/evals/sec-order-cart-ownership/fixtures/G38B/after.php
new file mode 100644
index 00000000000..5985e4a3f72
--- /dev/null
+++ b/.ai/review/evals/sec-order-cart-ownership/fixtures/G38B/after.php
@@ -0,0 +1,12 @@
+<?php
+/* Guest order-email lookup through admin-ajax. */
+add_action( 'wp_ajax_nopriv_wc_guest_order_email', 'wc_guest_order_email' );
+function wc_guest_order_email() {
+ $id = isset( $_GET['order_id'] ) && is_scalar( $_GET['order_id'] ) ? absint( $_GET['order_id'] ) : 0;
+ $key = isset( $_GET['key'] ) && is_string( $_GET['key'] ) ? wp_unslash( $_GET['key'] ) : '';
+ $email = isset( $_GET['email'] ) && is_string( $_GET['email'] ) ? wp_unslash( $_GET['email'] ) : '';
+ $order = wc_get_order( $id );
+ if ( ! $order instanceof WC_Order || 0 !== (int) $order->get_customer_id() ) { wp_send_json_error( array(), 404 ); return; }
+ if ( (int) $order->get_customer_id() !== get_current_user_id() ) { wp_send_json_error( array(), 403 ); return; }
+ wp_send_json( array( 'billing_email' => $order->get_billing_email() ) );
+}
diff --git a/.ai/review/evals/sec-order-cart-ownership/fixtures/G38B/before.php b/.ai/review/evals/sec-order-cart-ownership/fixtures/G38B/before.php
new file mode 100644
index 00000000000..4bb568250a4
--- /dev/null
+++ b/.ai/review/evals/sec-order-cart-ownership/fixtures/G38B/before.php
@@ -0,0 +1,12 @@
+<?php
+/* Guest order-email lookup through admin-ajax. */
+add_action( 'wp_ajax_nopriv_wc_guest_order_email', 'wc_guest_order_email' );
+function wc_guest_order_email() {
+ $id = isset( $_GET['order_id'] ) && is_scalar( $_GET['order_id'] ) ? absint( $_GET['order_id'] ) : 0;
+ $key = isset( $_GET['key'] ) && is_string( $_GET['key'] ) ? wp_unslash( $_GET['key'] ) : '';
+ $email = isset( $_GET['email'] ) && is_string( $_GET['email'] ) ? wp_unslash( $_GET['email'] ) : '';
+ $order = wc_get_order( $id );
+ if ( ! $order instanceof WC_Order || 0 !== (int) $order->get_customer_id() ) { wp_send_json_error( array(), 404 ); return; }
+ if ( ! hash_equals( $order->get_order_key(), $key ) || ! hash_equals( $order->get_billing_email(), $email ) ) { wp_send_json_error( array(), 403 ); return; }
+ wp_send_json( array( 'billing_email' => $order->get_billing_email() ) );
+}
diff --git a/.ai/review/evals/sec-order-cart-ownership/fixtures/G38C/after.php b/.ai/review/evals/sec-order-cart-ownership/fixtures/G38C/after.php
new file mode 100644
index 00000000000..17d16d1f34c
--- /dev/null
+++ b/.ai/review/evals/sec-order-cart-ownership/fixtures/G38C/after.php
@@ -0,0 +1,10 @@
+<?php
+/* Customer order-email lookup through admin-ajax. */
+add_action( 'wp_ajax_wc_private_order_email', 'wc_private_order_email' );
+function wc_private_order_email() {
+ $id = isset( $_GET['order_id'] ) && is_scalar( $_GET['order_id'] ) ? absint( $_GET['order_id'] ) : 0;
+ $order = wc_get_order( $id );
+ if ( ! $order instanceof WC_Order ) { wp_send_json_error( array(), 404 ); return; }
+ if ( ! current_user_can( 'view_order', $id ) ) { wp_send_json_error( array(), 403 ); return; }
+ wp_send_json( array( 'billing_email' => $order->get_billing_email(), 'number' => $order->get_order_number() ) );
+}
diff --git a/.ai/review/evals/sec-order-cart-ownership/fixtures/G38C/before.php b/.ai/review/evals/sec-order-cart-ownership/fixtures/G38C/before.php
new file mode 100644
index 00000000000..61401e7710f
--- /dev/null
+++ b/.ai/review/evals/sec-order-cart-ownership/fixtures/G38C/before.php
@@ -0,0 +1,10 @@
+<?php
+/* Customer order-email lookup through admin-ajax. */
+add_action( 'wp_ajax_wc_private_order_email', 'wc_private_order_email' );
+function wc_private_order_email() {
+ $id = isset( $_GET['order_id'] ) && is_scalar( $_GET['order_id'] ) ? absint( $_GET['order_id'] ) : 0;
+ $order = wc_get_order( $id );
+ if ( ! $order instanceof WC_Order ) { wp_send_json_error( array(), 404 ); return; }
+ if ( ! current_user_can( 'view_order', $id ) ) { wp_send_json_error( array(), 403 ); return; }
+ wp_send_json( array( 'billing_email' => $order->get_billing_email() ) );
+}
diff --git a/.ai/review/evals/sec-order-cart-ownership/fixtures/G38D/after.php b/.ai/review/evals/sec-order-cart-ownership/fixtures/G38D/after.php
new file mode 100644
index 00000000000..d7b9022d1ec
--- /dev/null
+++ b/.ai/review/evals/sec-order-cart-ownership/fixtures/G38D/after.php
@@ -0,0 +1,10 @@
+<?php
+/* Read-only staff order lookup for manage_woocommerce users. */
+add_action( 'wp_ajax_wc_staff_order_email', 'wc_staff_order_email' );
+function wc_staff_order_email() {
+ if ( ! current_user_can( 'manage_woocommerce' ) ) { wp_send_json_error( array(), 403 ); return; }
+ $id = isset( $_GET['order_id'] ) && is_scalar( $_GET['order_id'] ) ? absint( $_GET['order_id'] ) : 0;
+ $order = wc_get_order( $id );
+ if ( ! $order instanceof WC_Order ) { wp_send_json_error( array(), 404 ); return; }
+ wp_send_json( array( 'billing_email' => $order->get_billing_email(), 'customer_id' => $order->get_customer_id() ) );
+}
diff --git a/.ai/review/evals/sec-order-cart-ownership/fixtures/G38D/before.php b/.ai/review/evals/sec-order-cart-ownership/fixtures/G38D/before.php
new file mode 100644
index 00000000000..bfe5f9c40ce
--- /dev/null
+++ b/.ai/review/evals/sec-order-cart-ownership/fixtures/G38D/before.php
@@ -0,0 +1,10 @@
+<?php
+/* Read-only staff order lookup for manage_woocommerce users. */
+add_action( 'wp_ajax_wc_staff_order_email', 'wc_staff_order_email' );
+function wc_staff_order_email() {
+ if ( ! current_user_can( 'manage_woocommerce' ) ) { wp_send_json_error( array(), 403 ); return; }
+ $id = isset( $_GET['order_id'] ) && is_scalar( $_GET['order_id'] ) ? absint( $_GET['order_id'] ) : 0;
+ $order = wc_get_order( $id );
+ if ( ! $order instanceof WC_Order ) { wp_send_json_error( array(), 404 ); return; }
+ wp_send_json( array( 'billing_email' => $order->get_billing_email() ) );
+}
diff --git a/.ai/review/evals/sec-rest-and-ajax-permissions/cases.json b/.ai/review/evals/sec-rest-and-ajax-permissions/cases.json
new file mode 100644
index 00000000000..d36164f4fdf
--- /dev/null
+++ b/.ai/review/evals/sec-rest-and-ajax-permissions/cases.json
@@ -0,0 +1,77 @@
+{
+ "version": 1,
+ "cases": [
+ {
+ "id": "G37A-v5",
+ "path": "plugins/woocommerce/includes/rest-api/class-wc-rest-eval-settings.php",
+ "before": {
+ "file": "fixtures/G37A/before.php"
+ },
+ "after": {
+ "file": "fixtures/G37A/after.php"
+ },
+ "expect": {
+ "outcome": "finding",
+ "locations": [
+ {
+ "file": "plugins/woocommerce/includes/rest-api/class-wc-rest-eval-settings.php",
+ "lines": [
+ 6,
+ 6
+ ],
+ "anchor": " 'permission_callback' => '__return_true',"
+ }
+ ]
+ }
+ },
+ {
+ "id": "G37B-v3",
+ "path": "plugins/woocommerce/includes/rest-api/class-wc-rest-eval-settings.php",
+ "before": {
+ "file": "fixtures/G37B/before.php"
+ },
+ "after": {
+ "file": "fixtures/G37B/after.php"
+ },
+ "expect": {
+ "outcome": "finding",
+ "locations": [
+ {
+ "file": "plugins/woocommerce/includes/rest-api/class-wc-rest-eval-settings.php",
+ "lines": [
+ 7,
+ 7
+ ],
+ "anchor": " wp_verify_nonce( $nonce, 'wc-reset-store-label' );"
+ }
+ ]
+ }
+ },
+ {
+ "id": "G37C-v3",
+ "path": "plugins/woocommerce/includes/rest-api/class-wc-rest-eval-settings.php",
+ "before": {
+ "file": "fixtures/G37C/before.php"
+ },
+ "after": {
+ "file": "fixtures/G37C/after.php"
+ },
+ "expect": {
+ "outcome": "quiet"
+ }
+ },
+ {
+ "id": "G37D-v3",
+ "path": "plugins/woocommerce/includes/rest-api/class-wc-rest-eval-settings.php",
+ "before": {
+ "file": "fixtures/G37D/before.php"
+ },
+ "after": {
+ "file": "fixtures/G37D/after.php"
+ },
+ "expect": {
+ "outcome": "quiet"
+ }
+ }
+ ]
+}
diff --git a/.ai/review/evals/sec-rest-and-ajax-permissions/fixtures/G37A/after.php b/.ai/review/evals/sec-rest-and-ajax-permissions/fixtures/G37A/after.php
new file mode 100644
index 00000000000..e0c4044b23d
--- /dev/null
+++ b/.ai/review/evals/sec-rest-and-ajax-permissions/fixtures/G37A/after.php
@@ -0,0 +1,9 @@
+<?php
+/* Store-label settings endpoint. */
+add_action( 'rest_api_init', static function() {
+ register_rest_route( 'wc-eval/v1', '/store-label', array(
+ 'methods' => 'POST',
+ 'permission_callback' => '__return_true',
+ 'callback' => static function( $request ) { $label = $request->get_param( 'label' ); if ( ! is_string( $label ) ) { return new WP_Error( 'invalid_label', 'Invalid label', array( 'status' => 400 ) ); } update_option( 'wc_store_label', sanitize_text_field( $label ) ); return rest_ensure_response( array( 'saved' => true ) ); },
+ ) );
+} );
diff --git a/.ai/review/evals/sec-rest-and-ajax-permissions/fixtures/G37A/before.php b/.ai/review/evals/sec-rest-and-ajax-permissions/fixtures/G37A/before.php
new file mode 100644
index 00000000000..1ba21e82e24
--- /dev/null
+++ b/.ai/review/evals/sec-rest-and-ajax-permissions/fixtures/G37A/before.php
@@ -0,0 +1,9 @@
+<?php
+/* Store-label settings endpoint. */
+add_action( 'rest_api_init', static function() {
+ register_rest_route( 'wc-eval/v1', '/store-label', array(
+ 'methods' => 'POST',
+ 'permission_callback' => static function() { return current_user_can( 'manage_woocommerce' ); },
+ 'callback' => static function( $request ) { $label = $request->get_param( 'label' ); if ( ! is_string( $label ) ) { return new WP_Error( 'invalid_label', 'Invalid label', array( 'status' => 400 ) ); } update_option( 'wc_store_label', sanitize_text_field( $label ) ); return rest_ensure_response( array( 'saved' => true ) ); },
+ ) );
+} );
diff --git a/.ai/review/evals/sec-rest-and-ajax-permissions/fixtures/G37B/after.php b/.ai/review/evals/sec-rest-and-ajax-permissions/fixtures/G37B/after.php
new file mode 100644
index 00000000000..04771b52d17
--- /dev/null
+++ b/.ai/review/evals/sec-rest-and-ajax-permissions/fixtures/G37B/after.php
@@ -0,0 +1,10 @@
+<?php
+/* Store-label settings AJAX handler. */
+add_action( 'wp_ajax_wc_reset_store_label', 'wc_reset_store_label' );
+function wc_reset_store_label() {
+ if ( ! current_user_can( 'manage_woocommerce' ) ) { wp_send_json_error( array(), 403 ); return; }
+ $nonce = isset( $_REQUEST['_ajax_nonce'] ) && is_string( $_REQUEST['_ajax_nonce'] ) ? $_REQUEST['_ajax_nonce'] : '';
+ wp_verify_nonce( $nonce, 'wc-reset-store-label' );
+ update_option( 'wc_store_label', '' );
+ wp_send_json_success();
+}
diff --git a/.ai/review/evals/sec-rest-and-ajax-permissions/fixtures/G37B/before.php b/.ai/review/evals/sec-rest-and-ajax-permissions/fixtures/G37B/before.php
new file mode 100644
index 00000000000..615c1d05324
--- /dev/null
+++ b/.ai/review/evals/sec-rest-and-ajax-permissions/fixtures/G37B/before.php
@@ -0,0 +1,9 @@
+<?php
+/* Store-label settings AJAX handler. */
+add_action( 'wp_ajax_wc_reset_store_label', 'wc_reset_store_label' );
+function wc_reset_store_label() {
+ if ( ! current_user_can( 'manage_woocommerce' ) ) { wp_send_json_error( array(), 403 ); return; }
+ check_ajax_referer( 'wc-reset-store-label' );
+ update_option( 'wc_store_label', '' );
+ wp_send_json_success();
+}
diff --git a/.ai/review/evals/sec-rest-and-ajax-permissions/fixtures/G37C/after.php b/.ai/review/evals/sec-rest-and-ajax-permissions/fixtures/G37C/after.php
new file mode 100644
index 00000000000..e56f9beec64
--- /dev/null
+++ b/.ai/review/evals/sec-rest-and-ajax-permissions/fixtures/G37C/after.php
@@ -0,0 +1,5 @@
+<?php
+/* Public storefront name and description endpoint. */
+add_action( 'rest_api_init', static function() {
+ register_rest_route( 'wc-eval/v1', '/public-name', array( 'methods' => 'GET', 'permission_callback' => '__return_true', 'callback' => static function() { return rest_ensure_response( array( 'name' => get_bloginfo( 'name' ), 'description' => get_bloginfo( 'description' ) ) ); } ) );
+} );
diff --git a/.ai/review/evals/sec-rest-and-ajax-permissions/fixtures/G37C/before.php b/.ai/review/evals/sec-rest-and-ajax-permissions/fixtures/G37C/before.php
new file mode 100644
index 00000000000..daed8f900a3
--- /dev/null
+++ b/.ai/review/evals/sec-rest-and-ajax-permissions/fixtures/G37C/before.php
@@ -0,0 +1,5 @@
+<?php
+/* Public storefront name and description endpoint. */
+add_action( 'rest_api_init', static function() {
+ register_rest_route( 'wc-eval/v1', '/public-name', array( 'methods' => 'GET', 'permission_callback' => '__return_true', 'callback' => static function() { return rest_ensure_response( array( 'name' => get_bloginfo( 'name' ) ) ); } ) );
+} );
diff --git a/.ai/review/evals/sec-rest-and-ajax-permissions/fixtures/G37D/after.php b/.ai/review/evals/sec-rest-and-ajax-permissions/fixtures/G37D/after.php
new file mode 100644
index 00000000000..5920e0911fe
--- /dev/null
+++ b/.ai/review/evals/sec-rest-and-ajax-permissions/fixtures/G37D/after.php
@@ -0,0 +1,10 @@
+<?php
+/* Admin clients submit _ajax_nonce; legacy clients may submit _wpnonce. */
+add_action( 'wp_ajax_wc_reset_store_label', 'wc_reset_store_label' );
+function wc_reset_store_label() {
+ if ( ! current_user_can( 'manage_woocommerce' ) ) { wp_send_json_error( array(), 403 ); return; }
+ $nonce_result = check_ajax_referer( 'wc-reset-store-label', false, false );
+ if ( false === $nonce_result ) { wp_die( -1, 403 ); return; }
+ update_option( 'wc_store_label', '' );
+ wp_send_json_success();
+}
diff --git a/.ai/review/evals/sec-rest-and-ajax-permissions/fixtures/G37D/before.php b/.ai/review/evals/sec-rest-and-ajax-permissions/fixtures/G37D/before.php
new file mode 100644
index 00000000000..e241aebddfc
--- /dev/null
+++ b/.ai/review/evals/sec-rest-and-ajax-permissions/fixtures/G37D/before.php
@@ -0,0 +1,9 @@
+<?php
+/* Admin clients submit _ajax_nonce; legacy clients may submit _wpnonce. */
+add_action( 'wp_ajax_wc_reset_store_label', 'wc_reset_store_label' );
+function wc_reset_store_label() {
+ if ( ! current_user_can( 'manage_woocommerce' ) ) { wp_send_json_error( array(), 403 ); return; }
+ check_ajax_referer( 'wc-reset-store-label' );
+ update_option( 'wc_store_label', '' );
+ wp_send_json_success();
+}
diff --git a/.ai/review/evals/sec-sanitize-and-escape/cases.json b/.ai/review/evals/sec-sanitize-and-escape/cases.json
new file mode 100644
index 00000000000..7e70955ae5c
--- /dev/null
+++ b/.ai/review/evals/sec-sanitize-and-escape/cases.json
@@ -0,0 +1,77 @@
+{
+ "version": 1,
+ "cases": [
+ {
+ "id": "G39B-v3",
+ "path": "plugins/woocommerce/includes/class-wc-search-label.php",
+ "before": {
+ "file": "fixtures/G39B/before.php"
+ },
+ "after": {
+ "file": "fixtures/G39B/after.php"
+ },
+ "expect": {
+ "outcome": "finding",
+ "locations": [
+ {
+ "file": "plugins/woocommerce/includes/class-wc-search-label.php",
+ "lines": [
+ 3,
+ 3
+ ],
+ "anchor": "add_shortcode( 'wc_entity_notice', static function() { $notice = isset( $_GET['notice'] ) && is_string( $_GET['notice'] ) ? wp_unslash( $_GET['notice'] ) : ''; $notice = wp_kses_post( $notice ); return '<div class=\"wc-notice\">' . html_entity_decode( $notice, ENT_QUOTES, 'UTF-8' ) . '</div>'; } );"
+ }
+ ]
+ }
+ },
+ {
+ "id": "G39C-v3",
+ "path": "plugins/woocommerce/includes/class-wc-search-label.php",
+ "before": {
+ "file": "fixtures/G39C/before.php"
+ },
+ "after": {
+ "file": "fixtures/G39C/after.php"
+ },
+ "expect": {
+ "outcome": "quiet"
+ }
+ },
+ {
+ "id": "G39D-v3",
+ "path": "plugins/woocommerce/client/blocks/assets/js/blocks/cart/customer-note.js",
+ "before": {
+ "file": "fixtures/G39D/before.js"
+ },
+ "after": {
+ "file": "fixtures/G39D/after.js"
+ },
+ "expect": {
+ "outcome": "quiet"
+ }
+ },
+ {
+ "id": "G39A-v6",
+ "path": "plugins/woocommerce/includes/class-wc-search-label.php",
+ "before": {
+ "code": "<?php\n/* Shortcode rendered on a public page. */\nadd_shortcode( 'wc_search_label', static function() {\n $query = isset( $_GET['q'] ) && is_string( $_GET['q'] ) ? wp_unslash( $_GET['q'] ) : '';\n return '<span class=\"wc-search-label\">' . esc_html( $query ) . '</span>';\n} );\n"
+ },
+ "after": {
+ "code": "<?php\n/* Shortcode rendered on a public page. */\nadd_shortcode( 'wc_search_label', static function() {\n $query = isset( $_GET['q'] ) && is_string( $_GET['q'] ) ? wp_unslash( $_GET['q'] ) : '';\n return '<span class=\"wc-search-label\">' . $query . '</span>';\n} );\n"
+ },
+ "expect": {
+ "outcome": "finding",
+ "locations": [
+ {
+ "file": "plugins/woocommerce/includes/class-wc-search-label.php",
+ "lines": [
+ 5,
+ 5
+ ],
+ "anchor": " return '<span class=\"wc-search-label\">' . $query . '</span>';"
+ }
+ ]
+ }
+ }
+ ]
+}
diff --git a/.ai/review/evals/sec-sanitize-and-escape/fixtures/G39B/after.php b/.ai/review/evals/sec-sanitize-and-escape/fixtures/G39B/after.php
new file mode 100644
index 00000000000..1a5380810d1
--- /dev/null
+++ b/.ai/review/evals/sec-sanitize-and-escape/fixtures/G39B/after.php
@@ -0,0 +1,3 @@
+<?php
+/* Shortcode rendered on a public page. */
+add_shortcode( 'wc_entity_notice', static function() { $notice = isset( $_GET['notice'] ) && is_string( $_GET['notice'] ) ? wp_unslash( $_GET['notice'] ) : ''; $notice = wp_kses_post( $notice ); return '<div class="wc-notice">' . html_entity_decode( $notice, ENT_QUOTES, 'UTF-8' ) . '</div>'; } );
diff --git a/.ai/review/evals/sec-sanitize-and-escape/fixtures/G39B/before.php b/.ai/review/evals/sec-sanitize-and-escape/fixtures/G39B/before.php
new file mode 100644
index 00000000000..982100dff27
--- /dev/null
+++ b/.ai/review/evals/sec-sanitize-and-escape/fixtures/G39B/before.php
@@ -0,0 +1,7 @@
+<?php
+/* Shortcode rendered on a public page. */
+add_shortcode( 'wc_entity_notice', static function() {
+ $notice = isset( $_GET['notice'] ) && is_string( $_GET['notice'] ) ? wp_unslash( $_GET['notice'] ) : '';
+ $notice = html_entity_decode( $notice, ENT_QUOTES, 'UTF-8' );
+ return '<div class="wc-notice">' . wp_kses_post( $notice ) . '</div>';
+} );
diff --git a/.ai/review/evals/sec-sanitize-and-escape/fixtures/G39C/after.php b/.ai/review/evals/sec-sanitize-and-escape/fixtures/G39C/after.php
new file mode 100644
index 00000000000..a9f9244eedf
--- /dev/null
+++ b/.ai/review/evals/sec-sanitize-and-escape/fixtures/G39C/after.php
@@ -0,0 +1,3 @@
+<?php
+/* Shortcode rendered on a public page; closure is local to this callback. */
+add_shortcode( 'wc_search_label', static function() { $label_text = static function( $value ) { return $value; }; $q = isset( $_GET['q'] ) && is_string( $_GET['q'] ) ? wp_unslash( $_GET['q'] ) : ''; return '<span>' . esc_html( $label_text( $q ) ) . '</span>'; } );
diff --git a/.ai/review/evals/sec-sanitize-and-escape/fixtures/G39C/before.php b/.ai/review/evals/sec-sanitize-and-escape/fixtures/G39C/before.php
new file mode 100644
index 00000000000..655bd41ed94
--- /dev/null
+++ b/.ai/review/evals/sec-sanitize-and-escape/fixtures/G39C/before.php
@@ -0,0 +1,3 @@
+<?php
+/* Shortcode rendered on a public page; closure is local to this callback. */
+add_shortcode( 'wc_search_label', static function() { $label_text = static function( $value ) { return esc_html( $value ); }; $q = isset( $_GET['q'] ) && is_string( $_GET['q'] ) ? wp_unslash( $_GET['q'] ) : ''; return '<span>' . $label_text( $q ) . '</span>'; } );
diff --git a/.ai/review/evals/sec-sanitize-and-escape/fixtures/G39D/after.js b/.ai/review/evals/sec-sanitize-and-escape/fixtures/G39D/after.js
new file mode 100644
index 00000000000..ab90f2f4eef
--- /dev/null
+++ b/.ai/review/evals/sec-sanitize-and-escape/fixtures/G39D/after.js
@@ -0,0 +1,4 @@
+/* Customer note is a string. UI displays decoded text. */
+import { createElement } from '@wordpress/element';
+import { decodeEntities } from '@wordpress/html-entities';
+export function CustomerNote( { note } ) { return createElement( 'p', {}, decodeEntities( note ) ); }
diff --git a/.ai/review/evals/sec-sanitize-and-escape/fixtures/G39D/before.js b/.ai/review/evals/sec-sanitize-and-escape/fixtures/G39D/before.js
new file mode 100644
index 00000000000..c15685c0210
--- /dev/null
+++ b/.ai/review/evals/sec-sanitize-and-escape/fixtures/G39D/before.js
@@ -0,0 +1,3 @@
+/* Customer note is a string. UI displays decoded text. */
+import { createElement } from '@wordpress/element';
+export function CustomerNote( { note } ) { return createElement( 'p', {}, note ); }
diff --git a/.ai/review/evals/sec-sql-prepare-and-identifiers/cases.json b/.ai/review/evals/sec-sql-prepare-and-identifiers/cases.json
new file mode 100644
index 00000000000..06d186ea47f
--- /dev/null
+++ b/.ai/review/evals/sec-sql-prepare-and-identifiers/cases.json
@@ -0,0 +1,77 @@
+{
+ "version": 1,
+ "cases": [
+ {
+ "id": "G36A-v3",
+ "path": "plugins/woocommerce/includes/class-wc-catalog-probe.php",
+ "before": {
+ "file": "fixtures/G36A/before.php"
+ },
+ "after": {
+ "file": "fixtures/G36A/after.php"
+ },
+ "expect": {
+ "outcome": "finding",
+ "locations": [
+ {
+ "file": "plugins/woocommerce/includes/class-wc-catalog-probe.php",
+ "lines": [
+ 7,
+ 7
+ ],
+ "anchor": " $sql = \"SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_type='product' AND post_status='publish' AND post_name='$slug'\";"
+ }
+ ]
+ }
+ },
+ {
+ "id": "G36B-v4",
+ "path": "plugins/woocommerce/includes/class-wc-catalog-probe.php",
+ "before": {
+ "file": "fixtures/G36B/before.php"
+ },
+ "after": {
+ "file": "fixtures/G36B/after.php"
+ },
+ "expect": {
+ "outcome": "finding",
+ "locations": [
+ {
+ "file": "plugins/woocommerce/includes/class-wc-catalog-probe.php",
+ "lines": [
+ 7,
+ 8
+ ],
+ "anchor": " $orderby = esc_sql( $requested );"
+ }
+ ]
+ }
+ },
+ {
+ "id": "G36C-v3",
+ "path": "plugins/woocommerce/includes/class-wc-catalog-probe.php",
+ "before": {
+ "file": "fixtures/G36C/before.php"
+ },
+ "after": {
+ "file": "fixtures/G36C/after.php"
+ },
+ "expect": {
+ "outcome": "quiet"
+ }
+ },
+ {
+ "id": "G36D-v3",
+ "path": "plugins/woocommerce/includes/class-wc-catalog-probe.php",
+ "before": {
+ "file": "fixtures/G36D/before.php"
+ },
+ "after": {
+ "file": "fixtures/G36D/after.php"
+ },
+ "expect": {
+ "outcome": "quiet"
+ }
+ }
+ ]
+}
diff --git a/.ai/review/evals/sec-sql-prepare-and-identifiers/fixtures/G36A/after.php b/.ai/review/evals/sec-sql-prepare-and-identifiers/fixtures/G36A/after.php
new file mode 100644
index 00000000000..75fdfc7795f
--- /dev/null
+++ b/.ai/review/evals/sec-sql-prepare-and-identifiers/fixtures/G36A/after.php
@@ -0,0 +1,9 @@
+<?php
+/* Public catalog count AJAX endpoint. */
+add_action( 'wp_ajax_nopriv_wc_catalog_count', 'wc_catalog_count' );
+function wc_catalog_count() {
+ global $wpdb;
+ $slug = isset( $_GET['slug'] ) && is_string( $_GET['slug'] ) ? wp_unslash( $_GET['slug'] ) : '';
+ $sql = "SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_type='product' AND post_status='publish' AND post_name='$slug'";
+ wp_send_json( (int) $wpdb->get_var( $sql ) );
+}
diff --git a/.ai/review/evals/sec-sql-prepare-and-identifiers/fixtures/G36A/before.php b/.ai/review/evals/sec-sql-prepare-and-identifiers/fixtures/G36A/before.php
new file mode 100644
index 00000000000..8df40af2b33
--- /dev/null
+++ b/.ai/review/evals/sec-sql-prepare-and-identifiers/fixtures/G36A/before.php
@@ -0,0 +1,9 @@
+<?php
+/* Public catalog count AJAX endpoint. */
+add_action( 'wp_ajax_nopriv_wc_catalog_count', 'wc_catalog_count' );
+function wc_catalog_count() {
+ global $wpdb;
+ $slug = isset( $_GET['slug'] ) && is_string( $_GET['slug'] ) ? wp_unslash( $_GET['slug'] ) : '';
+ $sql = $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_type='product' AND post_status='publish' AND post_name=%s", $slug );
+ wp_send_json( (int) $wpdb->get_var( $sql ) );
+}
diff --git a/.ai/review/evals/sec-sql-prepare-and-identifiers/fixtures/G36B/after.php b/.ai/review/evals/sec-sql-prepare-and-identifiers/fixtures/G36B/after.php
new file mode 100644
index 00000000000..0f53e67f2f3
--- /dev/null
+++ b/.ai/review/evals/sec-sql-prepare-and-identifiers/fixtures/G36B/after.php
@@ -0,0 +1,9 @@
+<?php
+/* Public catalog sort AJAX endpoint; catalog contains published products. */
+add_action( 'wp_ajax_nopriv_wc_catalog_sort', 'wc_catalog_sort' );
+function wc_catalog_sort() {
+ global $wpdb;
+ $requested = isset( $_GET['orderby'] ) && is_string( $_GET['orderby'] ) ? wp_unslash( $_GET['orderby'] ) : 'ID';
+ $orderby = esc_sql( $requested );
+ wp_send_json( $wpdb->get_col( "SELECT ID FROM {$wpdb->posts} WHERE post_type='product' AND post_status='publish' ORDER BY $orderby LIMIT 10" ) );
+}
diff --git a/.ai/review/evals/sec-sql-prepare-and-identifiers/fixtures/G36B/before.php b/.ai/review/evals/sec-sql-prepare-and-identifiers/fixtures/G36B/before.php
new file mode 100644
index 00000000000..16049211d5e
--- /dev/null
+++ b/.ai/review/evals/sec-sql-prepare-and-identifiers/fixtures/G36B/before.php
@@ -0,0 +1,9 @@
+<?php
+/* Public catalog sort AJAX endpoint; catalog contains published products. */
+add_action( 'wp_ajax_nopriv_wc_catalog_sort', 'wc_catalog_sort' );
+function wc_catalog_sort() {
+ global $wpdb;
+ $requested = isset( $_GET['orderby'] ) && is_string( $_GET['orderby'] ) ? wp_unslash( $_GET['orderby'] ) : 'ID';
+ $orderby = in_array( $requested, array( 'ID', 'post_title' ), true ) ? $requested : 'ID';
+ wp_send_json( $wpdb->get_col( "SELECT ID FROM {$wpdb->posts} WHERE post_type='product' AND post_status='publish' ORDER BY $orderby LIMIT 10" ) );
+}
diff --git a/.ai/review/evals/sec-sql-prepare-and-identifiers/fixtures/G36C/after.php b/.ai/review/evals/sec-sql-prepare-and-identifiers/fixtures/G36C/after.php
new file mode 100644
index 00000000000..b7f3f62a396
--- /dev/null
+++ b/.ai/review/evals/sec-sql-prepare-and-identifiers/fixtures/G36C/after.php
@@ -0,0 +1,9 @@
+<?php
+/* Public catalog sort AJAX endpoint. */
+add_action( 'wp_ajax_nopriv_wc_catalog_sort', 'wc_catalog_sort' );
+function wc_catalog_sort() {
+ global $wpdb;
+ $requested = isset( $_GET['orderby'] ) && is_string( $_GET['orderby'] ) ? wp_unslash( $_GET['orderby'] ) : 'ID';
+ $orderby = in_array( $requested, array( 'ID', 'post_title', 'post_date' ), true ) ? $requested : 'ID';
+ wp_send_json( $wpdb->get_col( "SELECT ID FROM {$wpdb->posts} WHERE post_type='product' AND post_status='publish' ORDER BY $orderby LIMIT 10" ) );
+}
diff --git a/.ai/review/evals/sec-sql-prepare-and-identifiers/fixtures/G36C/before.php b/.ai/review/evals/sec-sql-prepare-and-identifiers/fixtures/G36C/before.php
new file mode 100644
index 00000000000..482ab6107eb
--- /dev/null
+++ b/.ai/review/evals/sec-sql-prepare-and-identifiers/fixtures/G36C/before.php
@@ -0,0 +1,9 @@
+<?php
+/* Public catalog sort AJAX endpoint. */
+add_action( 'wp_ajax_nopriv_wc_catalog_sort', 'wc_catalog_sort' );
+function wc_catalog_sort() {
+ global $wpdb;
+ $requested = isset( $_GET['orderby'] ) && is_string( $_GET['orderby'] ) ? wp_unslash( $_GET['orderby'] ) : 'ID';
+ $orderby = in_array( $requested, array( 'ID', 'post_title' ), true ) ? $requested : 'ID';
+ wp_send_json( $wpdb->get_col( "SELECT ID FROM {$wpdb->posts} WHERE post_type='product' AND post_status='publish' ORDER BY $orderby LIMIT 10" ) );
+}
diff --git a/.ai/review/evals/sec-sql-prepare-and-identifiers/fixtures/G36D/after.php b/.ai/review/evals/sec-sql-prepare-and-identifiers/fixtures/G36D/after.php
new file mode 100644
index 00000000000..c3051822088
--- /dev/null
+++ b/.ai/review/evals/sec-sql-prepare-and-identifiers/fixtures/G36D/after.php
@@ -0,0 +1,10 @@
+<?php
+/* Public product-ID lookup; result order is unspecified. */
+add_action( 'wp_ajax_nopriv_wc_catalog_ids', 'wc_catalog_ids' );
+function wc_catalog_ids() {
+ global $wpdb;
+ $ids = isset( $_GET['ids'] ) && is_array( $_GET['ids'] ) ? array_filter( $_GET['ids'], 'is_scalar' ) : array();
+ $ids = array_values( array_unique( array_map( 'absint', $ids ) ) );
+ if ( ! $ids ) { wp_send_json( array() ); return; }
+ wp_send_json( $wpdb->get_col( "SELECT ID FROM {$wpdb->posts} WHERE post_type='product' AND post_status='publish' AND ID IN (" . implode( ',', $ids ) . ')' ) );
+}
diff --git a/.ai/review/evals/sec-sql-prepare-and-identifiers/fixtures/G36D/before.php b/.ai/review/evals/sec-sql-prepare-and-identifiers/fixtures/G36D/before.php
new file mode 100644
index 00000000000..75814637367
--- /dev/null
+++ b/.ai/review/evals/sec-sql-prepare-and-identifiers/fixtures/G36D/before.php
@@ -0,0 +1,10 @@
+<?php
+/* Public product-ID lookup; result order is unspecified. */
+add_action( 'wp_ajax_nopriv_wc_catalog_ids', 'wc_catalog_ids' );
+function wc_catalog_ids() {
+ global $wpdb;
+ $ids = isset( $_GET['ids'] ) && is_array( $_GET['ids'] ) ? array_filter( $_GET['ids'], 'is_scalar' ) : array();
+ $ids = array_map( 'absint', $ids );
+ if ( ! $ids ) { wp_send_json( array() ); return; }
+ wp_send_json( $wpdb->get_col( "SELECT ID FROM {$wpdb->posts} WHERE post_type='product' AND post_status='publish' AND ID IN (" . implode( ',', $ids ) . ')' ) );
+}
diff --git a/.ai/review/evals/session-cart-persistence/cases.json b/.ai/review/evals/session-cart-persistence/cases.json
new file mode 100644
index 00000000000..2415e203da0
--- /dev/null
+++ b/.ai/review/evals/session-cart-persistence/cases.json
@@ -0,0 +1,77 @@
+{
+ "version": 1,
+ "cases": [
+ {
+ "id": "G17A-v3",
+ "path": "plugins/woocommerce/includes/class-wc-cart-session.php",
+ "before": {
+ "file": "fixtures/G17A/before.php"
+ },
+ "after": {
+ "file": "fixtures/G17A/after.php"
+ },
+ "expect": {
+ "outcome": "finding",
+ "locations": [
+ {
+ "file": "plugins/woocommerce/includes/class-wc-cart-session.php",
+ "lines": [
+ 5,
+ 5
+ ],
+ "anchor": " $delete_user_meta( $user_id, '_woocommerce_persistent_cart_' . get_current_blog_id() );"
+ }
+ ]
+ }
+ },
+ {
+ "id": "G17B-v3",
+ "path": "plugins/woocommerce/includes/class-wc-cart-session.php",
+ "before": {
+ "file": "fixtures/G17B/before.php"
+ },
+ "after": {
+ "file": "fixtures/G17B/after.php"
+ },
+ "expect": {
+ "outcome": "finding",
+ "locations": [
+ {
+ "file": "plugins/woocommerce/includes/class-wc-cart-session.php",
+ "lines": [
+ 4,
+ 4
+ ],
+ "anchor": " $data = $session->get_session_data();"
+ }
+ ]
+ }
+ },
+ {
+ "id": "G17C-v3",
+ "path": "plugins/woocommerce/includes/class-wc-cart-session.php",
+ "before": {
+ "file": "fixtures/G17C/before.php"
+ },
+ "after": {
+ "file": "fixtures/G17C/after.php"
+ },
+ "expect": {
+ "outcome": "quiet"
+ }
+ },
+ {
+ "id": "G17D-v3",
+ "path": "plugins/woocommerce/includes/class-wc-cart-session.php",
+ "before": {
+ "file": "fixtures/G17D/before.php"
+ },
+ "after": {
+ "file": "fixtures/G17D/after.php"
+ },
+ "expect": {
+ "outcome": "quiet"
+ }
+ }
+ ]
+}
diff --git a/.ai/review/evals/session-cart-persistence/fixtures/G17A/after.php b/.ai/review/evals/session-cart-persistence/fixtures/G17A/after.php
new file mode 100644
index 00000000000..3c002690077
--- /dev/null
+++ b/.ai/review/evals/session-cart-persistence/fixtures/G17A/after.php
@@ -0,0 +1,11 @@
+<?php
+/* Account logout handler. */
+function on_customer_logout( $session, $user_id, $delete_user_meta ) {
+ $session->destroy_session();
+ $delete_user_meta( $user_id, '_woocommerce_persistent_cart_' . get_current_blog_id() );
+}
+
+function saved_account_cart( $user_id ) {
+ $saved = get_user_meta( $user_id, '_woocommerce_persistent_cart_' . get_current_blog_id(), true );
+ return is_array( $saved ) ? ( $saved['cart'] ?? array() ) : array();
+}
diff --git a/.ai/review/evals/session-cart-persistence/fixtures/G17A/before.php b/.ai/review/evals/session-cart-persistence/fixtures/G17A/before.php
new file mode 100644
index 00000000000..4b757bd0a86
--- /dev/null
+++ b/.ai/review/evals/session-cart-persistence/fixtures/G17A/before.php
@@ -0,0 +1,10 @@
+<?php
+/* Account logout handler. */
+function on_customer_logout( $session, $user_id, $delete_user_meta ) {
+ $session->destroy_session();
+}
+
+function saved_account_cart( $user_id ) {
+ $saved = get_user_meta( $user_id, '_woocommerce_persistent_cart_' . get_current_blog_id(), true );
+ return is_array( $saved ) ? ( $saved['cart'] ?? array() ) : array();
+}
diff --git a/.ai/review/evals/session-cart-persistence/fixtures/G17B/after.php b/.ai/review/evals/session-cart-persistence/fixtures/G17B/after.php
new file mode 100644
index 00000000000..dcc05273bff
--- /dev/null
+++ b/.ai/review/evals/session-cart-persistence/fixtures/G17B/after.php
@@ -0,0 +1,12 @@
+<?php
+/* Checkout session adapter. */
+function read_cart_session( $session ) {
+ $data = $session->get_session_data();
+ return array( 'cart' => $data['cart'] ?? array() );
+}
+
+class Export_Custom_Session extends WC_Session {
+ public function init() {}
+ public function get_customer_id() { return 'export-customer'; }
+}
+$cart = read_cart_session( new Export_Custom_Session() );
diff --git a/.ai/review/evals/session-cart-persistence/fixtures/G17B/before.php b/.ai/review/evals/session-cart-persistence/fixtures/G17B/before.php
new file mode 100644
index 00000000000..065873d48bb
--- /dev/null
+++ b/.ai/review/evals/session-cart-persistence/fixtures/G17B/before.php
@@ -0,0 +1,11 @@
+<?php
+/* Checkout session adapter. */
+function read_cart_session( $session ) {
+ return array( 'cart' => $session->get( 'cart', array() ) );
+}
+
+class Export_Custom_Session extends WC_Session {
+ public function init() {}
+ public function get_customer_id() { return 'export-customer'; }
+}
+$cart = read_cart_session( new Export_Custom_Session() );
diff --git a/.ai/review/evals/session-cart-persistence/fixtures/G17C/after.php b/.ai/review/evals/session-cart-persistence/fixtures/G17C/after.php
new file mode 100644
index 00000000000..262e3dd4973
--- /dev/null
+++ b/.ai/review/evals/session-cart-persistence/fixtures/G17C/after.php
@@ -0,0 +1,10 @@
+<?php
+function read_cart_session( WC_Session $session ) {
+ return array( 'cart' => $session->get( 'cart', array() ), 'coupons' => $session->get( 'coupons', array() ) );
+}
+class Export_Custom_Session extends WC_Session {
+ public function init() {}
+}
+$session = new Export_Custom_Session();
+$session->set( 'cart', array( 'item-hash' => array( 'product_id' => 42, 'quantity' => 1 ) ) );
+$cart = read_cart_session( $session );
diff --git a/.ai/review/evals/session-cart-persistence/fixtures/G17C/before.php b/.ai/review/evals/session-cart-persistence/fixtures/G17C/before.php
new file mode 100644
index 00000000000..328b7f612a8
--- /dev/null
+++ b/.ai/review/evals/session-cart-persistence/fixtures/G17C/before.php
@@ -0,0 +1,11 @@
+<?php
+function read_cart_session( WC_Session $session ) {
+ $data = $session->get_session_data();
+ return array( 'cart' => $data['cart'] ?? array(), 'coupons' => $data['coupons'] ?? array() );
+}
+class Export_Custom_Session extends WC_Session {
+ public function init() {}
+}
+$session = new Export_Custom_Session();
+$session->set( 'cart', array( 'item-hash' => array( 'product_id' => 42, 'quantity' => 1 ) ) );
+$cart = read_cart_session( $session );
diff --git a/.ai/review/evals/session-cart-persistence/fixtures/G17D/after.php b/.ai/review/evals/session-cart-persistence/fixtures/G17D/after.php
new file mode 100644
index 00000000000..5cb892bdbd1
--- /dev/null
+++ b/.ai/review/evals/session-cart-persistence/fixtures/G17D/after.php
@@ -0,0 +1,8 @@
+<?php
+/* Cart keys are product/variation hashes. Guest items take precedence on a matching key. */
+function load_cart_after_login( $guest_cart, $saved_cart, $session ) {
+ $merged = $saved_cart;
+ foreach ( $guest_cart as $key => $item ) { $merged[ $key ] = $item; }
+ $session->set( 'cart', $merged );
+ return $merged;
+}
diff --git a/.ai/review/evals/session-cart-persistence/fixtures/G17D/before.php b/.ai/review/evals/session-cart-persistence/fixtures/G17D/before.php
new file mode 100644
index 00000000000..824ae784688
--- /dev/null
+++ b/.ai/review/evals/session-cart-persistence/fixtures/G17D/before.php
@@ -0,0 +1,7 @@
+<?php
+/* Cart keys are product/variation hashes. Guest items take precedence on a matching key. */
+function load_cart_after_login( $guest_cart, $saved_cart, $session ) {
+ $merged = array_replace( $saved_cart, $guest_cart );
+ $session->set( 'cart', $merged );
+ return $merged;
+}
diff --git a/.ai/review/evals/shipping-rate-pipeline/cases.json b/.ai/review/evals/shipping-rate-pipeline/cases.json
new file mode 100644
index 00000000000..14fc7abdbfa
--- /dev/null
+++ b/.ai/review/evals/shipping-rate-pipeline/cases.json
@@ -0,0 +1,93 @@
+{
+ "version": 1,
+ "cases": [
+ {
+ "id": "G20A-v4",
+ "path": "plugins/woocommerce/includes/class-wc-shipping.php",
+ "before": {
+ "file": "fixtures/G20A/before.php"
+ },
+ "after": {
+ "file": "fixtures/G20A/after.php"
+ },
+ "expect": {
+ "outcome": "finding",
+ "locations": [
+ {
+ "file": "plugins/woocommerce/includes/class-wc-shipping.php",
+ "lines": [
+ 3,
+ 3
+ ],
+ "anchor": " $rates = apply_filters( 'woocommerce_package_rates', $rates, $package );"
+ },
+ {
+ "file": "plugins/woocommerce/includes/class-wc-shipping.php",
+ "lines": [
+ 16,
+ 16
+ ],
+ "anchor": " return $rates;"
+ }
+ ]
+ }
+ },
+ {
+ "id": "G20B-v4",
+ "path": "plugins/woocommerce/includes/abstracts/abstract-wc-shipping-method.php",
+ "before": {
+ "file": "fixtures/G20B/before.php"
+ },
+ "after": {
+ "file": "fixtures/G20B/after.php"
+ },
+ "expect": {
+ "outcome": "finding",
+ "locations": [
+ {
+ "file": "plugins/woocommerce/includes/abstracts/abstract-wc-shipping-method.php",
+ "lines": [
+ 6,
+ 6
+ ],
+ "anchor": " return new WC_Shipping_Rate( $args['id'], $args['label'], $total_cost, array(), 'flat_rate', 1 );"
+ },
+ {
+ "file": "plugins/woocommerce/includes/abstracts/abstract-wc-shipping-method.php",
+ "lines": [
+ 9,
+ 9
+ ],
+ "anchor": " return $subtotal + make_export_rate( $args )->get_cost();"
+ }
+ ]
+ }
+ },
+ {
+ "id": "G20D-v4",
+ "path": "plugins/woocommerce/includes/class-wc-shipping.php",
+ "before": {
+ "file": "fixtures/G20D/before.php"
+ },
+ "after": {
+ "file": "fixtures/G20D/after.php"
+ },
+ "expect": {
+ "outcome": "quiet"
+ }
+ },
+ {
+ "id": "G20C-v7",
+ "path": "plugins/woocommerce/includes/abstracts/abstract-wc-shipping-method.php",
+ "before": {
+ "code": "<?php\n/* Local pickup display adapter. Merchant titles pass through the existing title filter.\n * Pinned WC_Shipping_Method::get_title() returns\n * apply_filters( 'woocommerce_shipping_method_title', $this->title, $this->id ).\n * Stored titles stay configured text; this adapter translates only its empty/invalid fallback.\n */\nfinal class Pickup_Display_Rate {\n public function create( WC_Shipping_Local_Pickup $method, $cost ) {\n $instance_id = $method->get_instance_id();\n $cost = wc_format_decimal( $cost );\n if ( '' === $cost ) { $cost = '0'; }\n $title = $method->get_title();\n $label = is_string( $title ) && '' !== trim( $title )\n ? $title : __( 'Local pickup', 'woocommerce' );\n return new WC_Shipping_Rate( 'local_pickup:' . $instance_id, $label, $cost, array(), 'local_pickup', $instance_id );\n }\n\n public function render_label( WC_Shipping_Rate $rate ) {\n return esc_html( $rate->get_label() );\n }\n}\n\n// Review-fixture tests need an already loaded WooCommerce unit-test bootstrap.\nif ( class_exists( 'WC_Unit_Test_Case', false ) ) {\n /** Tests for the local pickup display adapter. */\n class PickupDisplayRateTest extends WC_Unit_Test_Case {\n /** @testdox Preserves merchant titles and falls back for empty or invalid values. */\n public function test_title_values(): void {\n $method = new WC_Shipping_Local_Pickup( 17 );\n $sut = new Pickup_Display_Rate();\n foreach ( array( 'Collect in store', '0', '', ' ', null, array() ) as $title ) {\n $method->title = $title;\n $rate = $sut->create( $method, '2.50' );\n $expected = in_array( $title, array( 'Collect in store', '0' ), true )\n ? $title : __( 'Local pickup', 'woocommerce' );\n $this->assertSame( $expected, $rate->get_label() );\n }\n }\n\n /** @testdox Preserves stored titles and uses the current translation for an empty fallback. */\n public function test_translated_fallback(): void {\n $translate = static function( $translated, $text, $domain ) {\n return 'woocommerce' === $domain && 'Local pickup' === $text\n ? 'Retrait en magasin' : $translated;\n };\n add_filter( 'gettext', $translate, 10, 3 );\n try {\n $method = new WC_Shipping_Local_Pickup( 17 );\n $sut = new Pickup_Display_Rate();\n $method->title = 'Local pickup';\n $this->assertSame( 'Local pickup', $sut->create( $method, '' )->get_label() );\n $method->title = '';\n $this->assertSame( 'Retrait en magasin', $sut->create( $method, '' )->get_label() );\n } finally {\n remove_filter( 'gettext', $translate, 10 );\n }\n }\n\n /** @testdox Uses the existing title filter before checking the resulting label. */\n public function test_filtered_merchant_title(): void {\n $translate_title = static function( $title, $method_id ) {\n return 'local_pickup' === $method_id ? 'Collectez en magasin' : $title;\n };\n add_filter( 'woocommerce_shipping_method_title', $translate_title, 10, 2 );\n try {\n $method = new WC_Shipping_Local_Pickup( 17 );\n $method->title = 'Collect in store';\n $sut = new Pickup_Display_Rate();\n $this->assertSame( 'Collectez en magasin', $sut->create( $method, '' )->get_label() );\n } finally {\n remove_filter( 'woocommerce_shipping_method_title', $translate_title, 10 );\n }\n }\n\n /** @testdox Checks filtered values even when the raw property has different validity. */\n public function test_filtered_title_validity(): void {\n $method = new WC_Shipping_Local_Pickup( 17 );\n $sut = new Pickup_Display_Rate();\n $cases = array(\n array( 'Collect in store', '', __( 'Local pickup', 'woocommerce' ) ),\n array( 'Collect in store', array(), __( 'Local pickup', 'woocommerce' ) ),\n array( null, 'Collectez en magasin', 'Collectez en magasin' ),\n );\n foreach ( $cases as list( $raw_title, $filtered_title, $expected ) ) {\n $filter_title = static function( $title, $method_id ) use ( $filtered_title ) {\n return 'local_pickup' === $method_id ? $filtered_title : $title;\n };\n $method->title = $raw_title;\n add_filter( 'woocommerce_shipping_method_title', $filter_title, 10, 2 );\n try {\n $this->assertSame( $expected, $sut->create( $method, '' )->get_label() );\n } finally {\n remove_filter( 'woocommerce_shipping_method_title', $filter_title, 10 );\n }\n }\n }\n\n /** @testdox Keeps the prior rate setter conversion for scalar and stringable filter results. */\n public function test_filtered_string_compatible_values(): void {\n $method = new WC_Shipping_Local_Pickup( 17 );\n $method->title = 'Configured title';\n $sut = new Pickup_Display_Rate();\n $stringable = new class {\n public function __toString(): string { return 'Wrapped pickup'; }\n };\n foreach ( array( 0, 42, 2.5, true, $stringable ) as $value ) {\n $expected = (string) $value;\n $filter = static function( $title, $method_id ) use ( $value ) {\n return 'local_pickup' === $method_id ? $value : $title;\n };\n add_filter( 'woocommerce_shipping_method_title', $filter, 10, 2 );\n try {\n $this->assertSame( $expected, $sut->create( $method, '' )->get_label() );\n } finally {\n remove_filter( 'woocommerce_shipping_method_title', $filter, 10 );\n }\n }\n }\n\n /** @testdox Keeps the prior string cast for a resource rather than inventing a resource identifier. */\n public function test_filtered_resource_value(): void {\n $resource = fopen( 'php://memory', 'r+' );\n $filter = static function( $title, $method_id ) use ( $resource ) {\n return 'local_pickup' === $method_id ? $resource : $title;\n };\n add_filter( 'woocommerce_shipping_method_title', $filter, 10, 2 );\n try {\n $method = new WC_Shipping_Local_Pickup( 17 );\n $this->assertSame( (string) $resource, ( new Pickup_Display_Rate() )->create( $method, '' )->get_label() );\n } finally {\n remove_filter( 'woocommerce_shipping_method_title', $filter, 10 );\n fclose( $resource );\n }\n }\n\n /** @testdox Coerces a stringable filter result only once. */\n public function test_stringable_conversion_count(): void {\n $value = new class {\n public $calls = 0;\n public function __toString(): string { ++$this->calls; return 'Wrapped pickup'; }\n };\n $filter = static function() use ( $value ) { return $value; };\n add_filter( 'woocommerce_shipping_method_title', $filter, 10, 2 );\n try {\n $method = new WC_Shipping_Local_Pickup( 17 );\n $rate = ( new Pickup_Display_Rate() )->create( $method, '' );\n $this->assertSame( 'Wrapped pickup', $rate->get_label() );\n $this->assertSame( 1, $value->calls );\n } finally {\n remove_filter( 'woocommerce_shipping_method_title', $filter, 10 );\n }\n }\n\n /** @testdox Preserves an exception raised by the prior string conversion. */\n public function test_throwing_stringable(): void {\n $value = new class {\n public function __toString(): string { throw new RuntimeException( 'Label conversion failed' ); }\n };\n $filter = static function() use ( $value ) { return $value; };\n add_filter( 'woocommerce_shipping_method_title', $filter, 10, 2 );\n try {\n $this->expectException( RuntimeException::class );\n ( new Pickup_Display_Rate() )->create( new WC_Shipping_Local_Pickup( 17 ), '' );\n } finally {\n remove_filter( 'woocommerce_shipping_method_title', $filter, 10 );\n }\n }\n\n /** @testdox Preserves rate identity and cost while escaping labels only when rendered. */\n public function test_rate_contract(): void {\n $method = new WC_Shipping_Local_Pickup( 17 );\n $method->title = '<b>Collect & go</b>';\n $sut = new Pickup_Display_Rate();\n $rate = $sut->create( $method, '2.50' );\n $this->assertSame( 'local_pickup:17', $rate->get_id() );\n $this->assertSame( 'local_pickup', $rate->get_method_id() );\n $this->assertSame( 17, $rate->get_instance_id() );\n $this->assertSame( wc_format_decimal( '2.50' ), $rate->get_cost() );\n $this->assertSame( '0', $sut->create( $method, '' )->get_cost() );\n $this->assertSame( '<b>Collect & go</b>', $rate->get_label() );\n $this->assertSame( '<b>Collect & go</b>', $sut->render_label( $rate ) );\n }\n }\n}\n"
+ },
+ "after": {
+ "code": "<?php\n/* Local pickup display adapter. Merchant titles pass through the existing title filter.\n * Pinned WC_Shipping_Method::get_title() returns\n * apply_filters( 'woocommerce_shipping_method_title', $this->title, $this->id ).\n * Stored titles stay configured text; this adapter translates only its empty/invalid fallback.\n */\nfinal class Pickup_Display_Rate {\n public function create( WC_Shipping_Local_Pickup $method, $cost ) {\n $instance_id = $method->get_instance_id();\n $cost = wc_format_decimal( $cost );\n if ( '' === $cost ) { $cost = '0'; }\n $title = $method->get_title();\n if ( is_scalar( $title ) || is_resource( $title ) || ( is_object( $title ) && method_exists( $title, '__toString' ) ) ) {\n $title = (string) $title;\n }\n $label = is_string( $title ) && '' !== trim( $title )\n ? $title : __( 'Local pickup', 'woocommerce' );\n return new WC_Shipping_Rate( 'local_pickup:' . $instance_id, $label, $cost, array(), 'local_pickup', $instance_id );\n }\n\n public function render_label( WC_Shipping_Rate $rate ) {\n return esc_html( $rate->get_label() );\n }\n}\n\n// Review-fixture tests need an already loaded WooCommerce unit-test bootstrap.\nif ( class_exists( 'WC_Unit_Test_Case', false ) ) {\n /** Tests for the local pickup display adapter. */\n class PickupDisplayRateTest extends WC_Unit_Test_Case {\n /** @testdox Preserves merchant titles and falls back for empty or invalid values. */\n public function test_title_values(): void {\n $method = new WC_Shipping_Local_Pickup( 17 );\n $sut = new Pickup_Display_Rate();\n foreach ( array( 'Collect in store', '0', '', ' ', null, array() ) as $title ) {\n $method->title = $title;\n $rate = $sut->create( $method, '2.50' );\n $expected = in_array( $title, array( 'Collect in store', '0' ), true )\n ? $title : __( 'Local pickup', 'woocommerce' );\n $this->assertSame( $expected, $rate->get_label() );\n }\n }\n\n /** @testdox Preserves stored titles and uses the current translation for an empty fallback. */\n public function test_translated_fallback(): void {\n $translate = static function( $translated, $text, $domain ) {\n return 'woocommerce' === $domain && 'Local pickup' === $text\n ? 'Retrait en magasin' : $translated;\n };\n add_filter( 'gettext', $translate, 10, 3 );\n try {\n $method = new WC_Shipping_Local_Pickup( 17 );\n $sut = new Pickup_Display_Rate();\n $method->title = 'Local pickup';\n $this->assertSame( 'Local pickup', $sut->create( $method, '' )->get_label() );\n $method->title = '';\n $this->assertSame( 'Retrait en magasin', $sut->create( $method, '' )->get_label() );\n } finally {\n remove_filter( 'gettext', $translate, 10 );\n }\n }\n\n /** @testdox Uses the existing title filter before checking the resulting label. */\n public function test_filtered_merchant_title(): void {\n $translate_title = static function( $title, $method_id ) {\n return 'local_pickup' === $method_id ? 'Collectez en magasin' : $title;\n };\n add_filter( 'woocommerce_shipping_method_title', $translate_title, 10, 2 );\n try {\n $method = new WC_Shipping_Local_Pickup( 17 );\n $method->title = 'Collect in store';\n $sut = new Pickup_Display_Rate();\n $this->assertSame( 'Collectez en magasin', $sut->create( $method, '' )->get_label() );\n } finally {\n remove_filter( 'woocommerce_shipping_method_title', $translate_title, 10 );\n }\n }\n\n /** @testdox Checks filtered values even when the raw property has different validity. */\n public function test_filtered_title_validity(): void {\n $method = new WC_Shipping_Local_Pickup( 17 );\n $sut = new Pickup_Display_Rate();\n $cases = array(\n array( 'Collect in store', '', __( 'Local pickup', 'woocommerce' ) ),\n array( 'Collect in store', array(), __( 'Local pickup', 'woocommerce' ) ),\n array( null, 'Collectez en magasin', 'Collectez en magasin' ),\n );\n foreach ( $cases as list( $raw_title, $filtered_title, $expected ) ) {\n $filter_title = static function( $title, $method_id ) use ( $filtered_title ) {\n return 'local_pickup' === $method_id ? $filtered_title : $title;\n };\n $method->title = $raw_title;\n add_filter( 'woocommerce_shipping_method_title', $filter_title, 10, 2 );\n try {\n $this->assertSame( $expected, $sut->create( $method, '' )->get_label() );\n } finally {\n remove_filter( 'woocommerce_shipping_method_title', $filter_title, 10 );\n }\n }\n }\n\n /** @testdox Keeps the prior rate setter conversion for scalar and stringable filter results. */\n public function test_filtered_string_compatible_values(): void {\n $method = new WC_Shipping_Local_Pickup( 17 );\n $method->title = 'Configured title';\n $sut = new Pickup_Display_Rate();\n $stringable = new class {\n public function __toString(): string { return 'Wrapped pickup'; }\n };\n foreach ( array( 0, 42, 2.5, true, $stringable ) as $value ) {\n $expected = (string) $value;\n $filter = static function( $title, $method_id ) use ( $value ) {\n return 'local_pickup' === $method_id ? $value : $title;\n };\n add_filter( 'woocommerce_shipping_method_title', $filter, 10, 2 );\n try {\n $this->assertSame( $expected, $sut->create( $method, '' )->get_label() );\n } finally {\n remove_filter( 'woocommerce_shipping_method_title', $filter, 10 );\n }\n }\n }\n\n /** @testdox Keeps the prior string cast for a resource rather than inventing a resource identifier. */\n public function test_filtered_resource_value(): void {\n $resource = fopen( 'php://memory', 'r+' );\n $filter = static function( $title, $method_id ) use ( $resource ) {\n return 'local_pickup' === $method_id ? $resource : $title;\n };\n add_filter( 'woocommerce_shipping_method_title', $filter, 10, 2 );\n try {\n $method = new WC_Shipping_Local_Pickup( 17 );\n $this->assertSame( (string) $resource, ( new Pickup_Display_Rate() )->create( $method, '' )->get_label() );\n } finally {\n remove_filter( 'woocommerce_shipping_method_title', $filter, 10 );\n fclose( $resource );\n }\n }\n\n /** @testdox Coerces a stringable filter result only once. */\n public function test_stringable_conversion_count(): void {\n $value = new class {\n public $calls = 0;\n public function __toString(): string { ++$this->calls; return 'Wrapped pickup'; }\n };\n $filter = static function() use ( $value ) { return $value; };\n add_filter( 'woocommerce_shipping_method_title', $filter, 10, 2 );\n try {\n $method = new WC_Shipping_Local_Pickup( 17 );\n $rate = ( new Pickup_Display_Rate() )->create( $method, '' );\n $this->assertSame( 'Wrapped pickup', $rate->get_label() );\n $this->assertSame( 1, $value->calls );\n } finally {\n remove_filter( 'woocommerce_shipping_method_title', $filter, 10 );\n }\n }\n\n /** @testdox Preserves an exception raised by the prior string conversion. */\n public function test_throwing_stringable(): void {\n $value = new class {\n public function __toString(): string { throw new RuntimeException( 'Label conversion failed' ); }\n };\n $filter = static function() use ( $value ) { return $value; };\n add_filter( 'woocommerce_shipping_method_title', $filter, 10, 2 );\n try {\n $this->expectException( RuntimeException::class );\n ( new Pickup_Display_Rate() )->create( new WC_Shipping_Local_Pickup( 17 ), '' );\n } finally {\n remove_filter( 'woocommerce_shipping_method_title', $filter, 10 );\n }\n }\n\n /** @testdox Preserves rate identity and cost while escaping labels only when rendered. */\n public function test_rate_contract(): void {\n $method = new WC_Shipping_Local_Pickup( 17 );\n $method->title = '<b>Collect & go</b>';\n $sut = new Pickup_Display_Rate();\n $rate = $sut->create( $method, '2.50' );\n $this->assertSame( 'local_pickup:17', $rate->get_id() );\n $this->assertSame( 'local_pickup', $rate->get_method_id() );\n $this->assertSame( 17, $rate->get_instance_id() );\n $this->assertSame( wc_format_decimal( '2.50' ), $rate->get_cost() );\n $this->assertSame( '0', $sut->create( $method, '' )->get_cost() );\n $this->assertSame( '<b>Collect & go</b>', $rate->get_label() );\n $this->assertSame( '<b>Collect & go</b>', $sut->render_label( $rate ) );\n }\n }\n}\n"
+ },
+ "expect": {
+ "outcome": "quiet"
+ }
+ }
+ ]
+}
diff --git a/.ai/review/evals/shipping-rate-pipeline/fixtures/G20A/after.php b/.ai/review/evals/shipping-rate-pipeline/fixtures/G20A/after.php
new file mode 100644
index 00000000000..9b8fcfb36f6
--- /dev/null
+++ b/.ai/review/evals/shipping-rate-pipeline/fixtures/G20A/after.php
@@ -0,0 +1,31 @@
+<?php
+function calculate_package_rates( $rates, $package, $methods ) {
+ $rates = apply_filters( 'woocommerce_package_rates', $rates, $package );
+ if ( 'yes' === get_option( 'woocommerce_shipping_hide_rates_when_free', 'no' ) ) {
+ $free = array();
+ $pickup = array();
+ foreach ( $rates as $rate ) {
+ if ( 'free_shipping' === $rate->get_method_id() ) {
+ $free[ $rate->get_id() ] = $rate;
+ } elseif ( 'local_pickup' === $rate->get_method_id() || $methods[ $rate->get_method_id() ]->supports( 'local-pickup' ) ) {
+ $pickup[ $rate->get_id() ] = $rate;
+ }
+ }
+ if ( $free ) { $rates = array_merge( $free, $pickup ); }
+ }
+ return $rates;
+}
+add_filter( 'woocommerce_package_rates', static function ( $rates, $package ) {
+ $rates['flat_rate:99'] = new WC_Shipping_Rate( 'flat_rate:99', 'Express', '12.50', array(), 'flat_rate', 99 );
+ return $rates;
+}, 10, 2 );
+
+function available_delivery_rates( $package ) {
+ $rates = array(
+ 'free_shipping:1' => new WC_Shipping_Rate( 'free_shipping:1', 'Free shipping', '0', array(), 'free_shipping', 1 ),
+ 'local_pickup:2' => new WC_Shipping_Rate( 'local_pickup:2', 'Pickup', '0', array(), 'local_pickup', 2 ),
+ 'flat_rate:3' => new WC_Shipping_Rate( 'flat_rate:3', 'Delivery', '5', array(), 'flat_rate', 3 ),
+ );
+ $methods = array( 'flat_rate' => new WC_Shipping_Flat_Rate( 3 ) );
+ return calculate_package_rates( $rates, $package, $methods );
+}
diff --git a/.ai/review/evals/shipping-rate-pipeline/fixtures/G20A/before.php b/.ai/review/evals/shipping-rate-pipeline/fixtures/G20A/before.php
new file mode 100644
index 00000000000..0ba198339a1
--- /dev/null
+++ b/.ai/review/evals/shipping-rate-pipeline/fixtures/G20A/before.php
@@ -0,0 +1,30 @@
+<?php
+function calculate_package_rates( $rates, $package, $methods ) {
+ if ( 'yes' === get_option( 'woocommerce_shipping_hide_rates_when_free', 'no' ) ) {
+ $free = array();
+ $pickup = array();
+ foreach ( $rates as $rate ) {
+ if ( 'free_shipping' === $rate->get_method_id() ) {
+ $free[ $rate->get_id() ] = $rate;
+ } elseif ( 'local_pickup' === $rate->get_method_id() || $methods[ $rate->get_method_id() ]->supports( 'local-pickup' ) ) {
+ $pickup[ $rate->get_id() ] = $rate;
+ }
+ }
+ if ( $free ) { $rates = array_merge( $free, $pickup ); }
+ }
+ return apply_filters( 'woocommerce_package_rates', $rates, $package );
+}
+add_filter( 'woocommerce_package_rates', static function ( $rates, $package ) {
+ $rates['flat_rate:99'] = new WC_Shipping_Rate( 'flat_rate:99', 'Express', '12.50', array(), 'flat_rate', 99 );
+ return $rates;
+}, 10, 2 );
+
+function available_delivery_rates( $package ) {
+ $rates = array(
+ 'free_shipping:1' => new WC_Shipping_Rate( 'free_shipping:1', 'Free shipping', '0', array(), 'free_shipping', 1 ),
+ 'local_pickup:2' => new WC_Shipping_Rate( 'local_pickup:2', 'Pickup', '0', array(), 'local_pickup', 2 ),
+ 'flat_rate:3' => new WC_Shipping_Rate( 'flat_rate:3', 'Delivery', '5', array(), 'flat_rate', 3 ),
+ );
+ $methods = array( 'flat_rate' => new WC_Shipping_Flat_Rate( 3 ) );
+ return calculate_package_rates( $rates, $package, $methods );
+}
diff --git a/.ai/review/evals/shipping-rate-pipeline/fixtures/G20B/after.php b/.ai/review/evals/shipping-rate-pipeline/fixtures/G20B/after.php
new file mode 100644
index 00000000000..6d85020cc62
--- /dev/null
+++ b/.ai/review/evals/shipping-rate-pipeline/fixtures/G20B/after.php
@@ -0,0 +1,57 @@
+<?php
+function make_export_rate( $args ) {
+ $args = wp_parse_args( $args, array( 'id' => 'flat_rate:1', 'label' => 'Delivery', 'cost' => '0', 'price_decimals' => false ) );
+ $total_cost = is_array( $args['cost'] ) ? array_sum( $args['cost'] ) : $args['cost'];
+ $total_cost = wc_format_decimal( $total_cost, $args['price_decimals'] );
+ return new WC_Shipping_Rate( $args['id'], $args['label'], $total_cost, array(), 'flat_rate', 1 );
+}
+function shipping_export_amount( $subtotal, $args ) {
+ return $subtotal + make_export_rate( $args )->get_cost();
+}
+$amount = shipping_export_amount( 10, array( 'cost' => '' ) );
+
+// Reduced WC_Shipping_Rate storage surface; cost setter/getter match the core class.
+class WC_Shipping_Rate {
+ private $cost;
+ public function __construct( $id = '', $label = '', $cost = 0, $taxes = array(), $method_id = '', $instance_id = 0 ) {
+ $this->set_cost( $cost );
+ }
+ public function set_cost( $cost ) { $this->cost = $cost; }
+ public function get_cost() { return apply_filters( 'woocommerce_shipping_rate_cost', $this->cost, $this ); }
+}
+
+// Core decimal formatter.
+function wc_format_decimal( $number, $dp = false, $trim_zeros = false ) {
+ $number = $number ?? '';
+
+ if ( '' === $number ) {
+ return '';
+ }
+
+ $locale = localeconv();
+ $decimals = array( wc_get_price_decimal_separator(), $locale['decimal_point'], $locale['mon_decimal_point'] );
+
+ // Remove locale from string.
+ if ( ! is_float( $number ) ) {
+ $number = str_replace( $decimals, '.', $number );
+
+ // Convert multiple dots to just one.
+ $number = preg_replace( '/\.(?![^.]+$)|[^0-9.-]/', '', wc_clean( $number ) );
+ }
+
+ if ( false !== $dp ) {
+ $dp = intval( '' === $dp ? wc_get_price_decimals() : $dp );
+ $number = number_format( floatval( $number ), $dp, '.', '' );
+ } elseif ( is_float( $number ) ) {
+ // DP is false - don't use number format, just return a string using whatever is given. Remove scientific notation using sprintf.
+ $number = str_replace( $decimals, '.', sprintf( '%.' . wc_get_rounding_precision() . 'f', $number ) );
+ // We already had a float, so trailing zeros are not needed.
+ $trim_zeros = true;
+ }
+
+ if ( $trim_zeros && strstr( $number, '.' ) ) {
+ $number = rtrim( rtrim( $number, '0' ), '.' );
+ }
+
+ return $number;
+}
diff --git a/.ai/review/evals/shipping-rate-pipeline/fixtures/G20B/before.php b/.ai/review/evals/shipping-rate-pipeline/fixtures/G20B/before.php
new file mode 100644
index 00000000000..4fc06234afe
--- /dev/null
+++ b/.ai/review/evals/shipping-rate-pipeline/fixtures/G20B/before.php
@@ -0,0 +1,58 @@
+<?php
+function make_export_rate( $args ) {
+ $args = wp_parse_args( $args, array( 'id' => 'flat_rate:1', 'label' => 'Delivery', 'cost' => '0', 'price_decimals' => false ) );
+ $total_cost = is_array( $args['cost'] ) ? array_sum( $args['cost'] ) : $args['cost'];
+ $total_cost = wc_format_decimal( $total_cost, $args['price_decimals'] );
+ if ( '' === $total_cost ) { $total_cost = '0'; }
+ return new WC_Shipping_Rate( $args['id'], $args['label'], $total_cost, array(), 'flat_rate', 1 );
+}
+function shipping_export_amount( $subtotal, $args ) {
+ return $subtotal + make_export_rate( $args )->get_cost();
+}
+$amount = shipping_export_amount( 10, array( 'cost' => '' ) );
+
+// Reduced WC_Shipping_Rate storage surface; cost setter/getter match the core class.
+class WC_Shipping_Rate {
+ private $cost;
+ public function __construct( $id = '', $label = '', $cost = 0, $taxes = array(), $method_id = '', $instance_id = 0 ) {
+ $this->set_cost( $cost );
+ }
+ public function set_cost( $cost ) { $this->cost = $cost; }
+ public function get_cost() { return apply_filters( 'woocommerce_shipping_rate_cost', $this->cost, $this ); }
+}
+
+// Core decimal formatter.
+function wc_format_decimal( $number, $dp = false, $trim_zeros = false ) {
+ $number = $number ?? '';
+
+ if ( '' === $number ) {
+ return '';
+ }
+
+ $locale = localeconv();
+ $decimals = array( wc_get_price_decimal_separator(), $locale['decimal_point'], $locale['mon_decimal_point'] );
+
+ // Remove locale from string.
+ if ( ! is_float( $number ) ) {
+ $number = str_replace( $decimals, '.', $number );
+
+ // Convert multiple dots to just one.
+ $number = preg_replace( '/\.(?![^.]+$)|[^0-9.-]/', '', wc_clean( $number ) );
+ }
+
+ if ( false !== $dp ) {
+ $dp = intval( '' === $dp ? wc_get_price_decimals() : $dp );
+ $number = number_format( floatval( $number ), $dp, '.', '' );
+ } elseif ( is_float( $number ) ) {
+ // DP is false - don't use number format, just return a string using whatever is given. Remove scientific notation using sprintf.
+ $number = str_replace( $decimals, '.', sprintf( '%.' . wc_get_rounding_precision() . 'f', $number ) );
+ // We already had a float, so trailing zeros are not needed.
+ $trim_zeros = true;
+ }
+
+ if ( $trim_zeros && strstr( $number, '.' ) ) {
+ $number = rtrim( rtrim( $number, '0' ), '.' );
+ }
+
+ return $number;
+}
diff --git a/.ai/review/evals/shipping-rate-pipeline/fixtures/G20D/after.php b/.ai/review/evals/shipping-rate-pipeline/fixtures/G20D/after.php
new file mode 100644
index 00000000000..d97294cb167
--- /dev/null
+++ b/.ai/review/evals/shipping-rate-pipeline/fixtures/G20D/after.php
@@ -0,0 +1,63 @@
+<?php
+function make_export_rate( $args ) {
+ $args = wp_parse_args( $args, array( 'id' => 'flat_rate:1', 'label' => 'Delivery', 'cost' => '', 'price_decimals' => false ) );
+ $total_cost = is_array( $args['cost'] ) ? array_sum( $args['cost'] ) : $args['cost'];
+ if ( '' === $total_cost ) { $total_cost = '0'; }
+ $total_cost = wc_format_decimal( $total_cost, $args['price_decimals'] );
+ if ( '' === $total_cost ) { $total_cost = '0'; }
+ return new WC_Shipping_Rate( $args['id'], $args['label'], $total_cost, array(), 'flat_rate', 1 );
+}
+function shipping_export_amount( $subtotal, $args ) {
+ return $subtotal + make_export_rate( $args )->get_cost();
+}
+$amounts = array(
+ shipping_export_amount( 10, array() ),
+ shipping_export_amount( 10, array( 'cost' => '' ) ),
+ shipping_export_amount( 10, array( 'cost' => '0' ) ),
+);
+
+// Reduced WC_Shipping_Rate storage surface; cost setter/getter match the core class.
+class WC_Shipping_Rate {
+ private $cost;
+ public function __construct( $id = '', $label = '', $cost = 0, $taxes = array(), $method_id = '', $instance_id = 0 ) {
+ $this->set_cost( $cost );
+ }
+ public function set_cost( $cost ) { $this->cost = $cost; }
+ public function get_cost() { return apply_filters( 'woocommerce_shipping_rate_cost', $this->cost, $this ); }
+}
+
+// Core decimal formatter.
+function wc_format_decimal( $number, $dp = false, $trim_zeros = false ) {
+ $number = $number ?? '';
+
+ if ( '' === $number ) {
+ return '';
+ }
+
+ $locale = localeconv();
+ $decimals = array( wc_get_price_decimal_separator(), $locale['decimal_point'], $locale['mon_decimal_point'] );
+
+ // Remove locale from string.
+ if ( ! is_float( $number ) ) {
+ $number = str_replace( $decimals, '.', $number );
+
+ // Convert multiple dots to just one.
+ $number = preg_replace( '/\.(?![^.]+$)|[^0-9.-]/', '', wc_clean( $number ) );
+ }
+
+ if ( false !== $dp ) {
+ $dp = intval( '' === $dp ? wc_get_price_decimals() : $dp );
+ $number = number_format( floatval( $number ), $dp, '.', '' );
+ } elseif ( is_float( $number ) ) {
+ // DP is false - don't use number format, just return a string using whatever is given. Remove scientific notation using sprintf.
+ $number = str_replace( $decimals, '.', sprintf( '%.' . wc_get_rounding_precision() . 'f', $number ) );
+ // We already had a float, so trailing zeros are not needed.
+ $trim_zeros = true;
+ }
+
+ if ( $trim_zeros && strstr( $number, '.' ) ) {
+ $number = rtrim( rtrim( $number, '0' ), '.' );
+ }
+
+ return $number;
+}
diff --git a/.ai/review/evals/shipping-rate-pipeline/fixtures/G20D/before.php b/.ai/review/evals/shipping-rate-pipeline/fixtures/G20D/before.php
new file mode 100644
index 00000000000..1f580c0b0a6
--- /dev/null
+++ b/.ai/review/evals/shipping-rate-pipeline/fixtures/G20D/before.php
@@ -0,0 +1,63 @@
+<?php
+function make_export_rate( $args ) {
+ $args = wp_parse_args( $args, array( 'id' => 'flat_rate:1', 'label' => 'Delivery', 'cost' => '0', 'price_decimals' => false ) );
+ $total_cost = is_array( $args['cost'] ) ? array_sum( $args['cost'] ) : $args['cost'];
+ if ( '' === $total_cost ) { $total_cost = '0'; }
+ $total_cost = wc_format_decimal( $total_cost, $args['price_decimals'] );
+ if ( '' === $total_cost ) { $total_cost = '0'; }
+ return new WC_Shipping_Rate( $args['id'], $args['label'], $total_cost, array(), 'flat_rate', 1 );
+}
+function shipping_export_amount( $subtotal, $args ) {
+ return $subtotal + make_export_rate( $args )->get_cost();
+}
+$amounts = array(
+ shipping_export_amount( 10, array() ),
+ shipping_export_amount( 10, array( 'cost' => '' ) ),
+ shipping_export_amount( 10, array( 'cost' => '0' ) ),
+);
+
+// Reduced WC_Shipping_Rate storage surface; cost setter/getter match the core class.
+class WC_Shipping_Rate {
+ private $cost;
+ public function __construct( $id = '', $label = '', $cost = 0, $taxes = array(), $method_id = '', $instance_id = 0 ) {
+ $this->set_cost( $cost );
+ }
+ public function set_cost( $cost ) { $this->cost = $cost; }
+ public function get_cost() { return apply_filters( 'woocommerce_shipping_rate_cost', $this->cost, $this ); }
+}
+
+// Core decimal formatter.
+function wc_format_decimal( $number, $dp = false, $trim_zeros = false ) {
+ $number = $number ?? '';
+
+ if ( '' === $number ) {
+ return '';
+ }
+
+ $locale = localeconv();
+ $decimals = array( wc_get_price_decimal_separator(), $locale['decimal_point'], $locale['mon_decimal_point'] );
+
+ // Remove locale from string.
+ if ( ! is_float( $number ) ) {
+ $number = str_replace( $decimals, '.', $number );
+
+ // Convert multiple dots to just one.
+ $number = preg_replace( '/\.(?![^.]+$)|[^0-9.-]/', '', wc_clean( $number ) );
+ }
+
+ if ( false !== $dp ) {
+ $dp = intval( '' === $dp ? wc_get_price_decimals() : $dp );
+ $number = number_format( floatval( $number ), $dp, '.', '' );
+ } elseif ( is_float( $number ) ) {
+ // DP is false - don't use number format, just return a string using whatever is given. Remove scientific notation using sprintf.
+ $number = str_replace( $decimals, '.', sprintf( '%.' . wc_get_rounding_precision() . 'f', $number ) );
+ // We already had a float, so trailing zeros are not needed.
+ $trim_zeros = true;
+ }
+
+ if ( $trim_zeros && strstr( $number, '.' ) ) {
+ $number = rtrim( rtrim( $number, '0' ), '.' );
+ }
+
+ return $number;
+}
diff --git a/.ai/review/evals/sql-clause-filter-contract/cases.json b/.ai/review/evals/sql-clause-filter-contract/cases.json
new file mode 100644
index 00000000000..998b57e295c
--- /dev/null
+++ b/.ai/review/evals/sql-clause-filter-contract/cases.json
@@ -0,0 +1,85 @@
+{
+ "version": 1,
+ "cases": [
+ {
+ "id": "G21A-v3",
+ "path": "plugins/woocommerce/includes/class-wc-eval-21.php",
+ "before": {
+ "file": "fixtures/G21A/before.php"
+ },
+ "after": {
+ "file": "fixtures/G21A/after.php"
+ },
+ "expect": {
+ "outcome": "finding",
+ "locations": [
+ {
+ "file": "plugins/woocommerce/includes/class-wc-eval-21.php",
+ "lines": [
+ 5,
+ 5
+ ],
+ "anchor": " $clauses['join'] .= \" LEFT JOIN {$wpdb->prefix}wc_product_meta_lookup lookup ON {$wpdb->posts}.ID = lookup.product_id\";"
+ },
+ {
+ "file": "plugins/woocommerce/includes/class-wc-eval-21.php",
+ "lines": [
+ 10,
+ 10
+ ],
+ "anchor": " $clauses['orderby'] = 'wc_product_meta_lookup.stock_quantity DESC';"
+ }
+ ]
+ }
+ },
+ {
+ "id": "G21B-v3",
+ "path": "plugins/woocommerce/includes/class-wc-eval-21.php",
+ "before": {
+ "file": "fixtures/G21B/before.php"
+ },
+ "after": {
+ "file": "fixtures/G21B/after.php"
+ },
+ "expect": {
+ "outcome": "finding",
+ "locations": [
+ {
+ "file": "plugins/woocommerce/includes/class-wc-eval-21.php",
+ "lines": [
+ 5,
+ 5
+ ],
+ "anchor": " $clauses['join'] = \" LEFT JOIN {$wpdb->prefix}wc_product_meta_lookup wc_product_meta_lookup ON {$wpdb->posts}.ID = wc_product_meta_lookup.product_id\";"
+ }
+ ]
+ }
+ },
+ {
+ "id": "G21C-v3",
+ "path": "plugins/woocommerce/includes/class-wc-eval-21.php",
+ "before": {
+ "file": "fixtures/G21C/before.php"
+ },
+ "after": {
+ "file": "fixtures/G21C/after.php"
+ },
+ "expect": {
+ "outcome": "quiet"
+ }
+ },
+ {
+ "id": "G21D-v3",
+ "path": "plugins/woocommerce/includes/class-wc-eval-21.php",
+ "before": {
+ "file": "fixtures/G21D/before.php"
+ },
+ "after": {
+ "file": "fixtures/G21D/after.php"
+ },
+ "expect": {
+ "outcome": "quiet"
+ }
+ }
+ ]
+}
diff --git a/.ai/review/evals/sql-clause-filter-contract/fixtures/G21A/after.php b/.ai/review/evals/sql-clause-filter-contract/fixtures/G21A/after.php
new file mode 100644
index 00000000000..0c9cde8e101
--- /dev/null
+++ b/.ai/review/evals/sql-clause-filter-contract/fixtures/G21A/after.php
@@ -0,0 +1,13 @@
+<?php
+/* Catalog-query callbacks; extensions can register posts_clauses callbacks. */
+function wc_catalog_clauses( $clauses ) {
+ global $wpdb;
+ $clauses['join'] .= " LEFT JOIN {$wpdb->prefix}wc_product_meta_lookup lookup ON {$wpdb->posts}.ID = lookup.product_id";
+ return $clauses;
+}
+add_filter( 'posts_clauses', 'wc_catalog_clauses', 10 );
+function extension_stock_sort( $clauses ) {
+ $clauses['orderby'] = 'wc_product_meta_lookup.stock_quantity DESC';
+ return $clauses;
+}
+add_filter( 'posts_clauses', 'extension_stock_sort', 20 );
diff --git a/.ai/review/evals/sql-clause-filter-contract/fixtures/G21A/before.php b/.ai/review/evals/sql-clause-filter-contract/fixtures/G21A/before.php
new file mode 100644
index 00000000000..3b332c62af4
--- /dev/null
+++ b/.ai/review/evals/sql-clause-filter-contract/fixtures/G21A/before.php
@@ -0,0 +1,13 @@
+<?php
+/* Catalog-query callbacks; extensions can register posts_clauses callbacks. */
+function wc_catalog_clauses( $clauses ) {
+ global $wpdb;
+ $clauses['join'] .= " LEFT JOIN {$wpdb->prefix}wc_product_meta_lookup wc_product_meta_lookup ON {$wpdb->posts}.ID = wc_product_meta_lookup.product_id";
+ return $clauses;
+}
+add_filter( 'posts_clauses', 'wc_catalog_clauses', 10 );
+function extension_stock_sort( $clauses ) {
+ $clauses['orderby'] = 'wc_product_meta_lookup.stock_quantity DESC';
+ return $clauses;
+}
+add_filter( 'posts_clauses', 'extension_stock_sort', 20 );
diff --git a/.ai/review/evals/sql-clause-filter-contract/fixtures/G21B/after.php b/.ai/review/evals/sql-clause-filter-contract/fixtures/G21B/after.php
new file mode 100644
index 00000000000..5584c9ca8f8
--- /dev/null
+++ b/.ai/review/evals/sql-clause-filter-contract/fixtures/G21B/after.php
@@ -0,0 +1,20 @@
+<?php
+/* Catalog-query callbacks. The catalog_region table has a unique product_id column. */
+function wc_catalog_clauses( $clauses ) {
+ global $wpdb;
+ $clauses['join'] = " LEFT JOIN {$wpdb->prefix}wc_product_meta_lookup wc_product_meta_lookup ON {$wpdb->posts}.ID = wc_product_meta_lookup.product_id";
+ return $clauses;
+}
+add_filter( 'posts_clauses', 'wc_catalog_clauses', 10 );
+function extension_stock_sort( $clauses ) {
+ $clauses['orderby'] = 'wc_product_meta_lookup.stock_quantity DESC';
+ return $clauses;
+}
+add_filter( 'posts_clauses', 'extension_stock_sort', 20 );
+function extension_region( $clauses ) {
+ global $wpdb;
+ $clauses['join'] .= " JOIN {$wpdb->prefix}catalog_region region ON region.product_id={$wpdb->posts}.ID";
+ $clauses['where'] .= " AND region.code='EU'";
+ return $clauses;
+}
+add_filter( 'posts_clauses', 'extension_region', 5 );
diff --git a/.ai/review/evals/sql-clause-filter-contract/fixtures/G21B/before.php b/.ai/review/evals/sql-clause-filter-contract/fixtures/G21B/before.php
new file mode 100644
index 00000000000..ec4c5e868fa
--- /dev/null
+++ b/.ai/review/evals/sql-clause-filter-contract/fixtures/G21B/before.php
@@ -0,0 +1,20 @@
+<?php
+/* Catalog-query callbacks. The catalog_region table has a unique product_id column. */
+function wc_catalog_clauses( $clauses ) {
+ global $wpdb;
+ $clauses['join'] .= " LEFT JOIN {$wpdb->prefix}wc_product_meta_lookup wc_product_meta_lookup ON {$wpdb->posts}.ID = wc_product_meta_lookup.product_id";
+ return $clauses;
+}
+add_filter( 'posts_clauses', 'wc_catalog_clauses', 10 );
+function extension_stock_sort( $clauses ) {
+ $clauses['orderby'] = 'wc_product_meta_lookup.stock_quantity DESC';
+ return $clauses;
+}
+add_filter( 'posts_clauses', 'extension_stock_sort', 20 );
+function extension_region( $clauses ) {
+ global $wpdb;
+ $clauses['join'] .= " JOIN {$wpdb->prefix}catalog_region region ON region.product_id={$wpdb->posts}.ID";
+ $clauses['where'] .= " AND region.code='EU'";
+ return $clauses;
+}
+add_filter( 'posts_clauses', 'extension_region', 5 );
diff --git a/.ai/review/evals/sql-clause-filter-contract/fixtures/G21C/after.php b/.ai/review/evals/sql-clause-filter-contract/fixtures/G21C/after.php
new file mode 100644
index 00000000000..5e4f17fc141
--- /dev/null
+++ b/.ai/review/evals/sql-clause-filter-contract/fixtures/G21C/after.php
@@ -0,0 +1,3 @@
+<?php
+/* Internal maintenance report; this function is the complete SQL builder. */
+function wc_internal_stock_sql() { global $wpdb; return "SELECT lookup.stock_quantity FROM {$wpdb->prefix}wc_product_meta_lookup lookup ORDER BY lookup.product_id"; }
diff --git a/.ai/review/evals/sql-clause-filter-contract/fixtures/G21C/before.php b/.ai/review/evals/sql-clause-filter-contract/fixtures/G21C/before.php
new file mode 100644
index 00000000000..749214b6c40
--- /dev/null
+++ b/.ai/review/evals/sql-clause-filter-contract/fixtures/G21C/before.php
@@ -0,0 +1,3 @@
+<?php
+/* Internal maintenance report; this function is the complete SQL builder. */
+function wc_internal_stock_sql() { global $wpdb; return "SELECT inventory.stock_quantity FROM {$wpdb->prefix}wc_product_meta_lookup inventory ORDER BY inventory.product_id"; }
diff --git a/.ai/review/evals/sql-clause-filter-contract/fixtures/G21D/after.php b/.ai/review/evals/sql-clause-filter-contract/fixtures/G21D/after.php
new file mode 100644
index 00000000000..8b534b5807a
--- /dev/null
+++ b/.ai/review/evals/sql-clause-filter-contract/fixtures/G21D/after.php
@@ -0,0 +1,3 @@
+<?php
+/* Internal maintenance report; query receives trusted SQL order terms. Ties have no specified ordering. */
+class WC_Internal_Catalog_Order { private function build_orderby( $orderby ) { global $wpdb; return empty( $orderby ) ? "{$wpdb->posts}.ID ASC" : $orderby . ", {$wpdb->posts}.ID ASC"; } public function query( $orderby ) { global $wpdb; $order = $this->build_orderby( $orderby ); return $wpdb->get_col( "SELECT ID FROM {$wpdb->posts}" . ( $order ? ' ORDER BY ' . $order : '' ) ); } }
diff --git a/.ai/review/evals/sql-clause-filter-contract/fixtures/G21D/before.php b/.ai/review/evals/sql-clause-filter-contract/fixtures/G21D/before.php
new file mode 100644
index 00000000000..4f4ea23fe07
--- /dev/null
+++ b/.ai/review/evals/sql-clause-filter-contract/fixtures/G21D/before.php
@@ -0,0 +1,3 @@
+<?php
+/* Internal maintenance report; query receives trusted SQL order terms. Ties have no specified ordering. */
+class WC_Internal_Catalog_Order { private function build_orderby( $orderby ) { global $wpdb; return $orderby; } public function query( $orderby ) { global $wpdb; $order = $this->build_orderby( $orderby ); return $wpdb->get_col( "SELECT ID FROM {$wpdb->posts}" . ( $order ? ' ORDER BY ' . $order : '' ) ); } }
diff --git a/.ai/review/evals/template-and-js-default/cases.json b/.ai/review/evals/template-and-js-default/cases.json
new file mode 100644
index 00000000000..44faca673e7
--- /dev/null
+++ b/.ai/review/evals/template-and-js-default/cases.json
@@ -0,0 +1,77 @@
+{
+ "version": 1,
+ "cases": [
+ {
+ "id": "G15A-v3",
+ "path": "plugins/woocommerce/templates/single-product/add-to-cart/simple.php",
+ "before": {
+ "file": "fixtures/G15A/before.php"
+ },
+ "after": {
+ "file": "fixtures/G15A/after.php"
+ },
+ "expect": {
+ "outcome": "finding",
+ "locations": [
+ {
+ "file": "plugins/woocommerce/templates/single-product/add-to-cart/simple.php",
+ "lines": [
+ 7,
+ 7
+ ],
+ "anchor": " <button type=\"submit\" disabled class=\"single_add_to_cart_button\">Add to cart</button>"
+ }
+ ]
+ }
+ },
+ {
+ "id": "G15B-v3",
+ "path": "plugins/woocommerce/templates/single-product/add-to-cart/simple.php",
+ "before": {
+ "file": "fixtures/G15B/before.php"
+ },
+ "after": {
+ "file": "fixtures/G15B/after.php"
+ },
+ "expect": {
+ "outcome": "finding",
+ "locations": [
+ {
+ "file": "plugins/woocommerce/templates/single-product/add-to-cart/simple.php",
+ "lines": [
+ 5,
+ 5
+ ],
+ "anchor": "<form method=\"post\" hidden>"
+ }
+ ]
+ }
+ },
+ {
+ "id": "G15C-v3",
+ "path": "plugins/woocommerce/templates/single-product/add-to-cart/simple.php",
+ "before": {
+ "file": "fixtures/G15C/before.php"
+ },
+ "after": {
+ "file": "fixtures/G15C/after.php"
+ },
+ "expect": {
+ "outcome": "quiet"
+ }
+ },
+ {
+ "id": "G15D-v3",
+ "path": "plugins/woocommerce/templates/single-product/add-to-cart/simple.php",
+ "before": {
+ "file": "fixtures/G15D/before.php"
+ },
+ "after": {
+ "file": "fixtures/G15D/after.php"
+ },
+ "expect": {
+ "outcome": "quiet"
+ }
+ }
+ ]
+}
diff --git a/.ai/review/evals/template-and-js-default/fixtures/G15A/after.php b/.ai/review/evals/template-and-js-default/fixtures/G15A/after.php
new file mode 100644
index 00000000000..69bf506997a
--- /dev/null
+++ b/.ai/review/evals/template-and-js-default/fixtures/G15A/after.php
@@ -0,0 +1,9 @@
+<?php
+/* Page-builder purchase template; frontend scripts are optional. */
+/** Product purchase form. @version 1.1.0 */
+?>
+<form method="post">
+ <input type="hidden" name="add-to-cart" value="42">
+ <button type="submit" disabled class="single_add_to_cart_button">Add to cart</button>
+</form>
+<?php
diff --git a/.ai/review/evals/template-and-js-default/fixtures/G15A/before.php b/.ai/review/evals/template-and-js-default/fixtures/G15A/before.php
new file mode 100644
index 00000000000..076b6134438
--- /dev/null
+++ b/.ai/review/evals/template-and-js-default/fixtures/G15A/before.php
@@ -0,0 +1,9 @@
+<?php
+/* Page-builder purchase template; frontend scripts are optional. */
+/** Product purchase form. @version 1.0.0 */
+?>
+<form method="post">
+ <input type="hidden" name="add-to-cart" value="42">
+ <button type="submit" class="single_add_to_cart_button">Add to cart</button>
+</form>
+<?php
diff --git a/.ai/review/evals/template-and-js-default/fixtures/G15B/after.php b/.ai/review/evals/template-and-js-default/fixtures/G15B/after.php
new file mode 100644
index 00000000000..173fe881e1a
--- /dev/null
+++ b/.ai/review/evals/template-and-js-default/fixtures/G15B/after.php
@@ -0,0 +1,9 @@
+<?php
+/* Page-builder purchase template; frontend scripts are optional. */
+/** Product purchase form. @version 1.1.0 */
+?>
+<form method="post" hidden>
+ <input type="hidden" name="add-to-cart" value="42">
+ <button type="submit" class="single_add_to_cart_button">Add to cart</button>
+</form>
+<?php
diff --git a/.ai/review/evals/template-and-js-default/fixtures/G15B/before.php b/.ai/review/evals/template-and-js-default/fixtures/G15B/before.php
new file mode 100644
index 00000000000..076b6134438
--- /dev/null
+++ b/.ai/review/evals/template-and-js-default/fixtures/G15B/before.php
@@ -0,0 +1,9 @@
+<?php
+/* Page-builder purchase template; frontend scripts are optional. */
+/** Product purchase form. @version 1.0.0 */
+?>
+<form method="post">
+ <input type="hidden" name="add-to-cart" value="42">
+ <button type="submit" class="single_add_to_cart_button">Add to cart</button>
+</form>
+<?php
diff --git a/.ai/review/evals/template-and-js-default/fixtures/G15C/after.php b/.ai/review/evals/template-and-js-default/fixtures/G15C/after.php
new file mode 100644
index 00000000000..f62b0f65425
--- /dev/null
+++ b/.ai/review/evals/template-and-js-default/fixtures/G15C/after.php
@@ -0,0 +1,9 @@
+<?php
+/* Page-builder purchase template. This reduced example uses static English labels. */
+/** Product purchase form. @version 1.1.0 */
+?>
+<form method="post">
+ <input type="hidden" name="add-to-cart" value="42">
+ <button type="submit" class="single_add_to_cart_button">Add item to cart</button>
+</form>
+<?php
diff --git a/.ai/review/evals/template-and-js-default/fixtures/G15C/before.php b/.ai/review/evals/template-and-js-default/fixtures/G15C/before.php
new file mode 100644
index 00000000000..23304b43772
--- /dev/null
+++ b/.ai/review/evals/template-and-js-default/fixtures/G15C/before.php
@@ -0,0 +1,9 @@
+<?php
+/* Page-builder purchase template. This reduced example uses static English labels. */
+/** Product purchase form. @version 1.0.0 */
+?>
+<form method="post">
+ <input type="hidden" name="add-to-cart" value="42">
+ <button type="submit" class="single_add_to_cart_button">Add to cart</button>
+</form>
+<?php
diff --git a/.ai/review/evals/template-and-js-default/fixtures/G15D/after.php b/.ai/review/evals/template-and-js-default/fixtures/G15D/after.php
new file mode 100644
index 00000000000..25f788f0c06
--- /dev/null
+++ b/.ai/review/evals/template-and-js-default/fixtures/G15D/after.php
@@ -0,0 +1,10 @@
+<?php
+/* Page-builder purchase template; frontend scripts are optional. */
+/** Product purchase form. @version 1.1.0 */
+?>
+<form method="post">
+ <input type="hidden" name="add-to-cart" value="42">
+ <span class="purchase-progress" hidden>Adding item…</span>
+ <button type="submit" class="single_add_to_cart_button">Add to cart</button>
+</form>
+<?php
diff --git a/.ai/review/evals/template-and-js-default/fixtures/G15D/before.php b/.ai/review/evals/template-and-js-default/fixtures/G15D/before.php
new file mode 100644
index 00000000000..076b6134438
--- /dev/null
+++ b/.ai/review/evals/template-and-js-default/fixtures/G15D/before.php
@@ -0,0 +1,9 @@
+<?php
+/* Page-builder purchase template; frontend scripts are optional. */
+/** Product purchase form. @version 1.0.0 */
+?>
+<form method="post">
+ <input type="hidden" name="add-to-cart" value="42">
+ <button type="submit" class="single_add_to_cart_button">Add to cart</button>
+</form>
+<?php
diff --git a/.ai/review/evals/upgrade-time-class-load/cases.json b/.ai/review/evals/upgrade-time-class-load/cases.json
new file mode 100644
index 00000000000..e90598fb3ec
--- /dev/null
+++ b/.ai/review/evals/upgrade-time-class-load/cases.json
@@ -0,0 +1,77 @@
+{
+ "version": 1,
+ "cases": [
+ {
+ "id": "G05A-v3",
+ "path": "plugins/woocommerce/includes/class-wc-upgrade-time-class-load.php",
+ "before": {
+ "file": "fixtures/G05A/before.php"
+ },
+ "after": {
+ "file": "fixtures/G05A/after.php"
+ },
+ "expect": {
+ "outcome": "finding",
+ "locations": [
+ {
+ "file": "plugins/woocommerce/includes/class-wc-upgrade-time-class-load.php",
+ "lines": [
+ 9,
+ 9
+ ],
+ "anchor": " return array( 'default_customer_address' => \\Automattic\\WooCommerce\\Enums\\DefaultCustomerAddress::BASE );"
+ }
+ ]
+ }
+ },
+ {
+ "id": "G05B-v3",
+ "path": "plugins/woocommerce/includes/class-wc-upgrade-time-class-load.php",
+ "before": {
+ "file": "fixtures/G05B/before.php"
+ },
+ "after": {
+ "file": "fixtures/G05B/after.php"
+ },
+ "expect": {
+ "outcome": "finding",
+ "locations": [
+ {
+ "file": "plugins/woocommerce/includes/class-wc-upgrade-time-class-load.php",
+ "lines": [
+ 9,
+ 9
+ ],
+ "anchor": " $controller = new WC_New_Export_Controller();"
+ }
+ ]
+ }
+ },
+ {
+ "id": "G05C-v3",
+ "path": "plugins/woocommerce/includes/class-wc-upgrade-time-class-load.php",
+ "before": {
+ "file": "fixtures/G05C/before.php"
+ },
+ "after": {
+ "file": "fixtures/G05C/after.php"
+ },
+ "expect": {
+ "outcome": "quiet"
+ }
+ },
+ {
+ "id": "G05D-v3",
+ "path": "plugins/woocommerce/includes/class-wc-upgrade-time-class-load.php",
+ "before": {
+ "file": "fixtures/G05D/before.php"
+ },
+ "after": {
+ "file": "fixtures/G05D/after.php"
+ },
+ "expect": {
+ "outcome": "quiet"
+ }
+ }
+ ]
+}
diff --git a/.ai/review/evals/upgrade-time-class-load/fixtures/G05A/after.php b/.ai/review/evals/upgrade-time-class-load/fixtures/G05A/after.php
new file mode 100644
index 00000000000..5d84c6ebd91
--- /dev/null
+++ b/.ai/review/evals/upgrade-time-class-load/fixtures/G05A/after.php
@@ -0,0 +1,11 @@
+<?php
+// Historical in-place update harness: bootstrap started before the PSR-4 fallback was introduced.
+$boot_classmap = array( 'WC_Legacy_Settings' => __DIR__ . '/legacy-settings.php' );
+spl_autoload_register( static function ( $class ) use ( $boot_classmap ) {
+ if ( isset( $boot_classmap[ $class ] ) ) { require_once $boot_classmap[ $class ]; }
+} );
+// The updater has replaced files on disk; footer callbacks still run in this request.
+function wc_upgrade_footer_settings() {
+ return array( 'default_customer_address' => \Automattic\WooCommerce\Enums\DefaultCustomerAddress::BASE );
+}
+add_action( 'admin_print_footer_scripts', 'wc_upgrade_footer_settings' );
diff --git a/.ai/review/evals/upgrade-time-class-load/fixtures/G05A/before.php b/.ai/review/evals/upgrade-time-class-load/fixtures/G05A/before.php
new file mode 100644
index 00000000000..b0c76e09f50
--- /dev/null
+++ b/.ai/review/evals/upgrade-time-class-load/fixtures/G05A/before.php
@@ -0,0 +1,11 @@
+<?php
+// Historical in-place update harness: bootstrap started before the PSR-4 fallback was introduced.
+$boot_classmap = array( 'WC_Legacy_Settings' => __DIR__ . '/legacy-settings.php' );
+spl_autoload_register( static function ( $class ) use ( $boot_classmap ) {
+ if ( isset( $boot_classmap[ $class ] ) ) { require_once $boot_classmap[ $class ]; }
+} );
+// The updater has replaced files on disk; footer callbacks still run in this request.
+function wc_upgrade_footer_settings() {
+ return array( 'default_customer_address' => 'base' );
+}
+add_action( 'admin_print_footer_scripts', 'wc_upgrade_footer_settings' );
diff --git a/.ai/review/evals/upgrade-time-class-load/fixtures/G05B/after.php b/.ai/review/evals/upgrade-time-class-load/fixtures/G05B/after.php
new file mode 100644
index 00000000000..9f70dc3db89
--- /dev/null
+++ b/.ai/review/evals/upgrade-time-class-load/fixtures/G05B/after.php
@@ -0,0 +1,13 @@
+<?php
+// Historical in-place update harness: bootstrap started before the PSR-4 fallback was introduced.
+$boot_classmap = array( 'WC_Legacy_Settings' => __DIR__ . '/legacy-settings.php' );
+spl_autoload_register( static function ( $class ) use ( $boot_classmap ) {
+ if ( isset( $boot_classmap[ $class ] ) ) { require_once $boot_classmap[ $class ]; }
+} );
+// The updater has replaced files on disk; footer callbacks still run in this request.
+function wc_register_export_routes() {
+ $controller = new WC_New_Export_Controller();
+ $controller->register_routes();
+}
+add_action( 'rest_api_init', 'wc_register_export_routes' );
+add_action( 'admin_print_footer_scripts', static function () { rest_get_server(); } );
diff --git a/.ai/review/evals/upgrade-time-class-load/fixtures/G05B/before.php b/.ai/review/evals/upgrade-time-class-load/fixtures/G05B/before.php
new file mode 100644
index 00000000000..502a95dead0
--- /dev/null
+++ b/.ai/review/evals/upgrade-time-class-load/fixtures/G05B/before.php
@@ -0,0 +1,15 @@
+<?php
+// Historical in-place update harness: bootstrap started before the PSR-4 fallback was introduced.
+$boot_classmap = array( 'WC_Legacy_Settings' => __DIR__ . '/legacy-settings.php' );
+spl_autoload_register( static function ( $class ) use ( $boot_classmap ) {
+ if ( isset( $boot_classmap[ $class ] ) ) { require_once $boot_classmap[ $class ]; }
+} );
+// The updater has replaced files on disk; footer callbacks still run in this request.
+function wc_register_export_routes() {
+ if ( class_exists( 'WC_New_Export_Controller' ) ) {
+ $controller = new WC_New_Export_Controller();
+ $controller->register_routes();
+ }
+}
+add_action( 'rest_api_init', 'wc_register_export_routes' );
+add_action( 'admin_print_footer_scripts', static function () { rest_get_server(); } );
diff --git a/.ai/review/evals/upgrade-time-class-load/fixtures/G05C/after.php b/.ai/review/evals/upgrade-time-class-load/fixtures/G05C/after.php
new file mode 100644
index 00000000000..844fbaea6ce
--- /dev/null
+++ b/.ai/review/evals/upgrade-time-class-load/fixtures/G05C/after.php
@@ -0,0 +1,15 @@
+<?php
+// Historical in-place update harness: bootstrap started before the PSR-4 fallback was introduced.
+$boot_classmap = array( 'WC_Legacy_Settings' => __DIR__ . '/legacy-settings.php' );
+spl_autoload_register( static function ( $class ) use ( $boot_classmap ) {
+ if ( isset( $boot_classmap[ $class ] ) ) { require_once $boot_classmap[ $class ]; }
+} );
+// The updater has replaced files on disk; footer callbacks still run in this request.
+function wc_upgrade_footer_settings() {
+ $default = 'base';
+ if ( class_exists( '\Automattic\WooCommerce\Enums\DefaultCustomerAddress' ) ) {
+ $default = \Automattic\WooCommerce\Enums\DefaultCustomerAddress::BASE;
+ }
+ return array( 'default_customer_address' => $default );
+}
+add_action( 'admin_print_footer_scripts', 'wc_upgrade_footer_settings' );
diff --git a/.ai/review/evals/upgrade-time-class-load/fixtures/G05C/before.php b/.ai/review/evals/upgrade-time-class-load/fixtures/G05C/before.php
new file mode 100644
index 00000000000..b0c76e09f50
--- /dev/null
+++ b/.ai/review/evals/upgrade-time-class-load/fixtures/G05C/before.php
@@ -0,0 +1,11 @@
+<?php
+// Historical in-place update harness: bootstrap started before the PSR-4 fallback was introduced.
+$boot_classmap = array( 'WC_Legacy_Settings' => __DIR__ . '/legacy-settings.php' );
+spl_autoload_register( static function ( $class ) use ( $boot_classmap ) {
+ if ( isset( $boot_classmap[ $class ] ) ) { require_once $boot_classmap[ $class ]; }
+} );
+// The updater has replaced files on disk; footer callbacks still run in this request.
+function wc_upgrade_footer_settings() {
+ return array( 'default_customer_address' => 'base' );
+}
+add_action( 'admin_print_footer_scripts', 'wc_upgrade_footer_settings' );
diff --git a/.ai/review/evals/upgrade-time-class-load/fixtures/G05D/after.php b/.ai/review/evals/upgrade-time-class-load/fixtures/G05D/after.php
new file mode 100644
index 00000000000..0456b1399e2
--- /dev/null
+++ b/.ai/review/evals/upgrade-time-class-load/fixtures/G05D/after.php
@@ -0,0 +1,10 @@
+<?php
+/* New admin preview module; registered only on a fresh request after woocommerce_loaded. */
+class WC_Export_Formatter {
+ public function format( $id ) { return 'export-' . $id; }
+}
+class WC_New_Export_Preview {
+ public function label( $id ) {
+ return ( new WC_Export_Formatter() )->format( $id );
+ }
+}
diff --git a/.ai/review/evals/upgrade-time-class-load/fixtures/G05D/before.php b/.ai/review/evals/upgrade-time-class-load/fixtures/G05D/before.php
new file mode 100644
index 00000000000..0d59e3fe640
--- /dev/null
+++ b/.ai/review/evals/upgrade-time-class-load/fixtures/G05D/before.php
@@ -0,0 +1,5 @@
+<?php
+/* New admin preview module; registered only on a fresh request after woocommerce_loaded. */
+class WC_Export_Formatter {
+ public function format( $id ) { return 'export-' . $id; }
+}
diff --git a/.ai/review/evals/wc-query-shop-front-page/cases.json b/.ai/review/evals/wc-query-shop-front-page/cases.json
new file mode 100644
index 00000000000..ff2a02d1880
--- /dev/null
+++ b/.ai/review/evals/wc-query-shop-front-page/cases.json
@@ -0,0 +1,77 @@
+{
+ "version": 1,
+ "cases": [
+ {
+ "id": "G13A-v3",
+ "path": "plugins/woocommerce/includes/class-wc-query.php",
+ "before": {
+ "file": "fixtures/G13A/before.php"
+ },
+ "after": {
+ "file": "fixtures/G13A/after.php"
+ },
+ "expect": {
+ "outcome": "finding",
+ "locations": [
+ {
+ "file": "plugins/woocommerce/includes/class-wc-query.php",
+ "lines": [
+ 5,
+ 5
+ ],
+ "anchor": " if ( $q->get( 'page_id' ) === $shop_id || $q->get( 'pagename' ) === $shop_slug ) {"
+ }
+ ]
+ }
+ },
+ {
+ "id": "G13B-v3",
+ "path": "plugins/woocommerce/includes/class-wc-query.php",
+ "before": {
+ "file": "fixtures/G13B/before.php"
+ },
+ "after": {
+ "file": "fixtures/G13B/after.php"
+ },
+ "expect": {
+ "outcome": "finding",
+ "locations": [
+ {
+ "file": "plugins/woocommerce/includes/class-wc-query.php",
+ "lines": [
+ 4,
+ 4
+ ],
+ "anchor": " if ( $request['is_cli'] ) { return; }"
+ }
+ ]
+ }
+ },
+ {
+ "id": "G13C-v4",
+ "path": "plugins/woocommerce/includes/class-wc-query.php",
+ "before": {
+ "file": "fixtures/G13C/before.php"
+ },
+ "after": {
+ "file": "fixtures/G13C/after.php"
+ },
+ "expect": {
+ "outcome": "quiet"
+ }
+ },
+ {
+ "id": "G13D-v3",
+ "path": "plugins/woocommerce/includes/class-wc-query.php",
+ "before": {
+ "file": "fixtures/G13D/before.php"
+ },
+ "after": {
+ "file": "fixtures/G13D/after.php"
+ },
+ "expect": {
+ "outcome": "quiet"
+ }
+ }
+ ]
+}
diff --git a/.ai/review/evals/wc-query-shop-front-page/fixtures/G13A/after.php b/.ai/review/evals/wc-query-shop-front-page/fixtures/G13A/after.php
new file mode 100644
index 00000000000..e05f99f6dee
--- /dev/null
+++ b/.ai/review/evals/wc-query-shop-front-page/fixtures/G13A/after.php
@@ -0,0 +1,27 @@
+<?php
+/* Reduced pre_get_posts callback for a site with the shop as its static front page. */
+function configure_shop_query( $q, $shop_id, $shop_slug ) {
+ if ( ! $q->is_main_query() || $q->get( 'page_builder_owns_shop' ) ) { return; }
+ if ( $q->get( 'page_id' ) === $shop_id || $q->get( 'pagename' ) === $shop_slug ) {
+ $q->set( 'page_id', 0 );
+ $q->set( 'post_type', 'product' );
+ $q->is_singular = false;
+ $q->is_post_type_archive = true;
+ $q->is_archive = true;
+ }
+}
+
+function on_shop_front_query( $q ) {
+ if ( 'page' !== get_option( 'show_on_front' ) ) { return; }
+ $shop_id = (int) wc_get_page_id( 'shop' );
+ if ( $shop_id <= 0 || $shop_id !== (int) get_option( 'page_on_front' ) ) { return; }
+ configure_shop_query( $q, $shop_id, (string) get_post_field( 'post_name', $shop_id ) );
+}
+add_action( 'pre_get_posts', 'on_shop_front_query' );
+
+function mark_page_builder_query( $q ) {
+ if ( $q->is_main_query() && 'yes' === get_option( 'page_builder_shop_enabled', 'no' ) ) {
+ $q->set( 'page_builder_owns_shop', true );
+ }
+}
+add_action( 'pre_get_posts', 'mark_page_builder_query', 5 );
diff --git a/.ai/review/evals/wc-query-shop-front-page/fixtures/G13A/before.php b/.ai/review/evals/wc-query-shop-front-page/fixtures/G13A/before.php
new file mode 100644
index 00000000000..d9901ca3031
--- /dev/null
+++ b/.ai/review/evals/wc-query-shop-front-page/fixtures/G13A/before.php
@@ -0,0 +1,27 @@
+<?php
+/* Reduced pre_get_posts callback for a site with the shop as its static front page. */
+function configure_shop_query( $q, $shop_id, $shop_slug ) {
+ if ( ! $q->is_main_query() || $q->get( 'page_builder_owns_shop' ) ) { return; }
+ if ( $q->get( 'page_id' ) === $shop_id ) {
+ $q->set( 'page_id', 0 );
+ $q->set( 'post_type', 'product' );
+ $q->is_singular = false;
+ $q->is_post_type_archive = true;
+ $q->is_archive = true;
+ }
+}
+
+function on_shop_front_query( $q ) {
+ if ( 'page' !== get_option( 'show_on_front' ) ) { return; }
+ $shop_id = (int) wc_get_page_id( 'shop' );
+ if ( $shop_id <= 0 || $shop_id !== (int) get_option( 'page_on_front' ) ) { return; }
+ configure_shop_query( $q, $shop_id, (string) get_post_field( 'post_name', $shop_id ) );
+}
+add_action( 'pre_get_posts', 'on_shop_front_query' );
+
+function mark_page_builder_query( $q ) {
+ if ( $q->is_main_query() && 'yes' === get_option( 'page_builder_shop_enabled', 'no' ) ) {
+ $q->set( 'page_builder_owns_shop', true );
+ }
+}
+add_action( 'pre_get_posts', 'mark_page_builder_query', 5 );
diff --git a/.ai/review/evals/wc-query-shop-front-page/fixtures/G13B/after.php b/.ai/review/evals/wc-query-shop-front-page/fixtures/G13B/after.php
new file mode 100644
index 00000000000..7be36311442
--- /dev/null
+++ b/.ai/review/evals/wc-query-shop-front-page/fixtures/G13B/after.php
@@ -0,0 +1,12 @@
+<?php
+/* Shop rewrite maintenance adapter. */
+function refresh_shop_rewrites( $request, $save ) {
+ if ( $request['is_cli'] ) { return; }
+ if ( $request['shop_is_front'] ) {
+ $save( array( 'shop_base' => $request['front_slug'] ) );
+ }
+}
+
+function refresh_from_cron( $save ) {
+ refresh_shop_rewrites( array( 'is_cli' => false, 'is_cron' => true, 'shop_is_front' => true, 'front_slug' => '' ), $save );
+}
diff --git a/.ai/review/evals/wc-query-shop-front-page/fixtures/G13B/before.php b/.ai/review/evals/wc-query-shop-front-page/fixtures/G13B/before.php
new file mode 100644
index 00000000000..2c90af26048
--- /dev/null
+++ b/.ai/review/evals/wc-query-shop-front-page/fixtures/G13B/before.php
@@ -0,0 +1,12 @@
+<?php
+/* Shop rewrite maintenance adapter. */
+function refresh_shop_rewrites( $request, $save ) {
+ if ( $request['is_cli'] || $request['is_cron'] ) { return; }
+ if ( $request['shop_is_front'] ) {
+ $save( array( 'shop_base' => $request['front_slug'] ) );
+ }
+}
+
+function refresh_from_cron( $save ) {
+ refresh_shop_rewrites( array( 'is_cli' => false, 'is_cron' => true, 'shop_is_front' => true, 'front_slug' => '' ), $save );
+}
diff --git a/.ai/review/evals/wc-query-shop-front-page/fixtures/G13C/after.php b/.ai/review/evals/wc-query-shop-front-page/fixtures/G13C/after.php
new file mode 100644
index 00000000000..859685b65ca
--- /dev/null
+++ b/.ai/review/evals/wc-query-shop-front-page/fixtures/G13C/after.php
@@ -0,0 +1,23 @@
+<?php
+final class Shop_Front_Query {
+ public function register() {
+ add_action( 'pre_get_posts', array( $this, 'configure' ) );
+ }
+
+ public function configure( WP_Query $query ) {
+ if ( ! $query->is_main_query() || 'page' !== get_option( 'show_on_front' ) ) { return; }
+ $shop_id = (int) wc_get_page_id( 'shop' );
+ if ( $shop_id <= 0 || $shop_id !== (int) get_option( 'page_on_front' ) ) { return; }
+ if ( $shop_id !== (int) $query->get( 'page_id' ) ) { return; }
+
+ $query->set( 'page_id', 0 );
+ $query->set( 'pagename', '' );
+ $query->set( 'name', '' );
+ $query->set( 'post_type', 'product' );
+ $query->is_singular = false;
+ $query->is_post_type_archive = true;
+ $query->is_archive = true;
+ }
+}
+
+( new Shop_Front_Query() )->register();
diff --git a/.ai/review/evals/wc-query-shop-front-page/fixtures/G13C/before.php b/.ai/review/evals/wc-query-shop-front-page/fixtures/G13C/before.php
new file mode 100644
index 00000000000..6d11dc98596
--- /dev/null
+++ b/.ai/review/evals/wc-query-shop-front-page/fixtures/G13C/before.php
@@ -0,0 +1,21 @@
+<?php
+final class Shop_Front_Query {
+ public function register() {
+ add_action( 'pre_get_posts', array( $this, 'configure' ) );
+ }
+
+ public function configure( WP_Query $query ) {
+ if ( ! $query->is_main_query() || 'page' !== get_option( 'show_on_front' ) ) { return; }
+ $shop_id = (int) wc_get_page_id( 'shop' );
+ if ( $shop_id <= 0 || $shop_id !== (int) get_option( 'page_on_front' ) ) { return; }
+ if ( $shop_id !== (int) $query->get( 'page_id' ) ) { return; }
+
+ $query->set( 'page_id', 0 );
+ $query->set( 'post_type', 'product' );
+ $query->is_singular = false;
+ $query->is_post_type_archive = true;
+ $query->is_archive = true;
+ }
+}
+
+( new Shop_Front_Query() )->register();
diff --git a/.ai/review/evals/wc-query-shop-front-page/fixtures/G13D/after.php b/.ai/review/evals/wc-query-shop-front-page/fixtures/G13D/after.php
new file mode 100644
index 00000000000..7b455f1efc1
--- /dev/null
+++ b/.ai/review/evals/wc-query-shop-front-page/fixtures/G13D/after.php
@@ -0,0 +1,9 @@
+<?php
+/* Secondary product widget query. */
+function configure_product_widget( $q ) {
+ if ( $q->is_main_query() ) { return; }
+ if ( $q->get( 'widget_catalog' ) ) {
+ $q->set( 'post_type', 'product' );
+ $q->set( 'posts_per_page', 8 );
+ }
+}
diff --git a/.ai/review/evals/wc-query-shop-front-page/fixtures/G13D/before.php b/.ai/review/evals/wc-query-shop-front-page/fixtures/G13D/before.php
new file mode 100644
index 00000000000..2b499eac1bc
--- /dev/null
+++ b/.ai/review/evals/wc-query-shop-front-page/fixtures/G13D/before.php
@@ -0,0 +1,9 @@
+<?php
+/* Secondary product widget query. */
+function configure_product_widget( $q ) {
+ if ( $q->is_main_query() ) { return; }
+ if ( $q->get( 'widget_catalog' ) ) {
+ $q->set( 'post_type', 'product' );
+ $q->set( 'posts_per_page', 4 );
+ }
+}
diff --git a/ai-review-config.json b/ai-review-config.json
index a79a5a82298..7b9db6da90d 100644
--- a/ai-review-config.json
+++ b/ai-review-config.json
@@ -157,7 +157,8 @@
"plugins/*/changelog/*",
"plugins/woocommerce/client/blocks/changelog/*",
"packages/js/*/changelog/*",
- "packages/php/*/changelog/*"
+ "packages/php/*/changelog/*",
+ ".ai/review/evals/**"
],
"guidelines": [
{
@@ -179,7 +180,8 @@
],
"personas": [
"regression"
- ]
+ ],
+ "evals": ".ai/review/evals/hook-payload-contract/cases.json"
},
{
"name": "hook-firing-scope",
@@ -211,7 +213,8 @@
],
"personas": [
"regression"
- ]
+ ],
+ "evals": ".ai/review/evals/hook-firing-scope/cases.json"
},
{
"name": "hook-callback-reentrancy",
@@ -228,7 +231,8 @@
],
"personas": [
"regression"
- ]
+ ],
+ "evals": ".ai/review/evals/hook-callback-reentrancy/cases.json"
},
{
"name": "php-loose-type-juggling",
@@ -262,7 +266,8 @@
],
"personas": [
"regression"
- ]
+ ],
+ "evals": ".ai/review/evals/php-loose-type-juggling/cases.json"
},
{
"name": "public-php-api-bc",
@@ -287,7 +292,8 @@
],
"personas": [
"regression"
- ]
+ ],
+ "evals": ".ai/review/evals/public-php-api-bc/cases.json"
},
{
"name": "upgrade-time-class-load",
@@ -314,7 +320,8 @@
],
"personas": [
"regression"
- ]
+ ],
+ "evals": ".ai/review/evals/upgrade-time-class-load/cases.json"
},
{
"name": "action-scheduler-lifecycle",
@@ -343,7 +350,8 @@
],
"personas": [
"regression"
- ]
+ ],
+ "evals": ".ai/review/evals/action-scheduler-lifecycle/cases.json"
},
{
"name": "request-input-validation",
@@ -379,7 +387,8 @@
],
"personas": [
"regression"
- ]
+ ],
+ "evals": ".ai/review/evals/request-input-validation/cases.json"
},
{
"name": "missing-object-deref",
@@ -415,7 +424,8 @@
],
"personas": [
"regression"
- ]
+ ],
+ "evals": ".ai/review/evals/missing-object-deref/cases.json"
},
{
"name": "meta-option-value-semantics",
@@ -447,7 +457,8 @@
],
"personas": [
"regression"
- ]
+ ],
+ "evals": ".ai/review/evals/meta-option-value-semantics/cases.json"
},
{
"name": "hpos-sync-on-read",
@@ -462,7 +473,8 @@
],
"personas": [
"regression"
- ]
+ ],
+ "evals": ".ai/review/evals/hpos-sync-on-read/cases.json"
},
{
"name": "order-storage-access",
@@ -543,7 +555,8 @@
],
"personas": [
"regression"
- ]
+ ],
+ "evals": ".ai/review/evals/migration-upgrade-state/cases.json"
},
{
"name": "proxy-predicate-inference",
@@ -571,7 +584,8 @@
],
"personas": [
"regression"
- ]
+ ],
+ "evals": ".ai/review/evals/proxy-predicate-inference/cases.json"
},
{
"name": "wc-query-shop-front-page",
@@ -586,7 +600,8 @@
],
"personas": [
"regression"
- ]
+ ],
+ "evals": ".ai/review/evals/wc-query-shop-front-page/cases.json"
},
{
"name": "request-lifecycle-state",
@@ -619,7 +634,8 @@
],
"personas": [
"regression"
- ]
+ ],
+ "evals": ".ai/review/evals/request-lifecycle-state/cases.json"
},
{
"name": "template-and-js-default",
@@ -645,7 +661,8 @@
],
"personas": [
"regression"
- ]
+ ],
+ "evals": ".ai/review/evals/template-and-js-default/cases.json"
},
{
"name": "rest-response-contract",
@@ -678,7 +695,8 @@
],
"personas": [
"regression"
- ]
+ ],
+ "evals": ".ai/review/evals/rest-response-contract/cases.json"
},
{
"name": "session-cart-persistence",
@@ -715,7 +733,8 @@
],
"personas": [
"regression"
- ]
+ ],
+ "evals": ".ai/review/evals/session-cart-persistence/cases.json"
},
{
"name": "check-then-write-race",
@@ -748,7 +767,8 @@
],
"personas": [
"regression"
- ]
+ ],
+ "evals": ".ai/review/evals/check-then-write-race/cases.json"
},
{
"name": "order-totals-recalc",
@@ -787,7 +807,8 @@
],
"personas": [
"regression"
- ]
+ ],
+ "evals": ".ai/review/evals/order-totals-recalc/cases.json"
},
{
"name": "shipping-rate-pipeline",
@@ -824,7 +845,8 @@
],
"personas": [
"regression"
- ]
+ ],
+ "evals": ".ai/review/evals/shipping-rate-pipeline/cases.json"
},
{
"name": "sql-clause-filter-contract",
@@ -854,7 +876,8 @@
],
"personas": [
"regression"
- ]
+ ],
+ "evals": ".ai/review/evals/sql-clause-filter-contract/cases.json"
},
{
"name": "cache-invalidation-and-poisoning",
@@ -886,7 +909,8 @@
],
"personas": [
"regression"
- ]
+ ],
+ "evals": ".ai/review/evals/cache-invalidation-and-poisoning/cases.json"
},
{
"name": "perf-refactor-semantic-drift",
@@ -914,14 +938,16 @@
],
"personas": [
"regression"
- ]
+ ],
+ "evals": ".ai/review/evals/perf-refactor-semantic-drift/cases.json"
},
{
"name": "fix-and-test-verification",
"file": ".ai/review/guidelines/fix-and-test-verification.md",
"personas": [
"regression"
- ]
+ ],
+ "evals": ".ai/review/evals/fix-and-test-verification/cases.json"
},
{
"name": "perf-sql-query-shape",
@@ -954,7 +980,8 @@
],
"personas": [
"performance"
- ]
+ ],
+ "evals": ".ai/review/evals/perf-sql-query-shape/cases.json"
},
{
"name": "perf-cache-option-priming",
@@ -988,7 +1015,8 @@
],
"personas": [
"performance"
- ]
+ ],
+ "evals": ".ai/review/evals/perf-cache-option-priming/cases.json"
},
{
"name": "perf-hot-path-eager-work",
@@ -1021,7 +1049,8 @@
],
"personas": [
"performance"
- ]
+ ],
+ "evals": ".ai/review/evals/perf-hot-path-eager-work/cases.json"
},
{
"name": "perf-blocking-remote-call",
@@ -1085,7 +1114,8 @@
"personas": [
"frontend",
"regression"
- ]
+ ],
+ "evals": ".ai/review/evals/script-handle-loading-contract/cases.json"
},
{
"name": "classic-jquery-contract",
@@ -1114,7 +1144,8 @@
],
"personas": [
"frontend"
- ]
+ ],
+ "evals": ".ai/review/evals/classic-jquery-contract/cases.json"
},
{
"name": "js-public-api-contract",
@@ -1153,7 +1184,8 @@
"personas": [
"frontend",
"regression"
- ]
+ ],
+ "evals": ".ai/review/evals/js-public-api-contract/cases.json"
},
{
"name": "checkout-extensibility-payload",
@@ -1191,7 +1223,8 @@
"personas": [
"frontend",
"regression"
- ]
+ ],
+ "evals": ".ai/review/evals/checkout-extensibility-payload/cases.json"
},
{
"name": "css-layout-regression",
@@ -1205,7 +1238,8 @@
],
"personas": [
"frontend"
- ]
+ ],
+ "evals": ".ai/review/evals/css-layout-regression/cases.json"
},
{
"name": "js-request-failure-handling",
@@ -1239,7 +1273,8 @@
],
"personas": [
"frontend"
- ]
+ ],
+ "evals": ".ai/review/evals/js-request-failure-handling/cases.json"
},
{
"name": "react-render-and-keyboard",
@@ -1270,7 +1305,8 @@
],
"personas": [
"frontend"
- ]
+ ],
+ "evals": ".ai/review/evals/react-render-and-keyboard/cases.json"
},
{
"name": "i18n-string-identity",
@@ -1308,7 +1344,8 @@
"personas": [
"frontend",
"regression"
- ]
+ ],
+ "evals": ".ai/review/evals/i18n-string-identity/cases.json"
},
{
"name": "sec-sql-prepare-and-identifiers",
@@ -1341,7 +1378,8 @@
],
"personas": [
"security"
- ]
+ ],
+ "evals": ".ai/review/evals/sec-sql-prepare-and-identifiers/cases.json"
},
{
"name": "sec-rest-and-ajax-permissions",
@@ -1370,7 +1408,8 @@
],
"personas": [
"security"
- ]
+ ],
+ "evals": ".ai/review/evals/sec-rest-and-ajax-permissions/cases.json"
},
{
"name": "sec-order-cart-ownership",
@@ -1402,7 +1441,8 @@
],
"personas": [
"security"
- ]
+ ],
+ "evals": ".ai/review/evals/sec-order-cart-ownership/cases.json"
},
{
"name": "sec-sanitize-and-escape",
@@ -1440,7 +1480,8 @@
],
"personas": [
"security"
- ]
+ ],
+ "evals": ".ai/review/evals/sec-sanitize-and-escape/cases.json"
},
{
"name": "sec-auth-tokens-and-cookies",
@@ -1471,7 +1512,8 @@
],
"personas": [
"security"
- ]
+ ],
+ "evals": ".ai/review/evals/sec-auth-tokens-and-cookies/cases.json"
}
]
}