Files
2meet-data-optimizer/includes/diagnostic/class-tmdo-site-health.php
T
wpdev f524ea3f16 build: 移植 PHPCS + PHPStan L6 品質 gate(PR-I 前半)
- phpcs.xml(自 A 移植):ruleset 改名、*/tools/* 例外換成 */back-compat/*、
  中文註解全域排除 Squiz.Commenting.InlineComment.InvalidEndChar、
  interface 別名檔排除 OneObjectStructurePerFile
- 檔頭正規化:16 個檔案的 declare(strict_types=1) 與前導 // 註解移到
  file docblock 之後,並移除 <?php 後多餘空行(phpcbf 另自動修 190 處)
- phpstan.neon + .phpstan/stubs.php(TMDO_ 與 WPDO_ 兩套常數)+
  重新產生的 phpstan-baseline.neon(710 errors,A 的 3877 行 baseline
  因前綴與路徑不同無法沿用)

現況:PHPCS 0 errors / 0 warnings、PHPStan L6 No errors、
unit 451 / integration 398 GREEN

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TbG1keQQ7XBa7qMQY16KCY
2026-07-31 06:22:00 +08:00

495 lines
17 KiB
PHP

<?php
/**
* 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
*/
// phpcs:ignore WPDO.AntiEAV -- platform diagnostic: raw meta inspection for site health
declare(strict_types=1);
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>',
);
}
}