diff --git a/admin/class-tmdo-admin.php b/admin/class-tmdo-admin.php
index e4fbc65..c3afe3a 100644
--- a/admin/class-tmdo-admin.php
+++ b/admin/class-tmdo-admin.php
@@ -2990,7 +2990,7 @@ wpdo.getListings({ per_page: 3 }).then(r => console.log(r));'
?>
-
+
@@ -3004,13 +3004,14 @@ wpdo.getListings({ per_page: 3 }).then(r => console.log(r));'
}
$tests = array(
- 'wpdo_schema_drift' => array( 'check_schema_drift', __( 'Schema 完整性', '2meet-data-optimizer' ) ),
- 'wpdo_error_budget' => array( 'check_error_budget', __( '錯誤預算(過去 7 天)', '2meet-data-optimizer' ) ),
- 'wpdo_hook_conflicts' => array( 'check_hook_conflicts', __( 'Hook 衝突', '2meet-data-optimizer' ) ),
- 'wpdo_autoload_bloat' => array( 'check_autoload_bloat', __( 'Autoload 大小', '2meet-data-optimizer' ) ),
- 'wpdo_postmeta_explosion' => array( 'check_postmeta_explosion', __( 'wp_postmeta 爆量', '2meet-data-optimizer' ) ),
- 'wpdo_orphan_zone_rows' => array( 'check_orphan_zone_rows', __( 'Orphan zone rows', '2meet-data-optimizer' ) ),
- 'wpdo_missing_snapshot' => array( 'check_missing_snapshot', __( '缺少最近快照', '2meet-data-optimizer' ) ),
+ 'wpdo_schema_drift' => array( 'check_schema_drift', __( 'Schema 完整性', '2meet-data-optimizer' ) ),
+ 'wpdo_error_budget' => array( 'check_error_budget', __( '錯誤預算(過去 7 天)', '2meet-data-optimizer' ) ),
+ 'wpdo_hook_conflicts' => array( 'check_hook_conflicts', __( 'Hook 衝突', '2meet-data-optimizer' ) ),
+ 'wpdo_autoload_bloat' => array( 'check_autoload_bloat', __( 'Autoload 大小', '2meet-data-optimizer' ) ),
+ 'wpdo_postmeta_explosion' => array( 'check_postmeta_explosion', __( 'wp_postmeta 爆量', '2meet-data-optimizer' ) ),
+ 'wpdo_orphan_zone_rows' => array( 'check_orphan_zone_rows', __( 'Orphan zone rows', '2meet-data-optimizer' ) ),
+ 'wpdo_missing_snapshot' => array( 'check_missing_snapshot', __( '缺少最近快照', '2meet-data-optimizer' ) ),
+ 'wpdo_entity_group_collisions' => array( 'check_entity_group_collisions', __( '實體群組名稱衝突', '2meet-data-optimizer' ) ),
);
?>
diff --git a/admin/class-tmdo-help-tabs.php b/admin/class-tmdo-help-tabs.php
index c1c36ba..b4899a4 100644
--- a/admin/class-tmdo-help-tabs.php
+++ b/admin/class-tmdo-help-tabs.php
@@ -184,7 +184,7 @@ class TMDO_Help_Tabs {
* @return string
*/
private static function content_doctor(): string {
- return '' . esc_html__( '7 項自動健康檢查:', '2meet-data-optimizer' ) . '
'
+ return '' . esc_html__( '8 項自動健康檢查:', '2meet-data-optimizer' ) . '
'
. ''
. '- schema_drift — ' . esc_html__( '所有 v2 表是否存在', '2meet-data-optimizer' ) . '
'
. '- error_budget — ' . esc_html__( '7 天內 wp_wpdo_errors 行數', '2meet-data-optimizer' ) . '
'
@@ -193,6 +193,7 @@ class TMDO_Help_Tabs {
. '- postmeta_explosion — ' . esc_html__( 'wp_postmeta > 5M 行', '2meet-data-optimizer' ) . '
'
. '- orphan_zone_rows — ' . esc_html__( '已 idle 的 module 但 zone 表還有資料', '2meet-data-optimizer' ) . '
'
. '- missing_snapshot — ' . esc_html__( '在 cutover/cleanup/complete 但 7 天沒 snapshot', '2meet-data-optimizer' ) . '
'
+ . '- entity_group_collisions — ' . esc_html__( '同名實體群組被不同外掛以不同欄位註冊(後者被忽略)', '2meet-data-optimizer' ) . '
'
. '
'
. '' . esc_html__( '結果有 5 分鐘 transient cache,剛操作完想立刻看新值請等下個週期。', '2meet-data-optimizer' ) . '
';
}
diff --git a/cli/class-tmdo-cli.php b/cli/class-tmdo-cli.php
index e227736..5c0023c 100644
--- a/cli/class-tmdo-cli.php
+++ b/cli/class-tmdo-cli.php
@@ -330,6 +330,25 @@ class TMDO_CLI {
WP_CLI::log( ' [OK] Crypto key derivable (AUTH_KEY / SECURE_AUTH_SALT present)' );
}
+ // ── Entity group name collisions ─────────────────────────────────────────
+ $collisions = TMDO_Entity_Registry::get_collisions();
+ if ( empty( $collisions ) ) {
+ WP_CLI::log( ' [OK] No entity group name collisions' );
+ }
+ foreach ( $collisions as $c ) {
+ WP_CLI::warning(
+ sprintf(
+ ' [COLLISION] %s/%s registered twice with different fields (second ignored) — added: %s; removed: %s; changed: %s',
+ $c['entity_type'],
+ $c['group'],
+ implode( ', ', $c['added'] ) ?: '-',
+ implode( ', ', $c['removed'] ) ?: '-',
+ implode( ', ', $c['changed'] ) ?: '-'
+ )
+ );
+ $all_ok = false;
+ }
+
// Check recent errors.
$errors = TMDO_Logger::get_recent( '', 5 );
if ( ! empty( $errors ) ) {
diff --git a/includes/diagnostic/class-tmdo-site-health.php b/includes/diagnostic/class-tmdo-site-health.php
index 77db2d9..bba38c2 100644
--- a/includes/diagnostic/class-tmdo-site-health.php
+++ b/includes/diagnostic/class-tmdo-site-health.php
@@ -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.
*
diff --git a/includes/engine/class-tmdo-entity-registry.php b/includes/engine/class-tmdo-entity-registry.php
index c4e0a5a..c6e6ec1 100644
--- a/includes/engine/class-tmdo-entity-registry.php
+++ b/includes/engine/class-tmdo-entity-registry.php
@@ -31,6 +31,9 @@ final class TMDO_Entity_Registry {
/** @var array 待建表的 Schema 清單 */
private static array $pending_schemas = array();
+ /** @var 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
+ */
+ public static function get_collisions(): array {
+ return array_values( self::$collisions );
+ }
+
// ─────────────────────────────────────────────────────────
// 查詢 API
// ─────────────────────────────────────────────────────────
diff --git a/tests/unit/EntityRegistryCollisionTest.php b/tests/unit/EntityRegistryCollisionTest.php
new file mode 100644
index 0000000..1b21b49
--- /dev/null
+++ b/tests/unit/EntityRegistryCollisionTest.php
@@ -0,0 +1,97 @@
+createMock( TMDO_Entity_Adapter_Interface::class ) );
+ $GLOBALS['_wp_options'] = array();
+ }
+
+ private function first_definition(): array {
+ return array(
+ array( 'key' => 'hp_price', 'type' => 'decimal', 'searchable' => true ),
+ array( 'key' => 'hp_featured', 'type' => 'boolean' ),
+ );
+ }
+
+ public function test_identical_reregistration_is_silent(): void {
+ $this->assertTrue( TMDO_Entity_Registry::register_group( 'post', 'hp_listing_core', $this->first_definition() ) );
+ $this->assertFalse( TMDO_Entity_Registry::register_group( 'post', 'hp_listing_core', $this->first_definition() ) );
+
+ $this->assertSame( array(), TMDO_Entity_Registry::get_collisions() );
+ }
+
+ public function test_different_definition_is_recorded_with_field_diff(): void {
+ TMDO_Entity_Registry::register_group( 'post', 'hp_listing_core', $this->first_definition() );
+
+ $second = array(
+ array( 'key' => 'hp_price', 'type' => 'integer', 'searchable' => true ),
+ array( 'key' => 'hp_vendor', 'type' => 'integer' ),
+ );
+ $this->assertFalse( TMDO_Entity_Registry::register_group( 'post', 'hp_listing_core', $second ) );
+
+ $collisions = TMDO_Entity_Registry::get_collisions();
+ $this->assertCount( 1, $collisions );
+ $this->assertSame( 'post', $collisions[0]['entity_type'] );
+ $this->assertSame( 'hp_listing_core', $collisions[0]['group'] );
+ $this->assertSame( array( 'hp_vendor' ), $collisions[0]['added'] );
+ $this->assertSame( array( 'hp_featured' ), $collisions[0]['removed'] );
+ $this->assertSame( array( 'hp_price' ), $collisions[0]['changed'] );
+ }
+
+ public function test_first_definition_wins(): void {
+ TMDO_Entity_Registry::register_group( 'post', 'hp_listing_core', $this->first_definition() );
+ TMDO_Entity_Registry::register_group( 'post', 'hp_listing_core', array( array( 'key' => 'hp_vendor', 'type' => 'integer' ) ) );
+
+ $this->assertSame( array( 'hp_price', 'hp_featured' ), TMDO_Entity_Registry::get_group_keys( 'post', 'hp_listing_core' ) );
+ $this->assertNull( TMDO_Entity_Registry::get_field( 'post', 'hp_vendor' ) );
+ }
+
+ public function test_same_collision_is_recorded_once_per_request(): void {
+ TMDO_Entity_Registry::register_group( 'post', 'g', $this->first_definition() );
+ $other = array( array( 'key' => 'x', 'type' => 'text' ) );
+ TMDO_Entity_Registry::register_group( 'post', 'g', $other );
+ TMDO_Entity_Registry::register_group( 'post', 'g', $other );
+
+ $this->assertCount( 1, TMDO_Entity_Registry::get_collisions() );
+ }
+
+ public function test_init_clears_collisions(): void {
+ TMDO_Entity_Registry::register_group( 'post', 'g', $this->first_definition() );
+ TMDO_Entity_Registry::register_group( 'post', 'g', array( array( 'key' => 'x', 'type' => 'text' ) ) );
+ TMDO_Entity_Registry::init();
+
+ $this->assertSame( array(), TMDO_Entity_Registry::get_collisions() );
+ }
+
+ public function test_site_health_reports_collisions(): void {
+ $this->assertSame( 'good', TMDO_Site_Health::check_entity_group_collisions()['status'] );
+
+ TMDO_Entity_Registry::register_group( 'post', 'hp_listing_core', $this->first_definition() );
+ TMDO_Entity_Registry::register_group( 'post', 'hp_listing_core', array( array( 'key' => 'x', 'type' => 'text' ) ) );
+
+ $result = TMDO_Site_Health::check_entity_group_collisions();
+ $this->assertSame( 'recommended', $result['status'] );
+ $this->assertStringContainsString( 'post/hp_listing_core', $result['description'] );
+ }
+
+ public function test_site_health_registers_the_collision_test(): void {
+ $tests = TMDO_Site_Health::register_tests( array() );
+ $this->assertArrayHasKey( 'wpdo_entity_group_collisions', $tests['direct'] );
+ }
+}