fix(2meet-data-optimizer): report entity group name collisions instead of silently dropping

- register_group() keeps the first definition but records a field diff (added/removed/changed) when the same entity/group is re-registered differently; identical re-registration stays silent
- Surface collisions via Site Health (wpdo_entity_group_collisions), the admin Doctor tab, and wp wpdo doctor; warning log throttled to once a day
- Verify: EntityRegistryCollisionTest (7); core unit 613 / integration 432, HP 141/49, WC 10/22 OK
- PR: PR-017
This commit is contained in:
2026-09-25 23:27:05 +08:00
parent 07db7734cf
commit e9d8696c93
6 changed files with 248 additions and 33 deletions
+59 -16
View File
@@ -2,7 +2,7 @@
/**
* TMDO_Site_Health — WordPress Site Health integration (v2.2.0 M3).
*
* Registers 7 tests under Tools → Site Health → Status:
* Registers 8 tests under Tools → Site Health → Status:
* 1. wpdo_schema_drift (critical)
* 2. wpdo_error_budget (recommended)
* 3. wpdo_hook_conflicts (recommended)
@@ -10,6 +10,7 @@
* 5. wpdo_postmeta_explosion (recommended)
* 6. wpdo_orphan_zone_rows (recommended)
* 7. wpdo_missing_snapshot (critical)
* 8. wpdo_entity_group_collisions (recommended)
*
* Each test result is cached for 5 minutes to keep Site Health responsive.
*
@@ -34,13 +35,14 @@ class TMDO_Site_Health {
/** 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',
'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',
'wpdo_entity_group_collisions' => 'check_entity_group_collisions',
);
/**
@@ -53,7 +55,7 @@ class TMDO_Site_Health {
}
/**
* Register the 7 tests with WP Site Health.
* Register the 8 tests with WP Site Health.
*
* @param array $tests Existing tests.
* @return array
@@ -76,13 +78,14 @@ class TMDO_Site_Health {
*/
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' ),
'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' ),
'wpdo_entity_group_collisions' => __( 'WPDO entity group collisions', '2meet-data-optimizer' ),
);
return $map[ $key ] ?? $key;
}
@@ -425,6 +428,46 @@ class TMDO_Site_Health {
// ─── helpers ──────────────────────────────────────────────────────────
// ─── Test 8: Entity group name collisions ───────────────────────────
/**
* Report entity groups registered twice with different definitions.
*
* Not cached: the collision list is in-memory state rebuilt on every
* request, so reading it is free and a transient would only go stale.
*
* @return array Site Health result.
*/
public static function check_entity_group_collisions(): array {
$collisions = class_exists( 'TMDO_Entity_Registry' ) ? TMDO_Entity_Registry::get_collisions() : array();
if ( empty( $collisions ) ) {
return self::wrap( 'wpdo_entity_group_collisions', self::pass( __( 'No entity group name collisions.', '2meet-data-optimizer' ) ) );
}
$lines = array();
foreach ( $collisions as $c ) {
$lines[] = sprintf(
'%s/%s (+%s −%s ~%s)',
$c['entity_type'],
$c['group'],
implode( ',', $c['added'] ) ?: '-',
implode( ',', $c['removed'] ) ?: '-',
implode( ',', $c['changed'] ) ?: '-'
);
}
return self::wrap(
'wpdo_entity_group_collisions',
self::fail(
__( 'WPDO entity group name collisions', '2meet-data-optimizer' ),
sprintf(
/* translators: %s: list of entity/group names with field differences */
__( 'Another plugin registered these groups with a different field list; its definition was ignored and the first one kept: %s', '2meet-data-optimizer' ),
implode( '; ', $lines )
),
'recommended'
)
);
}
/**
* Wrap a raw check result with required Site Health envelope fields.
*
+62 -8
View File
@@ -31,6 +31,9 @@ final class TMDO_Entity_Registry {
/** @var array<int, array{type:string, group:string, fields:array}> 待建表的 Schema 清單 */
private static array $pending_schemas = array();
/** @var array<string, array> "entity/group" → 同名群組以不同定義重複註冊的紀錄 */
private static array $collisions = array();
/** 合法的欄位型別 */
public const VALID_TYPES = array(
'text',
@@ -54,6 +57,7 @@ final class TMDO_Entity_Registry {
self::$groups = array();
self::$field_index = array();
self::$pending_schemas = array();
self::$collisions = array();
}
// ─────────────────────────────────────────────────────────
@@ -105,11 +109,6 @@ final class TMDO_Entity_Registry {
return false;
}
// 防重複登錄
if ( isset( self::$groups[ $entity_type ][ $group_name ] ) ) {
return false;
}
// 驗證並正規化欄位定義
$normalized = array();
@@ -141,15 +140,25 @@ final class TMDO_Entity_Registry {
$normalized_field['entity_type'] = $entity_type;
$normalized[] = $normalized_field;
// 建立索引以供快速查詢
self::$field_index[ $entity_type ][ $normalized_field['key'] ] = $normalized_field;
}
if ( empty( $normalized ) ) {
return false;
}
// 防重複登錄:相同定義(init:1 重新觸發)靜默略過;定義不同則記錄衝突,保留先註冊者。
if ( isset( self::$groups[ $entity_type ][ $group_name ] ) ) {
if ( self::$groups[ $entity_type ][ $group_name ] !== $normalized ) {
self::record_collision( $entity_type, $group_name, self::$groups[ $entity_type ][ $group_name ], $normalized );
}
return false;
}
// 建立索引以供快速查詢
foreach ( $normalized as $normalized_field ) {
self::$field_index[ $entity_type ][ $normalized_field['key'] ] = $normalized_field;
}
self::$groups[ $entity_type ][ $group_name ] = $normalized;
// 加入待建表佇列
@@ -162,6 +171,51 @@ final class TMDO_Entity_Registry {
return true;
}
/**
* 記錄同名群組衝突(每個 entity/group 每請求一筆),並寫入 warning。
*
* 註冊每個請求都會跑,warning 以 transient 節流為每天一次,避免灌爆 wpdo_errors。
*/
private static function record_collision( string $entity_type, string $group_name, array $existing, array $incoming ): void {
$id = $entity_type . '/' . $group_name;
if ( isset( self::$collisions[ $id ] ) ) {
return;
}
$old = array_column( $existing, null, 'key' );
$new = array_column( $incoming, null, 'key' );
$changed = array();
foreach ( array_intersect_key( $new, $old ) as $key => $field ) {
if ( $field !== $old[ $key ] ) {
$changed[] = (string) $key;
}
}
self::$collisions[ $id ] = array(
'entity_type' => $entity_type,
'group' => $group_name,
'added' => array_map( 'strval', array_keys( array_diff_key( $new, $old ) ) ),
'removed' => array_map( 'strval', array_keys( array_diff_key( $old, $new ) ) ),
'changed' => $changed,
);
$throttle = 'tmdo_group_collision_' . md5( (string) wp_json_encode( self::$collisions[ $id ] ) );
if ( class_exists( 'TMDO_Logger' ) && false === get_transient( $throttle ) ) {
set_transient( $throttle, 1, DAY_IN_SECONDS );
TMDO_Logger::warning( 'entity_group_collision', self::$collisions[ $id ] );
}
}
/**
* 本請求偵測到的同名群組衝突(第二份定義已被捨棄)。
*
* @return array<int, array{entity_type:string, group:string, added:string[], removed:string[], changed:string[]}>
*/
public static function get_collisions(): array {
return array_values( self::$collisions );
}
// ─────────────────────────────────────────────────────────
// 查詢 API
// ─────────────────────────────────────────────────────────