From 41ca076a3c024df0c4ba700b418c23e4f2f75eef Mon Sep 17 00:00:00 2001 From: Matthew Taylor Date: Thu, 2 Jul 2026 12:04:02 -0700 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20cart=20type=20diagnostic=20?= =?UTF-8?q?=E2=80=94=20hook-window=20tracer=20+=20shutdown=20fatal=20captu?= =?UTF-8?q?re=20(v1.2.0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opt-in via HYPERCART_CART_TYPE_DIAGNOSTIC (plus a hypercart_cart_type_diagnostic_enabled filter override) to find the root cause of the cart/checkout fatal "TypeError: Unsupported operand types: float / string" in WC_Discounts::sort_by_price() (universal-child-theme issue #888). Two error-level events: - cart_type_corruption: snapshot cart item quantity/price/discounted_price at woocommerce_before_calculate_totals priority PHP_INT_MIN, re-check at PHP_INT_MAX. Reports each non-numeric value with type-safe rendering (objects/arrays never string-cast), origin attribution (upstream / hook_callback / added_during_hook), applied coupons, user id, request context, and callback lists for the six hooks able to write cart item values. - cart_fatal_captured: register_shutdown_function catcher that matches the TypeError itself and dumps per-item quantity/price types from the in-memory cart. Covers paths that never fire the totals hook — WC_Cart::apply_coupon() validates via new WC_Discounts(WC()->cart) before any totals calculation. Safety: all entry points swallow Throwable so the tracer can never take down the cart it observes; re-entrancy guard on the snapshot; log volume capped at 5 events per PHP process with signature de-duplication across repeat hook firings. In WC 10.8.x only a non-numeric string quantity can raise this fatal (price is float-cast in WC_Discounts and WC_Cart_Totals), so quantity findings are the authoritative signal. Verified end to end on the Local site with a temporary corrupting mu-plugin: hook-window capture attributed the culprit by file and line ({closure@zz-cart-diag-corruptor-TEMP.php:12}), and the shutdown catcher captured a real resulting fatal (float * string in USPS weight-based shipping) with a full cart type dump. 142 PHPUnit tests pass (19 new in tests/CartDiagTest.php). Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 12 + README.md | 15 + hypercart-query-guard.php | 602 +++++++++++++++++++++++++++++++++++++- tests/CartDiagTest.php | 407 ++++++++++++++++++++++++++ 4 files changed, 1035 insertions(+), 1 deletion(-) create mode 100644 tests/CartDiagTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 0baf0d9..bbe4c5e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,18 @@ All notable changes to Hypercart Query Guard are documented here. This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). Every merged build carries a version number. +## [1.2.0] — 2026-07-02 + +### Added + +- **Cart type diagnostic (`HYPERCART_CART_TYPE_DIAGNOSTIC`).** Opt-in tracing for the production cart/checkout fatal `TypeError: Unsupported operand types: float / string` in `WC_Discounts::sort_by_price()` (universal-child-theme issue #888). In WooCommerce 10.8.x only a non-numeric string `quantity` can raise this fatal — price is float-cast upstream — so quantity findings are the authoritative signal. Two new error-level events: + - **`cart_type_corruption`** — snapshots cart item `quantity` / `price` / `discounted_price` at `woocommerce_before_calculate_totals` priority `PHP_INT_MIN` and re-checks at `PHP_INT_MAX`. Each non-numeric value is reported with a type-safe rendering (objects/arrays never string-cast), origin attribution (`upstream` = bad before the hook ran, `hook_callback` = corrupted by a hook callback, `added_during_hook` = item added mid-hook, e.g. BOGO free gifts), applied coupon codes, user id, cart item count, request context, and callback lists for the six hooks able to write cart item values (including `woocommerce_get_cart_item_from_session` and the product price filters). + - **`cart_fatal_captured`** — a `register_shutdown_function` catcher that matches the TypeError itself and dumps per-item quantity/price types from the in-memory cart. This is the guaranteed capture: `WC_Cart::apply_coupon()` validates via `new WC_Discounts( WC()->cart )` *before* any totals calculation, a path that never fires the instrumented hook. + - Safety: every diagnostic entry point swallows `Throwable` — the tracer can never take down the cart it observes. Log volume is capped at 5 corruption events per PHP process (`CART_DIAG_MAX_LOGS`) with signature de-duplication across repeat hook firings, so multi-cart Action Scheduler processes can still report several distinct findings without flooding. + - Gating: the wp-config constant plus a `hypercart_cart_type_diagnostic_enabled` filter override (register from wp-config or an earlier-loading mu-plugin). + +- **`tests/CartDiagTest.php`.** 19 tests covering classification and attribution, malformed value shapes (object/array/missing/non-array rows, throwing price reads), the log budget and de-duplication, hook callback enumeration (named/array/closure/invokable, pre-4.7 plain-array rows), and shutdown fatal capture. Integration gaps (real `WC_Cart`, live hook dispatch, actual shutdown sequence) are exercised manually on the Local site. + ## [1.1.0] — 2026-05-30 ### Changed diff --git a/README.md b/README.md index 518cb8b..a43641a 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,21 @@ Throttle policy defaults: | Elevated | 5 | 15s | 1 | | Critical | 1 | 10s | 1 | +### Cart type diagnostic + +Opt-in tracer for the cart/checkout fatal `TypeError: Unsupported operand types: float / string` in `WC_Discounts::sort_by_price()`. Enable with: + +```php +define( 'HYPERCART_CART_TYPE_DIAGNOSTIC', true ); +``` + +(or override at runtime via the `hypercart_cart_type_diagnostic_enabled` filter from wp-config or an earlier-loading mu-plugin). It emits two error-level events: + +- **`cart_type_corruption`** — a snapshot/re-check pair around `woocommerce_before_calculate_totals` reports every non-numeric cart item `quantity` / `price` / `discounted_price` with origin attribution (`upstream` / `hook_callback` / `added_during_hook`), applied coupons, and callback lists for the hooks able to write cart item values. +- **`cart_fatal_captured`** — a shutdown-time catcher that matches the TypeError itself (on any code path, including `WC_Cart::apply_coupon()` validation, which never fires the totals hook) and dumps per-item quantity/price types from the in-memory cart. + +The diagnostic never throws — all entry points swallow `Throwable` — and log volume is capped at 5 corruption events per PHP process with de-duplication across repeat hook firings. Note: in WooCommerce 10.8.x only a non-numeric string **quantity** can raise this fatal (price is float-cast upstream), so `quantity` findings are the authoritative signal; price findings are context. + Throttle tuning filters: ```php diff --git a/hypercart-query-guard.php b/hypercart-query-guard.php index 3594b4e..40fbe7c 100644 --- a/hypercart-query-guard.php +++ b/hypercart-query-guard.php @@ -3,7 +3,7 @@ * Plugin Name: Hypercart Query Guard * Plugin URI: https://hypercart.io * Description: PHP-side circuit breaker that enforces MySQL MAX_EXECUTION_TIME on read queries to prevent runaway SELECTs from saturating the pod. Tiered limits per request context, observe-mode for safe rollout, automatic re-application on connection rotation, and admin-search timeout fallback. - * Version: 1.1.0 + * Version: 1.2.0 * Author: Hypercart / Neochrome * License: GPL-2.0-or-later * License URI: https://www.gnu.org/licenses/gpl-2.0.html @@ -24,6 +24,20 @@ * queries. Without the drop-in, this MU-plugin falls * back to the v1 init-priority-1 behavior. * + * Cart diagnostic: define( 'HYPERCART_CART_TYPE_DIAGNOSTIC', true ); + * Traces non-numeric cart item values (quantity, price, + * discounted_price) that cause the PHP 8 TypeError in + * WC_Discounts::sort_by_price(). Emits two error events: + * 'cart_type_corruption' (hook-window detection with + * origin attribution and callback lists) and + * 'cart_fatal_captured' (shutdown-time capture of the + * fatal itself, on any code path). Override via the + * 'hypercart_cart_type_diagnostic_enabled' filter from + * wp-config.php or an earlier-loading mu-plugin. All + * entry points swallow Throwables — the tracer can + * never take down the cart it observes. Log volume is + * capped per process with per-signature de-duplication. + * * @package Hypercart */ @@ -177,6 +191,46 @@ final class Hypercart_Query_Guard { */ private static $throttle_runtime = array(); + /** + * Cart type diagnostic: maximum corruption events logged per PHP + * process. Bounded so a corrupted cart cannot flood the log, yet + * high enough that a long-running process (an Action Scheduler + * batch touching many carts) can report several distinct findings. + */ + const CART_DIAG_MAX_LOGS = 5; + + /** + * Cart type diagnostic: early snapshot of cart item values, + * keyed by cart item key. + * + * @var array + */ + private static $cart_diag_snapshot = array(); + + /** + * Cart type diagnostic: corruption events logged by this process. + * + * @var int + */ + private static $cart_diag_log_count = 0; + + /** + * Cart type diagnostic: signatures of corruption sets already + * logged, so repeat hook firings do not re-log identical findings. + * + * @var array + */ + private static $cart_diag_logged_sigs = array(); + + /** + * Cart type diagnostic: true while a snapshot/check pair is in + * flight; guards re-entrant calculate_totals() calls from + * overwriting the outer snapshot mid-hook. + * + * @var bool + */ + private static $cart_diag_in_progress = false; + /** * Whether the v2 db.php drop-in is active for this request. * @@ -191,6 +245,22 @@ private static function dropin_active() { * Bootstrap. */ public static function init() { + // Cart type diagnostic — independent of query guard mode. + if ( self::cart_diag_enabled() ) { + // PHP_INT_MIN/PHP_INT_MAX bracket the widest window a WP hook + // allows: priority-0/negative callbacks still run after the + // snapshot, and "run last" callbacks still run before the check. + add_action( 'woocommerce_before_calculate_totals', array( __CLASS__, 'cart_diag_snapshot' ), PHP_INT_MIN, 1 ); + add_action( 'woocommerce_before_calculate_totals', array( __CLASS__, 'cart_diag_check' ), PHP_INT_MAX, 1 ); + + // The sort_by_price() fatal also fires on paths that never run + // woocommerce_before_calculate_totals (WC_Cart::apply_coupon() + // validates via `new WC_Discounts( WC()->cart )` before any + // totals calculation), so capture the fatal itself at shutdown + // regardless of code path. + register_shutdown_function( array( __CLASS__, 'cart_diag_shutdown_capture' ) ); + } + $mode = self::get_mode(); $throttle_mode = self::get_throttle_mode(); @@ -1236,6 +1306,536 @@ public static function is_admin_ajax_request( $uri ) { return false !== strpos( (string) $uri, 'admin-ajax.php' ); } + // ================================================================ + // Cart Type Diagnostic + // ================================================================ + + /** + * Whether the cart type diagnostic is enabled. + * + * Constant-first with a filter override, mirroring the throttle + * gating pattern. Evaluated once at mu-plugin load, so a filter + * override must be registered from wp-config.php or an + * earlier-loading mu-plugin. + * + * @return bool + */ + private static function cart_diag_enabled() { + $enabled = defined( 'HYPERCART_CART_TYPE_DIAGNOSTIC' ) && HYPERCART_CART_TYPE_DIAGNOSTIC; + if ( function_exists( 'apply_filters' ) ) { + $enabled = (bool) apply_filters( 'hypercart_cart_type_diagnostic_enabled', $enabled ); + } + return $enabled; + } + + /** + * Early snapshot (priority PHP_INT_MIN): record cart item quantity, + * price, and discounted_price before other hook callbacks run. + * + * All diagnostic entry points swallow Throwables: this code runs on + * production checkout against data known to be malformed in unknown + * ways, and must never become a fatal of its own. + * + * @param WC_Cart|mixed $cart + */ + public static function cart_diag_snapshot( $cart ) { + try { + self::cart_diag_snapshot_body( $cart ); + } catch ( Throwable $t ) { + self::cart_diag_note_internal_failure( $t ); + } + } + + /** + * Late check (priority PHP_INT_MAX): compare current cart item + * values against the early snapshot and log every non-numeric + * quantity/price/discounted_price with origin attribution and the + * callback lists needed to identify the offending plugin. + * + * @param WC_Cart|mixed $cart + */ + public static function cart_diag_check( $cart ) { + try { + self::cart_diag_check_body( $cart ); + } catch ( Throwable $t ) { + self::cart_diag_note_internal_failure( $t ); + } + } + + /** + * Shutdown-time fatal catcher. The sort_by_price() TypeError can + * fire on paths that never run the instrumented hook, so this is + * the guaranteed capture regardless of where corruption entered. + */ + public static function cart_diag_shutdown_capture() { + try { + $err = error_get_last(); + if ( ! is_array( $err ) || ! isset( $err['type'], $err['message'] ) ) { + return; + } + if ( ! in_array( (int) $err['type'], array( E_ERROR, E_RECOVERABLE_ERROR ), true ) ) { + return; + } + + $cart = null; + if ( function_exists( 'WC' ) && is_object( WC() ) && isset( WC()->cart ) && is_object( WC()->cart ) ) { + $cart = WC()->cart; + } + + self::cart_diag_capture_fatal( $err, $cart ); + } catch ( Throwable $t ) { + self::cart_diag_note_internal_failure( $t ); + } + } + + /** + * Testable core of the shutdown capture: match the cart/discount + * type fatal and dump per-item type data from the in-memory cart. + * + * @param array $err error_get_last()-shaped array. + * @param object|null $cart Cart object, if one is available. + * @return bool Whether the fatal matched and was logged. + */ + public static function cart_diag_capture_fatal( $err, $cart ) { + $message = isset( $err['message'] ) ? (string) $err['message'] : ''; + $file = isset( $err['file'] ) ? (string) $err['file'] : ''; + + if ( false === strpos( $message, 'Unsupported operand types' ) ) { + return false; + } + + // Only fatals raised from WooCommerce cart/discount internals. + $haystack = $file . ' ' . $message; + if ( false === stripos( $haystack, 'wc-discounts' ) + && false === stripos( $haystack, 'wc-cart' ) + && false === stripos( $haystack, 'sort_by_price' ) + && false === stripos( $haystack, 'woocommerce' ) ) { + return false; + } + + $items = array(); + if ( is_object( $cart ) ) { + // Prefer the raw property over get_cart(): no filter chain + // runs during shutdown after a fatal. + $contents = isset( $cart->cart_contents ) && is_array( $cart->cart_contents ) ? $cart->cart_contents : null; + if ( null === $contents && is_callable( array( $cart, 'get_cart' ) ) ) { + $contents = $cart->get_cart(); + } + if ( is_array( $contents ) ) { + foreach ( $contents as $key => $item ) { + if ( ! is_array( $item ) ) { + $items[] = array( + 'cart_key' => substr( (string) $key, 0, 12 ), + 'item_type' => gettype( $item ), + ); + continue; + } + $has_qty = array_key_exists( 'quantity', $item ); + $qty = $has_qty ? $item['quantity'] : null; + $items[] = array( + 'cart_key' => substr( (string) $key, 0, 12 ), + 'product_id' => ( isset( $item['product_id'] ) && is_scalar( $item['product_id'] ) ) ? $item['product_id'] : 0, + 'variation_id' => ( isset( $item['variation_id'] ) && is_scalar( $item['variation_id'] ) ) ? $item['variation_id'] : 0, + 'quantity' => $has_qty ? self::cart_diag_safe_value( $qty ) : '{missing}', + 'quantity_type' => $has_qty ? gettype( $qty ) : 'missing', + 'price' => self::cart_diag_safe_value( self::cart_diag_item_price( $item, 'edit' ) ), + 'discounted_price_type' => array_key_exists( 'discounted_price', $item ) ? gettype( $item['discounted_price'] ) : 'absent', + ); + } + } + } + + $uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : ''; + + self::log( 'error', array( + 'event' => 'cart_fatal_captured', + 'fatal_message' => self::truncate( $message, 500 ), + 'fatal_file' => self::truncate( $file . ':' . ( isset( $err['line'] ) ? (int) $err['line'] : 0 ), 200 ), + 'cart_items' => $items, + 'applied_coupons' => self::cart_diag_applied_coupons( $cart ), + 'user_id' => function_exists( 'get_current_user_id' ) ? get_current_user_id() : 0, + 'callbacks' => self::cart_diag_callback_map(), + 'uri' => $uri, + 'context' => self::detect_context(), + 'is_admin_ajax' => self::is_admin_ajax_request( $uri ), + ) ); + + return true; + } + + /** + * @param WC_Cart|mixed $cart + */ + private static function cart_diag_snapshot_body( $cart ) { + if ( self::$cart_diag_log_count >= self::CART_DIAG_MAX_LOGS ) { + return; // Log budget exhausted — skip the per-item work. + } + + // Duck-typed: third-party code occasionally fires this hook + // argless, and the diagnostic must not assume WC_Cart exists. + if ( ! is_object( $cart ) || ! is_callable( array( $cart, 'get_cart' ) ) ) { + return; + } + + // Re-entrancy guard: a callback that calls calculate_totals() + // mid-hook re-fires this hook nested; overwriting the outer + // snapshot with post-corruption values would flip attribution + // to 'upstream'. Keep the outer (earliest) snapshot instead. + if ( self::$cart_diag_in_progress ) { + return; + } + self::$cart_diag_in_progress = true; + + self::$cart_diag_snapshot = array(); + + $items = $cart->get_cart(); + if ( ! is_array( $items ) ) { + return; + } + + foreach ( $items as $key => $item ) { + if ( ! is_array( $item ) ) { + continue; // Non-array item row: recorded as corruption by the check pass. + } + + self::$cart_diag_snapshot[ $key ] = array( + 'quantity' => array_key_exists( 'quantity', $item ) ? $item['quantity'] : null, + // 'edit' context skips the product-price filter chain: no + // third-party code runs and no observer effect, while + // set_price() writes are still visible. The check pass + // reads the filtered 'view' value WC actually consumes. + 'price' => self::cart_diag_item_price( $item, 'edit' ), + 'discounted_price' => array_key_exists( 'discounted_price', $item ) ? $item['discounted_price'] : null, + 'has_discounted' => array_key_exists( 'discounted_price', $item ), + ); + } + } + + /** + * @param WC_Cart|mixed $cart + */ + private static function cart_diag_check_body( $cart ) { + // The snapshot/check pair completed (or never started); either + // way the next firing may take a fresh snapshot. + self::$cart_diag_in_progress = false; + + if ( self::$cart_diag_log_count >= self::CART_DIAG_MAX_LOGS ) { + return; + } + + if ( ! is_object( $cart ) || ! is_callable( array( $cart, 'get_cart' ) ) ) { + return; + } + + $items = $cart->get_cart(); + if ( ! is_array( $items ) ) { + return; + } + + $corrupted = array(); + + foreach ( $items as $key => $item ) { + $early = isset( self::$cart_diag_snapshot[ $key ] ) ? self::$cart_diag_snapshot[ $key ] : null; + + if ( ! is_array( $item ) ) { + // The whole item row was replaced with a non-array value. + $corrupted[] = self::cart_diag_entry( 'item', $key, $item, $item, null !== $early, null ); + continue; + } + + // WC 10.8.x: only a non-numeric string quantity can raise the + // float/string TypeError in WC_Discounts::sort_by_price() — + // price is float-cast upstream. The price/discounted_price + // findings below are context, not the fatal's cause. + if ( ! array_key_exists( 'quantity', $item ) ) { + $row = self::cart_diag_entry( 'quantity', $key, $item, null, null !== $early, $early ? $early['quantity'] : null ); + $row['current_value'] = '{missing}'; + $row['current_type'] = 'missing'; + $corrupted[] = $row; + } elseif ( ! is_numeric( $item['quantity'] ) ) { + $corrupted[] = self::cart_diag_entry( + 'quantity', + $key, + $item, + $item['quantity'], + null !== $early, + $early ? $early['quantity'] : null + ); + } + + // 'view' context: the filtered value WC_Discounts consumes. + $price = self::cart_diag_item_price( $item, 'view' ); + if ( null !== $price && ! is_numeric( $price ) ) { + $corrupted[] = self::cart_diag_entry( 'price', $key, $item, $price, null !== $early, $early ? $early['price'] : null ); + } + + if ( array_key_exists( 'discounted_price', $item ) && null !== $item['discounted_price'] && ! is_numeric( $item['discounted_price'] ) ) { + $had_early_field = $early && ! empty( $early['has_discounted'] ); + $corrupted[] = self::cart_diag_entry( + 'discounted_price', + $key, + $item, + $item['discounted_price'], + $had_early_field, + $had_early_field ? $early['discounted_price'] : null + ); + } + } + + if ( empty( $corrupted ) ) { + return; + } + + // The hook fires several times per request; identical findings + // are logged once per process, new findings get their own event + // up to CART_DIAG_MAX_LOGS. + $sig_parts = array(); + foreach ( $corrupted as $entry ) { + $sig_parts[] = $entry['field'] . '|' . $entry['cart_key'] . '|' . $entry['current_type'] . '|' . $entry['current_value']; + } + $sig = md5( implode( "\n", $sig_parts ) ); + if ( isset( self::$cart_diag_logged_sigs[ $sig ] ) ) { + return; + } + self::$cart_diag_logged_sigs[ $sig ] = true; + self::$cart_diag_log_count++; + + $uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : ''; + + self::log( 'error', array( + 'event' => 'cart_type_corruption', + 'corrupted' => $corrupted, + 'cart_item_count' => count( $items ), + 'applied_coupons' => self::cart_diag_applied_coupons( $cart ), + 'user_id' => function_exists( 'get_current_user_id' ) ? get_current_user_id() : 0, + 'callbacks' => self::cart_diag_callback_map(), + 'uri' => $uri, + 'context' => self::detect_context(), + 'is_admin_ajax' => self::is_admin_ajax_request( $uri ), + ) ); + } + + /** + * Build one corruption record. + * + * Attribution: 'upstream' = already bad in the snapshot (session, + * add-to-cart, or a filter predating the hook); 'hook_callback' = + * clean at snapshot time, bad now; 'added_during_hook' = the item + * had no snapshot entry (e.g. a BOGO/free-gift callback added it). + * + * @param string $field Which cart item field is non-numeric. + * @param string $key Cart item key. + * @param mixed $item Cart item row (usually an array). + * @param mixed $value The corrupted value. + * @param bool $has_early Whether snapshot data exists for the field. + * @param mixed $early_value Snapshot value for the field. + * @return array + */ + private static function cart_diag_entry( $field, $key, $item, $value, $has_early, $early_value ) { + if ( ! $has_early ) { + $origin = 'added_during_hook'; + } elseif ( is_numeric( $early_value ) ) { + $origin = 'hook_callback'; + } else { + $origin = 'upstream'; + } + + $product_type = ''; + if ( is_array( $item ) && isset( $item['data'] ) && is_object( $item['data'] ) && is_callable( array( $item['data'], 'get_type' ) ) ) { + $type_val = $item['data']->get_type(); + $product_type = is_string( $type_val ) ? $type_val : gettype( $type_val ); + } + + return array( + 'field' => $field, + 'cart_key' => substr( (string) $key, 0, 12 ), + 'product_id' => ( is_array( $item ) && isset( $item['product_id'] ) && is_scalar( $item['product_id'] ) ) ? $item['product_id'] : 0, + 'variation_id' => ( is_array( $item ) && isset( $item['variation_id'] ) && is_scalar( $item['variation_id'] ) ) ? $item['variation_id'] : 0, + 'product_type' => $product_type, + 'current_value' => self::cart_diag_safe_value( $value ), + 'current_type' => gettype( $value ), + 'early_value' => $has_early ? self::cart_diag_safe_value( $early_value ) : null, + 'early_type' => $has_early ? gettype( $early_value ) : null, + 'corrupted_by' => $origin, + ); + } + + /** + * Read a cart item's product price without letting a broken product + * object or throwing price filter take the diagnostic down. + * + * @param array|mixed $item Cart item row. + * @param string $context 'view' (filtered — what WC consumes) or 'edit' (raw prop). + * @return mixed Null when no readable product object is present; a + * '{get_price threw …}' marker when the read throws + * (itself diagnostic signal — non-numeric, so flagged). + */ + private static function cart_diag_item_price( $item, $context ) { + if ( ! is_array( $item ) || ! isset( $item['data'] ) || ! is_object( $item['data'] ) || ! is_callable( array( $item['data'], 'get_price' ) ) ) { + return null; + } + try { + return $item['data']->get_price( $context ); + } catch ( Throwable $t ) { + return '{get_price threw ' . get_class( $t ) . '}'; + } + } + + /** + * Render any value as a short, log-safe string. A bare (string) + * cast fatals on objects without __toString — exactly the malformed + * shapes this diagnostic exists to record — so never cast blindly. + * + * @param mixed $value + * @return string + */ + private static function cart_diag_safe_value( $value ) { + if ( null === $value ) { + return '{null}'; + } + if ( is_bool( $value ) ) { + return $value ? '{true}' : '{false}'; + } + if ( is_scalar( $value ) ) { + return self::truncate( (string) $value, 80 ); + } + if ( is_object( $value ) ) { + return '{object:' . get_class( $value ) . '}'; + } + if ( is_array( $value ) ) { + return self::truncate( '{array:' . self::encode_log_payload( $value ) . '}', 80 ); + } + return '{' . gettype( $value ) . '}'; + } + + /** + * Applied coupon codes, capped and rendered log-safe. The + * sort_by_price() fatal only occurs while a coupon is being + * applied, so the coupon list is primary reproduction context. + * + * @param object|mixed $cart + * @return string[] + */ + private static function cart_diag_applied_coupons( $cart ) { + if ( ! is_object( $cart ) || ! is_callable( array( $cart, 'get_applied_coupons' ) ) ) { + return array(); + } + try { + $coupons = $cart->get_applied_coupons(); + } catch ( Throwable $t ) { + return array( '{get_applied_coupons threw ' . get_class( $t ) . '}' ); + } + if ( ! is_array( $coupons ) ) { + return array(); + } + $out = array(); + foreach ( array_slice( $coupons, 0, 20 ) as $code ) { + $out[] = self::cart_diag_safe_value( $code ); + } + return $out; + } + + /** + * Callback lists for every hook through which cart item values can + * be written or rewritten. 'upstream' corruption typically enters + * via the session/product filters, not the totals hook itself. + * + * @return array + */ + private static function cart_diag_callback_map() { + $hooks = array( + 'woocommerce_before_calculate_totals', + 'woocommerce_get_cart_item_from_session', + 'woocommerce_get_cart_contents', + 'woocommerce_add_cart_item', + 'woocommerce_product_get_price', + 'woocommerce_product_variation_get_price', + ); + + $map = array(); + foreach ( $hooks as $hook_name ) { + $callbacks = self::enumerate_hook_callbacks( $hook_name ); + if ( ! empty( $callbacks ) ) { + $map[ $hook_name ] = $callbacks; + } + } + return $map; + } + + /** + * Record (once per process) that the diagnostic itself failed. The + * diagnostic must never take down the cart it is observing, so all + * entry points swallow Throwables and leave a single breadcrumb. + * Deliberately bypasses self::log() in case log() is implicated. + * + * @param Throwable $t + */ + private static function cart_diag_note_internal_failure( $t ) { + static $noted = false; + if ( $noted ) { + return; + } + $noted = true; + error_log( '[hypercart_query_guard][error] cart_diag internal failure: ' . get_class( $t ) . ': ' . $t->getMessage() ); + } + + /** + * List all registered callbacks for a hook, with priorities. + * + * @param string $hook_name + * @return string[] e.g. ['10:my_function', '30:MyClass::method'] + */ + private static function enumerate_hook_callbacks( $hook_name ) { + global $wp_filter; + + // isset() on the property is false for pre-4.7-style plain-array + // rows as well as missing hooks — both return empty safely. + if ( ! isset( $wp_filter[ $hook_name ]->callbacks ) || ! is_array( $wp_filter[ $hook_name ]->callbacks ) ) { + return array(); + } + + $list = array(); + foreach ( $wp_filter[ $hook_name ]->callbacks as $priority => $hooks ) { + if ( ! is_array( $hooks ) ) { + continue; + } + foreach ( $hooks as $hook ) { + $fn = isset( $hook['function'] ) ? $hook['function'] : null; + if ( is_string( $fn ) ) { + $name = $fn; + } elseif ( is_array( $fn ) ) { + $cls = isset( $fn[0] ) ? ( is_object( $fn[0] ) ? get_class( $fn[0] ) : ( is_string( $fn[0] ) ? $fn[0] : gettype( $fn[0] ) ) ) : '?'; + $mth = ( isset( $fn[1] ) && is_string( $fn[1] ) ) ? $fn[1] : '?'; + $name = $cls . '::' . $mth; + } elseif ( $fn instanceof Closure ) { + try { + $ref = new ReflectionFunction( $fn ); + $file = $ref->getFileName(); + // getFileName() is false for closures over internal + // functions (first-class callable syntax). + $name = $file ? '{closure@' . basename( $file ) . ':' . (int) $ref->getStartLine() . '}' : '{closure:internal}'; + } catch ( ReflectionException $e ) { + $name = '{closure}'; + } + } elseif ( is_object( $fn ) ) { + $name = '{invokable:' . get_class( $fn ) . '}'; + } else { + $name = '{unknown:' . gettype( $fn ) . '}'; + } + $list[] = $priority . ':' . $name; + } + } + + // Keep individual log entries bounded on hook-heavy sites. + if ( count( $list ) > 60 ) { + $extra = count( $list ) - 60; + $list = array_slice( $list, 0, 60 ); + $list[] = '+' . $extra . ' more'; + } + + return $list; + } + /** * Centralized log emitter. Prefers Hypercart_Logger if present (your * existing file-based logger from the Performance Monitor plugin), diff --git a/tests/CartDiagTest.php b/tests/CartDiagTest.php new file mode 100644 index 0000000..607e8c1 --- /dev/null +++ b/tests/CartDiagTest.php @@ -0,0 +1,407 @@ +items = $items; + $this->coupons = $coupons; + } + + public function get_cart() { + return $this->items; + } + + public function get_applied_coupons() { + return $this->coupons; + } +} + +/** + * Minimal stand-in for WC_Product: get_price() + get_type(). + */ +class HCQG_Fake_Product { + /** @var mixed */ + private $price; + + public function __construct( $price ) { + $this->price = $price; + } + + public function get_price( $context = 'view' ) { + return $this->price; + } + + public function get_type() { + return 'simple'; + } +} + +/** + * Product whose price read throws, like a broken third-party price filter. + */ +class HCQG_Throwing_Product { + public function get_price( $context = 'view' ) { + throw new RuntimeException( 'broken price filter' ); + } + + public function get_type() { + return 'simple'; + } +} + +final class CartDiagTest extends TestCase { + + /** @var array[] All log payloads captured during the test. */ + private $captured = array(); + + protected function setUp(): void { + parent::setUp(); + WP_Stub_State::reset(); + Hypercart_Logger::reset(); + $this->captured = array(); + $this->reset_diag_state(); + unset( $GLOBALS['wp_filter'] ); + + add_filter( + 'hypercart_query_guard_log_payload', + function ( $payload ) { + $this->captured[] = $payload; + return $payload; + } + ); + } + + protected function tearDown(): void { + unset( $GLOBALS['wp_filter'] ); + parent::tearDown(); + } + + /** + * Reset the diagnostic's private static state between tests. + */ + private function reset_diag_state() { + $ref = new ReflectionClass( Hypercart_Query_Guard::class ); + foreach ( array( + 'cart_diag_snapshot' => array(), + 'cart_diag_log_count' => 0, + 'cart_diag_logged_sigs' => array(), + 'cart_diag_in_progress' => false, + ) as $prop => $value ) { + $p = $ref->getProperty( $prop ); + $p->setAccessible( true ); + $p->setValue( null, $value ); + } + } + + /** + * Build a well-formed cart item row. + * + * @param mixed $quantity Quantity value (any shape under test). + * @param mixed $price Product price. + * @param array $extra Extra keys merged into the row. + * @return array + */ + private function item( $quantity, $price = 10.0, $extra = array() ) { + return array_merge( + array( + 'quantity' => $quantity, + 'product_id' => 123, + 'variation_id' => 0, + 'data' => new HCQG_Fake_Product( $price ), + ), + $extra + ); + } + + /** + * @return array[] Captured cart_type_corruption payloads. + */ + private function corruption_events() { + return array_values( + array_filter( + $this->captured, + function ( $p ) { + return isset( $p['event'] ) && 'cart_type_corruption' === $p['event']; + } + ) + ); + } + + private function run_pair( $cart ) { + Hypercart_Query_Guard::cart_diag_snapshot( $cart ); + Hypercart_Query_Guard::cart_diag_check( $cart ); + } + + // ----------------------------------------------------------------------- + // Classification and attribution + // ----------------------------------------------------------------------- + + public function test_clean_cart_logs_nothing() { + $this->run_pair( new HCQG_Fake_Cart( array( 'k1' => $this->item( 2 ) ) ) ); + $this->assertSame( array(), $this->corruption_events() ); + } + + public function test_numeric_string_quantity_is_not_flagged() { + $this->run_pair( new HCQG_Fake_Cart( array( 'k1' => $this->item( '2' ) ) ) ); + $this->assertSame( array(), $this->corruption_events() ); + } + + public function test_string_quantity_present_at_snapshot_is_upstream() { + $this->run_pair( new HCQG_Fake_Cart( array( 'k1' => $this->item( 'undefined' ) ) ) ); + + $events = $this->corruption_events(); + $this->assertCount( 1, $events ); + $row = $events[0]['corrupted'][0]; + $this->assertSame( 'quantity', $row['field'] ); + $this->assertSame( 'undefined', $row['current_value'] ); + $this->assertSame( 'string', $row['current_type'] ); + $this->assertSame( 'upstream', $row['corrupted_by'] ); + $this->assertSame( 'simple', $row['product_type'] ); + } + + public function test_quantity_corrupted_mid_hook_is_hook_callback() { + $cart = new HCQG_Fake_Cart( array( 'k1' => $this->item( 2 ) ) ); + Hypercart_Query_Guard::cart_diag_snapshot( $cart ); + $cart->items['k1']['quantity'] = 'NaN'; // what a mid-hook callback would do + Hypercart_Query_Guard::cart_diag_check( $cart ); + + $row = $this->corruption_events()[0]['corrupted'][0]; + $this->assertSame( 'hook_callback', $row['corrupted_by'] ); + $this->assertSame( '2', $row['early_value'] ); + $this->assertSame( 'integer', $row['early_type'] ); + } + + public function test_item_added_mid_hook_is_added_during_hook() { + $cart = new HCQG_Fake_Cart( array( 'k1' => $this->item( 1 ) ) ); + Hypercart_Query_Guard::cart_diag_snapshot( $cart ); + $cart->items['k2'] = $this->item( '1 (gift)' ); // BOGO-style auto-add + Hypercart_Query_Guard::cart_diag_check( $cart ); + + $row = $this->corruption_events()[0]['corrupted'][0]; + $this->assertSame( 'added_during_hook', $row['corrupted_by'] ); + } + + // ----------------------------------------------------------------------- + // Malformed value shapes must never fatal + // ----------------------------------------------------------------------- + + public function test_object_quantity_does_not_fatal_and_is_labeled() { + $this->run_pair( new HCQG_Fake_Cart( array( 'k1' => $this->item( new stdClass() ) ) ) ); + + $row = $this->corruption_events()[0]['corrupted'][0]; + $this->assertSame( '{object:stdClass}', $row['current_value'] ); + $this->assertSame( 'object', $row['current_type'] ); + } + + public function test_array_quantity_is_encoded_not_cast() { + $this->run_pair( new HCQG_Fake_Cart( array( 'k1' => $this->item( array( 3 ) ) ) ) ); + + $row = $this->corruption_events()[0]['corrupted'][0]; + $this->assertStringStartsWith( '{array:', $row['current_value'] ); + $this->assertSame( 'array', $row['current_type'] ); + } + + public function test_missing_quantity_key_is_flagged_as_missing() { + $item = $this->item( 1 ); + unset( $item['quantity'] ); + $this->run_pair( new HCQG_Fake_Cart( array( 'k1' => $item ) ) ); + + $row = $this->corruption_events()[0]['corrupted'][0]; + $this->assertSame( 'quantity', $row['field'] ); + $this->assertSame( '{missing}', $row['current_value'] ); + $this->assertSame( 'missing', $row['current_type'] ); + } + + public function test_non_array_item_row_is_flagged() { + $this->run_pair( new HCQG_Fake_Cart( array( 'k1' => 'garbage' ) ) ); + + $row = $this->corruption_events()[0]['corrupted'][0]; + $this->assertSame( 'item', $row['field'] ); + $this->assertSame( 'garbage', $row['current_value'] ); + } + + public function test_throwing_price_read_is_captured_not_fatal() { + $item = $this->item( 1 ); + $item['data'] = new HCQG_Throwing_Product(); + $this->run_pair( new HCQG_Fake_Cart( array( 'k1' => $item ) ) ); + + $rows = $this->corruption_events()[0]['corrupted']; + $price_rows = array_values( + array_filter( + $rows, + function ( $r ) { + return 'price' === $r['field']; + } + ) + ); + $this->assertCount( 1, $price_rows ); + $this->assertStringStartsWith( '{get_price threw', $price_rows[0]['current_value'] ); + } + + public function test_non_cart_arguments_are_ignored_without_error() { + Hypercart_Query_Guard::cart_diag_snapshot( null ); + Hypercart_Query_Guard::cart_diag_check( null ); + Hypercart_Query_Guard::cart_diag_snapshot( '' ); + Hypercart_Query_Guard::cart_diag_check( '' ); + Hypercart_Query_Guard::cart_diag_snapshot( new stdClass() ); + Hypercart_Query_Guard::cart_diag_check( new stdClass() ); + $this->assertSame( array(), $this->captured ); + } + + // ----------------------------------------------------------------------- + // Log budget and de-duplication + // ----------------------------------------------------------------------- + + public function test_identical_corruption_logged_once_per_process() { + $cart = new HCQG_Fake_Cart( array( 'k1' => $this->item( 'undefined' ) ) ); + for ( $i = 0; $i < 4; $i++ ) { + $this->run_pair( $cart ); + } + $this->assertCount( 1, $this->corruption_events() ); + } + + public function test_new_corruption_gets_its_own_event_up_to_cap() { + for ( $i = 0; $i < 8; $i++ ) { + $this->run_pair( new HCQG_Fake_Cart( array( "k{$i}" => $this->item( "bad-{$i}" ) ) ) ); + } + $this->assertCount( Hypercart_Query_Guard::CART_DIAG_MAX_LOGS, $this->corruption_events() ); + } + + // ----------------------------------------------------------------------- + // Payload context fields + // ----------------------------------------------------------------------- + + public function test_coupons_and_context_fields_present() { + $this->run_pair( + new HCQG_Fake_Cart( + array( 'k1' => $this->item( 'x' ) ), + array( 'SAVE20', 'bogo-free' ) + ) + ); + + $event = $this->corruption_events()[0]; + $this->assertSame( array( 'SAVE20', 'bogo-free' ), $event['applied_coupons'] ); + $this->assertSame( 1, $event['cart_item_count'] ); + $this->assertArrayHasKey( 'uri', $event ); + $this->assertArrayHasKey( 'context', $event ); + $this->assertArrayHasKey( 'is_admin_ajax', $event ); + $this->assertArrayHasKey( 'user_id', $event ); + $this->assertArrayHasKey( 'callbacks', $event ); + } + + // ----------------------------------------------------------------------- + // Hook callback enumeration + // ----------------------------------------------------------------------- + + public function test_enumerate_hook_callbacks_labels_all_shapes() { + $closure = function () {}; + $GLOBALS['wp_filter'] = array( + 'woocommerce_before_calculate_totals' => (object) array( + 'callbacks' => array( + 10 => array( + array( 'function' => 'my_named_function', 'accepted_args' => 1 ), + array( 'function' => array( 'Some_Class', 'some_method' ), 'accepted_args' => 1 ), + array( 'function' => $closure, 'accepted_args' => 1 ), + array( 'function' => new HCQG_Fake_Product( 1 ), 'accepted_args' => 1 ), + ), + ), + ), + ); + + $method = new ReflectionMethod( Hypercart_Query_Guard::class, 'enumerate_hook_callbacks' ); + $method->setAccessible( true ); + $list = $method->invoke( null, 'woocommerce_before_calculate_totals' ); + + $this->assertContains( '10:my_named_function', $list ); + $this->assertContains( '10:Some_Class::some_method', $list ); + $this->assertCount( 1, preg_grep( '/^10:\{closure@CartDiagTest\.php:\d+\}$/', $list ) ); + $this->assertContains( '10:{invokable:HCQG_Fake_Product}', $list ); + } + + public function test_enumerate_handles_plain_array_wp_filter_row() { + $GLOBALS['wp_filter'] = array( + 'woocommerce_before_calculate_totals' => array( 10 => array() ), // pre-4.7-style row + ); + + $method = new ReflectionMethod( Hypercart_Query_Guard::class, 'enumerate_hook_callbacks' ); + $method->setAccessible( true ); + $this->assertSame( array(), $method->invoke( null, 'woocommerce_before_calculate_totals' ) ); + } + + // ----------------------------------------------------------------------- + // Shutdown fatal capture + // ----------------------------------------------------------------------- + + public function test_fatal_capture_matches_discounts_type_error() { + $cart = new HCQG_Fake_Cart( + array( 'k1' => $this->item( 'undefined' ) ), + array( 'SAVE20' ) + ); + $err = array( + 'type' => E_ERROR, + 'message' => 'Uncaught TypeError: Unsupported operand types: float / string in /srv/wp-content/plugins/woocommerce/includes/class-wc-discounts.php:296', + 'file' => '/srv/wp-content/plugins/woocommerce/includes/class-wc-discounts.php', + 'line' => 296, + ); + + $this->assertTrue( Hypercart_Query_Guard::cart_diag_capture_fatal( $err, $cart ) ); + + $events = array_values( + array_filter( + $this->captured, + function ( $p ) { + return isset( $p['event'] ) && 'cart_fatal_captured' === $p['event']; + } + ) + ); + $this->assertCount( 1, $events ); + $this->assertSame( 'undefined', $events[0]['cart_items'][0]['quantity'] ); + $this->assertSame( 'string', $events[0]['cart_items'][0]['quantity_type'] ); + $this->assertSame( array( 'SAVE20' ), $events[0]['applied_coupons'] ); + $this->assertStringContainsString( 'class-wc-discounts.php:296', $events[0]['fatal_file'] ); + } + + public function test_fatal_capture_ignores_unrelated_fatals() { + $err = array( + 'type' => E_ERROR, + 'message' => 'Uncaught Error: Call to undefined function foo() in /srv/whatever.php:1', + 'file' => '/srv/whatever.php', + 'line' => 1, + ); + $this->assertFalse( Hypercart_Query_Guard::cart_diag_capture_fatal( $err, null ) ); + $this->assertSame( array(), $this->captured ); + } + + public function test_fatal_capture_without_cart_still_logs_error_details() { + $err = array( + 'type' => E_ERROR, + 'message' => 'Uncaught TypeError: Unsupported operand types: float / string in /srv/wp-content/plugins/woocommerce/includes/class-wc-discounts.php:296', + 'file' => '/srv/wp-content/plugins/woocommerce/includes/class-wc-discounts.php', + 'line' => 296, + ); + $this->assertTrue( Hypercart_Query_Guard::cart_diag_capture_fatal( $err, null ) ); + $this->assertCount( 1, $this->captured ); + $this->assertSame( array(), $this->captured[0]['cart_items'] ); + } +} From 8c7689707821ef536acf287e4bc371b3f7a8c7f7 Mon Sep 17 00:00:00 2001 From: Matthew Taylor Date: Thu, 2 Jul 2026 12:18:10 -0700 Subject: [PATCH 2/2] docs+hardening: address cart-diag review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three items from the second-session code review of the cart type diagnostic: - Document the usage constraint (file header + README): the corruption scan runs on every woocommerce_before_calculate_totals firing even on clean carts, adding one filtered get_price() read per cart item per totals calculation — enable to reproduce, then disable; not a permanent fixture. - Comment the edit/view context mix on price rows (check pass) and note in the README that corrupted_by on price rows is a hint, not proof — quantity rows carry the authoritative signal. - Make cart_diag_capture_fatal() self-guarded: public entry point now wraps a private body in try/catch like the other diagnostic entry points, so it never throws regardless of caller; docblock notes it is public for testability. No behavior change to detection or attribution. 142 tests pass. Co-Authored-By: Claude Fable 5 --- README.md | 4 +++- hypercart-query-guard.php | 26 ++++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index a43641a..2ba1eeb 100644 --- a/README.md +++ b/README.md @@ -111,7 +111,9 @@ define( 'HYPERCART_CART_TYPE_DIAGNOSTIC', true ); - **`cart_type_corruption`** — a snapshot/re-check pair around `woocommerce_before_calculate_totals` reports every non-numeric cart item `quantity` / `price` / `discounted_price` with origin attribution (`upstream` / `hook_callback` / `added_during_hook`), applied coupons, and callback lists for the hooks able to write cart item values. - **`cart_fatal_captured`** — a shutdown-time catcher that matches the TypeError itself (on any code path, including `WC_Cart::apply_coupon()` validation, which never fires the totals hook) and dumps per-item quantity/price types from the in-memory cart. -The diagnostic never throws — all entry points swallow `Throwable` — and log volume is capped at 5 corruption events per PHP process with de-duplication across repeat hook firings. Note: in WooCommerce 10.8.x only a non-numeric string **quantity** can raise this fatal (price is float-cast upstream), so `quantity` findings are the authoritative signal; price findings are context. +The diagnostic never throws — all entry points swallow `Throwable` — and log volume is capped at 5 corruption events per PHP process with de-duplication across repeat hook firings. Note: in WooCommerce 10.8.x only a non-numeric string **quantity** can raise this fatal (price is float-cast upstream), so `quantity` findings are the authoritative signal; price findings are context. On `price` rows, `early_value` is the raw (edit-context) price while `current_value` is the filtered (view-context) value, so `corrupted_by` on price rows is a hint, not proof. + +**Enable to reproduce, then disable.** The corruption scan runs on every `woocommerce_before_calculate_totals` firing even when the cart is clean, adding one filtered `get_price()` read per cart item per totals calculation. That is mild next to what a totals calculation already costs, but it is not zero — the diagnostic is an investigation tool, not a permanent fixture. Throttle tuning filters: diff --git a/hypercart-query-guard.php b/hypercart-query-guard.php index 40fbe7c..9676c52 100644 --- a/hypercart-query-guard.php +++ b/hypercart-query-guard.php @@ -37,6 +37,10 @@ * entry points swallow Throwables — the tracer can * never take down the cart it observes. Log volume is * capped per process with per-signature de-duplication. + * Enable to reproduce, then disable: while enabled it + * adds one filtered get_price() read per cart item per + * totals calculation (the scan runs even on clean + * carts), so it is not intended to stay on permanently. * * @package Hypercart */ @@ -1392,11 +1396,28 @@ public static function cart_diag_shutdown_capture() { * Testable core of the shutdown capture: match the cart/discount * type fatal and dump per-item type data from the in-memory cart. * + * Public for testability, and self-guarded like the other entry + * points — it never throws, no matter who calls it. + * * @param array $err error_get_last()-shaped array. * @param object|null $cart Cart object, if one is available. * @return bool Whether the fatal matched and was logged. */ public static function cart_diag_capture_fatal( $err, $cart ) { + try { + return self::cart_diag_capture_fatal_body( $err, $cart ); + } catch ( Throwable $t ) { + self::cart_diag_note_internal_failure( $t ); + return false; + } + } + + /** + * @param array $err error_get_last()-shaped array. + * @param object|null $cart Cart object, if one is available. + * @return bool Whether the fatal matched and was logged. + */ + private static function cart_diag_capture_fatal_body( $err, $cart ) { $message = isset( $err['message'] ) ? (string) $err['message'] : ''; $file = isset( $err['file'] ) ? (string) $err['file'] : ''; @@ -1564,6 +1585,11 @@ private static function cart_diag_check_body( $cart ) { } // 'view' context: the filtered value WC_Discounts consumes. + // NOTE: the snapshot stored the raw 'edit' value, so on price + // rows early_value/current_value mix contexts: a view-only + // filter corruption reads as 'hook_callback' even if that + // filter predates the hook. Treat corrupted_by on price rows + // as a hint — quantity rows carry the authoritative signal. $price = self::cart_diag_item_price( $item, 'view' ); if ( null !== $price && ! is_numeric( $price ) ) { $corrupted[] = self::cart_diag_entry( 'price', $key, $item, $price, null !== $early, $early ? $early['price'] : null );