76c01e44df
對齊 A v3.2.0。型別強制會把隱式轉換變成 TypeError,所以一次全檔加入 並跑完整測試(unit 451 / integration 398 全綠,無迴歸)。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TbG1keQQ7XBa7qMQY16KCY
494 lines
17 KiB
PHP
494 lines
17 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
// phpcs:ignore WPDO.AntiEAV -- platform diagnostic: raw meta inspection for site health
|
|
/**
|
|
* TMDO_Site_Health — WordPress Site Health integration (v2.2.0 M3).
|
|
*
|
|
* Registers 7 tests under Tools → Site Health → Status:
|
|
* 1. wpdo_schema_drift (critical)
|
|
* 2. wpdo_error_budget (recommended)
|
|
* 3. wpdo_hook_conflicts (recommended)
|
|
* 4. wpdo_autoload_bloat (recommended)
|
|
* 5. wpdo_postmeta_explosion (recommended)
|
|
* 6. wpdo_orphan_zone_rows (recommended)
|
|
* 7. wpdo_missing_snapshot (critical)
|
|
*
|
|
* Each test result is cached for 5 minutes to keep Site Health responsive.
|
|
*
|
|
* @package WP_Data_Optimizer
|
|
*/
|
|
|
|
if ( ! defined( 'ABSPATH' ) ) {
|
|
exit;
|
|
}
|
|
|
|
/**
|
|
* Site Health test suite. Hook on `init` admin context.
|
|
*/
|
|
class TMDO_Site_Health {
|
|
|
|
/** Transient cache TTL for individual checks. */
|
|
private const CACHE_TTL = 300;
|
|
|
|
/** Test name → callable suffix mapping. */
|
|
private const TESTS = array(
|
|
'wpdo_schema_drift' => 'check_schema_drift',
|
|
'wpdo_error_budget' => 'check_error_budget',
|
|
'wpdo_hook_conflicts' => 'check_hook_conflicts',
|
|
'wpdo_autoload_bloat' => 'check_autoload_bloat',
|
|
'wpdo_postmeta_explosion' => 'check_postmeta_explosion',
|
|
'wpdo_orphan_zone_rows' => 'check_orphan_zone_rows',
|
|
'wpdo_missing_snapshot' => 'check_missing_snapshot',
|
|
);
|
|
|
|
/**
|
|
* Hook into Site Health.
|
|
*
|
|
* @return void
|
|
*/
|
|
public static function register(): void {
|
|
add_filter( 'site_status_tests', array( __CLASS__, 'register_tests' ) );
|
|
}
|
|
|
|
/**
|
|
* Register the 7 tests with WP Site Health.
|
|
*
|
|
* @param array $tests Existing tests.
|
|
* @return array
|
|
*/
|
|
public static function register_tests( array $tests ): array {
|
|
foreach ( self::TESTS as $key => $cb_suffix ) {
|
|
$tests['direct'][ $key ] = array(
|
|
'label' => self::label_for( $key ),
|
|
'test' => array( __CLASS__, $cb_suffix ),
|
|
);
|
|
}
|
|
return $tests;
|
|
}
|
|
|
|
/**
|
|
* Human-readable label for each test.
|
|
*
|
|
* @param string $key Test slug.
|
|
* @return string
|
|
*/
|
|
private static function label_for( string $key ): string {
|
|
$map = array(
|
|
'wpdo_schema_drift' => __( 'WPDO schema drift', '2meet-data-optimizer' ),
|
|
'wpdo_error_budget' => __( 'WPDO error budget', '2meet-data-optimizer' ),
|
|
'wpdo_hook_conflicts' => __( 'WPDO hook conflicts', '2meet-data-optimizer' ),
|
|
'wpdo_autoload_bloat' => __( 'WPDO autoload bloat', '2meet-data-optimizer' ),
|
|
'wpdo_postmeta_explosion' => __( 'WPDO postmeta explosion', '2meet-data-optimizer' ),
|
|
'wpdo_orphan_zone_rows' => __( 'WPDO orphan zone rows', '2meet-data-optimizer' ),
|
|
'wpdo_missing_snapshot' => __( 'WPDO missing snapshot', '2meet-data-optimizer' ),
|
|
);
|
|
return $map[ $key ] ?? $key;
|
|
}
|
|
|
|
// ─── Test 1: Schema drift ─────────────────────────────────────────────
|
|
|
|
/**
|
|
* Verify all expected v2 tables exist.
|
|
*
|
|
* @return array Site Health result.
|
|
*/
|
|
public static function check_schema_drift(): array {
|
|
$result = self::cached(
|
|
'wpdo_sh_schema_drift',
|
|
static function () {
|
|
if ( ! class_exists( 'TMDO_Installer' ) ) {
|
|
return self::pass( __( 'WPDO installer not loaded.', '2meet-data-optimizer' ) );
|
|
}
|
|
if ( ! method_exists( 'TMDO_Installer', 'v2_tables_status' ) ) {
|
|
return self::pass( __( 'Schema check unavailable on this version.', '2meet-data-optimizer' ) );
|
|
}
|
|
$status = TMDO_Installer::v2_tables_status();
|
|
$missing = array_keys( array_filter( $status, static fn( $exists ) => ! $exists ) );
|
|
if ( empty( $missing ) ) {
|
|
return self::pass( __( 'All WPDO v2 tables exist.', '2meet-data-optimizer' ) );
|
|
}
|
|
return self::fail(
|
|
__( 'WPDO v2 tables missing', '2meet-data-optimizer' ),
|
|
sprintf(
|
|
/* translators: %s: comma-separated list of missing table names */
|
|
__( 'Missing tables: %s. Run wp wpdo install or re-activate the plugin.', '2meet-data-optimizer' ),
|
|
implode( ', ', $missing )
|
|
),
|
|
'critical'
|
|
);
|
|
}
|
|
);
|
|
return self::wrap( 'wpdo_schema_drift', $result );
|
|
}
|
|
|
|
// ─── Test 2: Error budget (last 7 days) ──────────────────────────────
|
|
|
|
/**
|
|
* Count errors in wp_wpdo_errors over the last 7 days.
|
|
*
|
|
* @return array Site Health result.
|
|
*/
|
|
public static function check_error_budget(): array {
|
|
$result = self::cached(
|
|
'wpdo_sh_error_budget',
|
|
static function () {
|
|
global $wpdb;
|
|
$table = $wpdb->prefix . 'wpdo_errors';
|
|
$exists = (int) $wpdb->get_var(
|
|
$wpdb->prepare( // phpcs:ignore WordPress.DB
|
|
'SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s',
|
|
$table
|
|
)
|
|
);
|
|
if ( 0 === $exists ) {
|
|
return self::pass( __( 'Error log not present (yet) — clean.', '2meet-data-optimizer' ) );
|
|
}
|
|
$threshold = (int) apply_filters( 'wpdo/site_health/error_budget_threshold', 100 );
|
|
$count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}` WHERE created_at >= DATE_SUB(UTC_TIMESTAMP(), INTERVAL 7 DAY)" ); // phpcs:ignore WordPress.DB
|
|
if ( $count <= $threshold ) {
|
|
return self::pass(
|
|
sprintf(
|
|
/* translators: %d: error count */
|
|
__( '%d errors in the last 7 days (within budget).', '2meet-data-optimizer' ),
|
|
$count
|
|
)
|
|
);
|
|
}
|
|
return self::fail(
|
|
__( 'WPDO error budget exceeded', '2meet-data-optimizer' ),
|
|
sprintf(
|
|
/* translators: 1: error count, 2: threshold */
|
|
__( '%1$d errors in the last 7 days (threshold %2$d). Inspect under Tools → WP Data Optimizer → Logs.', '2meet-data-optimizer' ),
|
|
$count,
|
|
$threshold
|
|
),
|
|
'recommended'
|
|
);
|
|
}
|
|
);
|
|
return self::wrap( 'wpdo_error_budget', $result );
|
|
}
|
|
|
|
// ─── Test 3: Hook conflicts ──────────────────────────────────────────
|
|
|
|
/**
|
|
* Check for interceptor or hook conflicts via TMDO_Conflict_Monitor.
|
|
*
|
|
* @return array Site Health result.
|
|
*/
|
|
public static function check_hook_conflicts(): array {
|
|
$result = self::cached(
|
|
'wpdo_sh_hook_conflicts',
|
|
static function () {
|
|
if ( ! class_exists( 'TMDO_Conflict_Monitor' ) ) {
|
|
return self::pass( __( 'Conflict monitor not loaded.', '2meet-data-optimizer' ) );
|
|
}
|
|
$summary = TMDO_Conflict_Monitor::get_summary();
|
|
$total = (int) ( $summary['total'] ?? 0 );
|
|
if ( 0 === $total ) {
|
|
return self::pass( __( 'No interceptor / hook conflicts detected.', '2meet-data-optimizer' ) );
|
|
}
|
|
return self::fail(
|
|
__( 'WPDO hook conflicts detected', '2meet-data-optimizer' ),
|
|
sprintf(
|
|
/* translators: %d: number of conflicts */
|
|
__( '%d interceptor/hook conflicts detected. Run wp wpdo conflict-scan for details.', '2meet-data-optimizer' ),
|
|
$total
|
|
),
|
|
'recommended'
|
|
);
|
|
}
|
|
);
|
|
return self::wrap( 'wpdo_hook_conflicts', $result );
|
|
}
|
|
|
|
// ─── Test 4: Autoload bloat ──────────────────────────────────────────
|
|
|
|
/**
|
|
* Check total autoloaded options size against a configurable threshold.
|
|
*
|
|
* @return array Site Health result.
|
|
*/
|
|
public static function check_autoload_bloat(): array {
|
|
$result = self::cached(
|
|
'wpdo_sh_autoload_bloat',
|
|
static function () {
|
|
global $wpdb;
|
|
$threshold_mb = (int) apply_filters( 'wpdo/site_health/autoload_threshold_mb', 5 );
|
|
$bytes = (int) $wpdb->get_var( "SELECT SUM(LENGTH(option_value)) FROM `{$wpdb->options}` WHERE autoload = 'yes'" ); // phpcs:ignore WordPress.DB
|
|
$mb = $bytes / 1024 / 1024;
|
|
if ( $mb < $threshold_mb ) {
|
|
return self::pass(
|
|
sprintf(
|
|
/* translators: 1: actual size in MB */
|
|
__( 'Autoload total %1$0.2f MB (under %2$d MB threshold).', '2meet-data-optimizer' ),
|
|
$mb,
|
|
$threshold_mb
|
|
)
|
|
);
|
|
}
|
|
return self::fail(
|
|
__( 'Autoload size large', '2meet-data-optimizer' ),
|
|
sprintf(
|
|
/* translators: 1: actual size in MB, 2: threshold in MB */
|
|
__( 'Autoload total %1$0.2f MB exceeds %2$d MB threshold. Consider migrating large autoloaded options to wp_wpdo_uni_options.', '2meet-data-optimizer' ),
|
|
$mb,
|
|
$threshold_mb
|
|
),
|
|
'recommended'
|
|
);
|
|
}
|
|
);
|
|
return self::wrap( 'wpdo_autoload_bloat', $result );
|
|
}
|
|
|
|
// ─── Test 5: Postmeta explosion ──────────────────────────────────────
|
|
|
|
/**
|
|
* Check whether wp_postmeta row count exceeds the explosion threshold.
|
|
*
|
|
* @return array Site Health result.
|
|
*/
|
|
public static function check_postmeta_explosion(): array {
|
|
$result = self::cached(
|
|
'wpdo_sh_postmeta_explosion',
|
|
static function () {
|
|
global $wpdb;
|
|
$threshold = (int) apply_filters( 'wpdo/site_health/postmeta_threshold', 5_000_000 );
|
|
$count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$wpdb->postmeta}`" ); // phpcs:ignore WordPress.DB
|
|
if ( $count < $threshold ) {
|
|
return self::pass(
|
|
sprintf(
|
|
/* translators: %s: row count */
|
|
__( 'wp_postmeta has %s rows (under threshold).', '2meet-data-optimizer' ),
|
|
number_format_i18n( $count )
|
|
)
|
|
);
|
|
}
|
|
// Also check if any zone module is active.
|
|
$any_active = false;
|
|
if ( class_exists( 'TMDO_Feature_Flags' ) ) {
|
|
foreach ( TMDO_Feature_Flags::all() as $state ) {
|
|
if ( 'idle' !== $state ) {
|
|
$any_active = true;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
$severity = $any_active ? 'recommended' : 'critical';
|
|
return self::fail(
|
|
__( 'wp_postmeta is large', '2meet-data-optimizer' ),
|
|
sprintf(
|
|
/* translators: %s: row count */
|
|
__( 'wp_postmeta has %s rows. Run the Classifier to identify candidates for migration into Hot/Warm/Cold/Archive zones.', '2meet-data-optimizer' ),
|
|
number_format_i18n( $count )
|
|
),
|
|
$severity
|
|
);
|
|
}
|
|
);
|
|
return self::wrap( 'wpdo_postmeta_explosion', $result );
|
|
}
|
|
|
|
// ─── Test 6: Orphan zone rows ────────────────────────────────────────
|
|
|
|
/**
|
|
* Detect zone table rows that belong to modules currently in idle state.
|
|
*
|
|
* @return array Site Health result.
|
|
*/
|
|
public static function check_orphan_zone_rows(): array {
|
|
$result = self::cached(
|
|
'wpdo_sh_orphan_zone',
|
|
static function () {
|
|
global $wpdb;
|
|
if ( ! class_exists( 'TMDO_Feature_Flags' ) ) {
|
|
return self::pass( __( 'Feature flags not loaded.', '2meet-data-optimizer' ) );
|
|
}
|
|
$idle_modules = array_keys( array_filter( TMDO_Feature_Flags::all(), static fn( $state ) => 'idle' === $state ) );
|
|
$orphans = array();
|
|
foreach ( $idle_modules as $module ) {
|
|
// Best-effort: zone tables for hot_*/cold_* are dynamically named.
|
|
$candidates = array(
|
|
$wpdb->prefix . 'wpdo_warm',
|
|
$wpdb->prefix . 'wpdo_archive',
|
|
);
|
|
foreach ( $candidates as $table ) {
|
|
$exists = (int) $wpdb->get_var(
|
|
$wpdb->prepare( // phpcs:ignore WordPress.DB
|
|
'SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s',
|
|
$table
|
|
)
|
|
);
|
|
if ( 0 === $exists ) {
|
|
continue;
|
|
}
|
|
$count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}`" ); // phpcs:ignore WordPress.DB
|
|
if ( $count > 0 ) {
|
|
$orphans[ $table ] = $count;
|
|
}
|
|
}
|
|
}
|
|
if ( empty( $orphans ) ) {
|
|
return self::pass( __( 'No orphan zone rows from idle modules.', '2meet-data-optimizer' ) );
|
|
}
|
|
$lines = array();
|
|
foreach ( $orphans as $t => $n ) {
|
|
$lines[] = sprintf( '%s (%s rows)', $t, number_format_i18n( $n ) );
|
|
}
|
|
return self::fail(
|
|
__( 'Orphan zone rows detected', '2meet-data-optimizer' ),
|
|
sprintf(
|
|
/* translators: %s: list of tables and row counts */
|
|
__( 'Modules in idle state but zone tables still hold data: %s. These rows are typically cleanup leftovers — verify before truncating.', '2meet-data-optimizer' ),
|
|
implode( ', ', $lines )
|
|
),
|
|
'recommended'
|
|
);
|
|
}
|
|
);
|
|
return self::wrap( 'wpdo_orphan_zone_rows', $result );
|
|
}
|
|
|
|
// ─── Test 7: Missing snapshot ────────────────────────────────────────
|
|
|
|
/**
|
|
* Verify that a recent snapshot exists when modules are in risk states.
|
|
*
|
|
* @return array Site Health result.
|
|
*/
|
|
public static function check_missing_snapshot(): array {
|
|
$result = self::cached(
|
|
'wpdo_sh_missing_snapshot',
|
|
static function () {
|
|
global $wpdb;
|
|
if ( ! class_exists( 'TMDO_Snapshot_Manager' ) || ! class_exists( 'TMDO_Feature_Flags' ) ) {
|
|
return self::pass( __( 'Snapshot system not yet available.', '2meet-data-optimizer' ) );
|
|
}
|
|
// Risk only applies to modules in cutover/cleanup/complete (data is in custom tables).
|
|
$risk_modules = array_keys(
|
|
array_filter(
|
|
TMDO_Feature_Flags::all(),
|
|
static fn( $state ) => in_array( $state, array( 'cutover', 'cleanup', 'complete' ), true )
|
|
)
|
|
);
|
|
if ( empty( $risk_modules ) ) {
|
|
return self::pass( __( 'No modules in risk state — snapshot not required.', '2meet-data-optimizer' ) );
|
|
}
|
|
$snap_table = $wpdb->prefix . TMDO_Snapshot_Manager::TABLE_SLUG;
|
|
$exists = (int) $wpdb->get_var(
|
|
$wpdb->prepare( // phpcs:ignore WordPress.DB
|
|
'SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s',
|
|
$snap_table
|
|
)
|
|
);
|
|
if ( 0 === $exists ) {
|
|
return self::fail(
|
|
__( 'Snapshot table missing', '2meet-data-optimizer' ),
|
|
__( 'Snapshot system table not present. Run wp wpdo install.', '2meet-data-optimizer' ),
|
|
'critical'
|
|
);
|
|
}
|
|
$days = (int) apply_filters( 'wpdo/site_health/snapshot_max_age_days', 7 );
|
|
$recent = (int) $wpdb->get_var(
|
|
$wpdb->prepare(
|
|
"SELECT COUNT(*) FROM `{$snap_table}` WHERE created_at >= DATE_SUB(UTC_TIMESTAMP(), INTERVAL %d DAY)", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- {$snap_table} is $wpdb->prefix + TABLE_SLUG constant (no user input)
|
|
$days
|
|
)
|
|
);
|
|
if ( $recent > 0 ) {
|
|
return self::pass(
|
|
sprintf(
|
|
/* translators: 1: count, 2: days */
|
|
__( 'Found %1$d snapshot(s) within the last %2$d days.', '2meet-data-optimizer' ),
|
|
$recent,
|
|
$days
|
|
)
|
|
);
|
|
}
|
|
return self::fail(
|
|
__( 'No recent WPDO snapshot', '2meet-data-optimizer' ),
|
|
sprintf(
|
|
/* translators: 1: comma-separated module names, 2: days */
|
|
__( 'Modules in risk state (%1$s) but no snapshot in last %2$d days. Run: wp wpdo snapshot create --trigger=manual --notes="catch-up safety net"', '2meet-data-optimizer' ),
|
|
implode( ', ', $risk_modules ),
|
|
$days
|
|
),
|
|
'critical'
|
|
);
|
|
}
|
|
);
|
|
return self::wrap( 'wpdo_missing_snapshot', $result );
|
|
}
|
|
|
|
// ─── helpers ──────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Wrap a raw check result with required Site Health envelope fields.
|
|
*
|
|
* @param string $key Test slug.
|
|
* @param array $result Raw result with keys label, status, description, severity.
|
|
* @return array
|
|
*/
|
|
private static function wrap( string $key, array $result ): array {
|
|
$result['test'] = $key;
|
|
$result['badge'] = array(
|
|
'label' => 'WPDO',
|
|
'color' => 'blue',
|
|
);
|
|
return $result;
|
|
}
|
|
|
|
/**
|
|
* Cache a callable's return value via transient.
|
|
*
|
|
* @param string $key Transient key.
|
|
* @param callable $producer Callable returning result array.
|
|
* @return array
|
|
*/
|
|
private static function cached( string $key, callable $producer ): array {
|
|
$cached = get_transient( $key );
|
|
if ( is_array( $cached ) ) {
|
|
return $cached;
|
|
}
|
|
$result = $producer();
|
|
set_transient( $key, $result, self::CACHE_TTL );
|
|
return $result;
|
|
}
|
|
|
|
/**
|
|
* Build a "pass" result envelope.
|
|
*
|
|
* @param string $description Body text.
|
|
* @return array
|
|
*/
|
|
private static function pass( string $description ): array {
|
|
return array(
|
|
'label' => __( 'WPDO check passed', '2meet-data-optimizer' ),
|
|
'status' => 'good',
|
|
'description' => '<p>' . esc_html( $description ) . '</p>',
|
|
'severity' => 'good',
|
|
'actions' => '',
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Build a fail / warning result envelope.
|
|
*
|
|
* @param string $label Test heading.
|
|
* @param string $description Body.
|
|
* @param string $severity 'critical' | 'recommended'.
|
|
* @return array
|
|
*/
|
|
private static function fail( string $label, string $description, string $severity ): array {
|
|
return array(
|
|
'label' => $label,
|
|
'status' => 'critical' === $severity ? 'critical' : 'recommended',
|
|
'description' => '<p>' . esc_html( $description ) . '</p>',
|
|
'severity' => $severity,
|
|
'actions' => '<p><a href="' . esc_url( admin_url( 'tools.php?page=wp-data-optimizer' ) ) . '">' . esc_html__( 'Open WPDO admin', '2meet-data-optimizer' ) . '</a></p>',
|
|
);
|
|
}
|
|
}
|