Files
2meet-data-optimizer/cli/class-tmdo-cli-term-comment.php
T
wpdev 76c01e44df refactor: 全部 128 個生產檔加入 declare(strict_types=1)(PR-H)
對齊 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
2026-07-31 06:13:33 +08:00

1024 lines
35 KiB
PHP

<?php
declare(strict_types=1);
// phpcs:ignore WPDO.AntiEAV -- platform CLI inspector tool: raw meta queries needed for diagnostics
/**
* TMDO_CLI_Term_Comment — Term + Comment entity CLI subcommands (v2.12.0+).
*
* Adds the following subcommands under `wp wpdo`:
*
* wp wpdo termmeta-cleanup — Clean wp_termmeta garbage (wxr_import,
* demo_data, transients).
* wp wpdo commentmeta-cleanup — Clean wp_commentmeta garbage (wxr_import,
* demo_data, transients, orphan_post_meta).
*
* Mirrors TMDO_CLI_Post's design: thin wrappers around core cleaner classes.
* Future term/comment Entity Bridge subcommands (term-diagnose,
* term-migrate-group, comment-diagnose, etc.) will land in this same file
* across v2.12.x releases.
*
* @package WP_Data_Optimizer
* @since 2.12.0
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
// phpcs:disable Squiz.Commenting.FunctionComment.MissingParamTag,Generic.CodeAnalysis.UnusedFunctionParameter,Generic.Commenting.DocComment.ShortNotCapital -- WP_CLI callbacks accept ($args, $assoc_args) by contract; many subcommands only need one.
if ( ! class_exists( 'WP_CLI' ) ) {
return;
}
/**
* Term + Comment entity CLI subcommands. Loaded only in WP_CLI context.
*/
class TMDO_CLI_Term_Comment {
// ── termmeta-cleanup (v2.12.0 Phase 0) ────────────────────────────────────
/**
* Clean wp_termmeta garbage rows.
*
* Targets:
* - wxr_import — `_wxr_import_*` rows (WP importer residue, never read)
* - demo_data — `_2meet_demo_*` rows (re-seedable demo markers)
* - transients — `_transient_*` / `_transient_timeout_*` cache layer
*
* SAFETY: by default refuses to run. Pass --dry-run to preview, or
* --confirm to actually delete.
*
* ## OPTIONS
*
* [--target=<target>]
* : Which garbage class to address. Default: all.
* ---
* default: all
* options:
* - all
* - wxr_import
* - demo_data
* - transients
* ---
*
* [--dry-run]
* : Show row counts without deleting.
*
* [--confirm]
* : Required to actually DELETE rows. Mutually exclusive with --dry-run.
*
* ## EXAMPLES
*
* wp wpdo termmeta-cleanup --dry-run
* wp wpdo termmeta-cleanup --target=wxr_import --dry-run
* wp wpdo termmeta-cleanup --confirm
*
* @param array $args Positional arguments (unused).
* @param array $assoc_args Named arguments.
*/
public function termmeta_cleanup( $args, $assoc_args ): void {
$target = (string) ( $assoc_args['target'] ?? TMDO_Termmeta_Cleaner::TARGET_ALL );
$dry_run = isset( $assoc_args['dry-run'] );
$confirm = isset( $assoc_args['confirm'] );
if ( ! in_array( $target, TMDO_Termmeta_Cleaner::VALID_TARGETS, true ) ) {
WP_CLI::error(
'Invalid --target. Choose: ' . implode( ', ', TMDO_Termmeta_Cleaner::VALID_TARGETS )
);
}
if ( $dry_run && $confirm ) {
WP_CLI::error( '--dry-run and --confirm are mutually exclusive.' );
}
if ( ! $dry_run && ! $confirm ) {
WP_CLI::error(
"Refusing to run without an explicit choice. Pass --dry-run to preview, or --confirm to delete.\n" .
'Example: wp wpdo termmeta-cleanup --dry-run'
);
}
if ( $dry_run ) {
$counts = TMDO_Termmeta_Cleaner::count_garbage( $target );
self::render_term_table( $counts, 'would delete' );
WP_CLI::success(
sprintf( '[dry-run] %d row(s) would be deleted. Re-run with --confirm to apply.', $counts['total'] )
);
return;
}
$deleted = TMDO_Termmeta_Cleaner::delete_garbage( $target );
self::render_term_table( $deleted, 'deleted' );
if ( class_exists( 'TMDO_Logger' ) ) {
TMDO_Logger::info(
'termmeta_cleanup',
array(
'target' => $target,
'wxr_import' => $deleted['wxr_import'],
'demo_data' => $deleted['demo_data'],
'transients' => $deleted['transients'],
'total' => $deleted['total'],
)
);
}
WP_CLI::success( sprintf( 'Deleted %d row(s) from wp_termmeta.', $deleted['total'] ) );
}
/**
* Render a table for termmeta cleanup output.
*
* @param array $counts Output from TMDO_Termmeta_Cleaner::count_garbage / delete_garbage.
* @param string $verb Column header verb.
*/
private static function render_term_table( array $counts, string $verb ): void {
$rows = array(
array(
'bucket' => 'wxr_import',
$verb => $counts['wxr_import'],
'rule' => '_wxr_import_*',
),
array(
'bucket' => 'demo_data',
$verb => $counts['demo_data'],
'rule' => '_2meet_demo_*',
),
array(
'bucket' => 'transients',
$verb => $counts['transients'],
'rule' => '_transient_% OR _transient_timeout_%',
),
array(
'bucket' => 'TOTAL',
$verb => $counts['total'],
'rule' => '',
),
);
WP_CLI\Utils\format_items( 'table', $rows, array( 'bucket', $verb, 'rule' ) );
}
// ── commentmeta-cleanup (v2.12.0 Phase 0) ─────────────────────────────────
/**
* Clean wp_commentmeta garbage rows.
*
* Targets:
* - wxr_import — `_wxr_import_*` rows (often dominant — dev10 had 211)
* - demo_data — `_2meet_demo_*` rows
* - transients — `_transient_*` cache rows
* - orphan_post_meta — Stray post-domain keys (`_hp_price`, `_thumbnail_id`,
* etc.) mistakenly written to commentmeta
*
* SAFETY: by default refuses to run. Pass --dry-run to preview, or
* --confirm to actually delete.
*
* ## OPTIONS
*
* [--target=<target>]
* : Which garbage class to address. Default: all.
* ---
* default: all
* options:
* - all
* - wxr_import
* - demo_data
* - transients
* - orphan_post_meta
* ---
*
* [--dry-run]
* : Show row counts without deleting.
*
* [--confirm]
* : Required to actually DELETE rows.
*
* ## EXAMPLES
*
* wp wpdo commentmeta-cleanup --dry-run
* wp wpdo commentmeta-cleanup --target=wxr_import --confirm
* wp wpdo commentmeta-cleanup --confirm
*
* @param array $args Positional arguments (unused).
* @param array $assoc_args Named arguments.
*/
public function commentmeta_cleanup( $args, $assoc_args ): void {
$target = (string) ( $assoc_args['target'] ?? TMDO_Commentmeta_Cleaner::TARGET_ALL );
$dry_run = isset( $assoc_args['dry-run'] );
$confirm = isset( $assoc_args['confirm'] );
if ( ! in_array( $target, TMDO_Commentmeta_Cleaner::VALID_TARGETS, true ) ) {
WP_CLI::error(
'Invalid --target. Choose: ' . implode( ', ', TMDO_Commentmeta_Cleaner::VALID_TARGETS )
);
}
if ( $dry_run && $confirm ) {
WP_CLI::error( '--dry-run and --confirm are mutually exclusive.' );
}
if ( ! $dry_run && ! $confirm ) {
WP_CLI::error(
"Refusing to run without an explicit choice. Pass --dry-run to preview, or --confirm to delete.\n" .
'Example: wp wpdo commentmeta-cleanup --dry-run'
);
}
if ( $dry_run ) {
$counts = TMDO_Commentmeta_Cleaner::count_garbage( $target );
self::render_comment_table( $counts, 'would delete' );
WP_CLI::success(
sprintf( '[dry-run] %d row(s) would be deleted. Re-run with --confirm to apply.', $counts['total'] )
);
return;
}
$deleted = TMDO_Commentmeta_Cleaner::delete_garbage( $target );
self::render_comment_table( $deleted, 'deleted' );
if ( class_exists( 'TMDO_Logger' ) ) {
TMDO_Logger::info(
'commentmeta_cleanup',
array(
'target' => $target,
'wxr_import' => $deleted['wxr_import'],
'demo_data' => $deleted['demo_data'],
'transients' => $deleted['transients'],
'orphan_post_meta' => $deleted['orphan_post_meta'],
'total' => $deleted['total'],
)
);
}
WP_CLI::success( sprintf( 'Deleted %d row(s) from wp_commentmeta.', $deleted['total'] ) );
}
/**
* Render a table for commentmeta cleanup output.
*
* @param array $counts Output from TMDO_Commentmeta_Cleaner::count_garbage / delete_garbage.
* @param string $verb Column header verb.
*/
private static function render_comment_table( array $counts, string $verb ): void {
$rows = array(
array(
'bucket' => 'wxr_import',
$verb => $counts['wxr_import'],
'rule' => '_wxr_import_*',
),
array(
'bucket' => 'demo_data',
$verb => $counts['demo_data'],
'rule' => '_2meet_demo_*',
),
array(
'bucket' => 'transients',
$verb => $counts['transients'],
'rule' => '_transient_% OR _transient_timeout_%',
),
array(
'bucket' => 'orphan_post_meta',
$verb => $counts['orphan_post_meta'],
'rule' => '_hp_price / _hp_status / _thumbnail_id / etc.',
),
array(
'bucket' => 'TOTAL',
$verb => $counts['total'],
'rule' => '',
),
);
WP_CLI\Utils\format_items( 'table', $rows, array( 'bucket', $verb, 'rule' ) );
}
// ── term-promote-mode (v2.12.5 Phase 5) ───────────────────────────────────
/**
* Promote term entity mode through the FSM safe path.
*
* Wraps TMDO_Mode_Manager::set('term', $new_mode). Mode_Manager enforces
* one-step-at-a-time promotion (disabled → dual_write → shadow_read →
* aeav_only); this CLI provides friendly UX + validation.
*
* ## OPTIONS
*
* <new_mode>
* : Target mode. One of: disabled, dual_write, shadow_read, aeav_only.
*
* ## EXAMPLES
*
* wp wpdo term-promote-mode dual_write
* wp wpdo term-promote-mode shadow_read
* wp wpdo term-promote-mode aeav_only
*
* @param array $args Positional arguments — [new_mode].
* @param array $assoc_args Named arguments (unused).
*/
public function term_promote_mode( $args, $assoc_args ): void {
self::promote_mode( 'term', $args, $assoc_args );
}
// ── comment-promote-mode (v2.12.5 Phase 5) ────────────────────────────────
/**
* Promote comment entity mode through the FSM safe path.
*
* ## OPTIONS
*
* <new_mode>
* : Target mode. One of: disabled, dual_write, shadow_read, aeav_only.
*
* ## EXAMPLES
*
* wp wpdo comment-promote-mode dual_write
* wp wpdo comment-promote-mode shadow_read
*
* @param array $args Positional arguments — [new_mode].
* @param array $assoc_args Named arguments (unused).
*/
public function comment_promote_mode( $args, $assoc_args ): void {
self::promote_mode( 'comment', $args, $assoc_args );
}
/**
* Shared promote-mode implementation for both term and comment.
*
* @param string $entity_type 'term' or 'comment'.
* @param array $args Positional arguments — [new_mode].
* @param array $assoc_args Named arguments (unused).
* @return void
*/
private static function promote_mode( string $entity_type, array $args, array $assoc_args ): void {
unset( $assoc_args );
if ( empty( $args[0] ) ) {
WP_CLI::error( "Missing new_mode argument. Usage: wp wpdo {$entity_type}-promote-mode <mode>" );
}
$new_mode = (string) $args[0];
if ( ! class_exists( 'TMDO_Mode_Manager' ) ) {
WP_CLI::error( 'TMDO_Mode_Manager not loaded.' );
}
$current = TMDO_Mode_Manager::get( $entity_type );
WP_CLI::log( "Current {$entity_type} mode: {$current}" );
WP_CLI::log( "Target {$entity_type} mode: {$new_mode}" );
$result = TMDO_Mode_Manager::set( $entity_type, $new_mode );
if ( true !== $result ) {
$msg = $result instanceof \WP_Error ? $result->get_error_message() : 'Unknown error';
WP_CLI::error( "Mode promotion failed: {$msg}" );
}
WP_CLI::success( "Promoted {$entity_type} mode {$current}{$new_mode}" );
// Hint next step.
$advice = self::next_promotion_hint( $new_mode );
if ( '' !== $advice ) {
WP_CLI::log( '' );
WP_CLI::log( $advice );
}
}
/**
* Suggest the next manual step after a mode promotion.
*
* @param string $new_mode Just-set mode.
* @return string
*/
private static function next_promotion_hint( string $new_mode ): string {
switch ( $new_mode ) {
case 'dual_write':
return 'Next: observe write paths for ≥ 24h, then promote to shadow_read with `<entity>-promote-mode shadow_read`.';
case 'shadow_read':
return 'Next: run `wp wpdo term-comment-shadow-report` periodically; verify drift_total = 0 over 24h, then promote to aeav_only.';
case 'aeav_only':
return 'Next: monitor wp_termmeta / wp_commentmeta row growth — should be 0 for managed keys. Cleanup historical rows with `termmeta-cleanup` / `commentmeta-cleanup`.';
default:
return '';
}
}
// ── term-comment-diagnose (v2.12.5 Phase 5) ───────────────────────────────
/**
* Read-only diagnostic report of term + comment entity bridge state.
*
* Shows current modes, ratio, registered group keys, flat table row counts.
*
* ## EXAMPLES
*
* wp wpdo term-comment-diagnose
*
* @param array $args Positional arguments (unused).
* @param array $assoc_args Named arguments (unused).
*/
public function term_comment_diagnose( $args, $assoc_args ): void {
unset( $args, $assoc_args );
global $wpdb;
$term_mode = class_exists( 'TMDO_Mode_Manager' ) ? TMDO_Mode_Manager::get( 'term' ) : 'unavailable';
$comment_mode = class_exists( 'TMDO_Mode_Manager' ) ? TMDO_Mode_Manager::get( 'comment' ) : 'unavailable';
$terms_count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->terms}" );
$termmeta_count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->termmeta}" );
$comments_count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->comments}" );
$commentmeta_count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->commentmeta}" );
WP_CLI::log( '── Term entity ──' );
WP_CLI::log( sprintf( 'Mode: %s', $term_mode ) );
WP_CLI::log( sprintf( 'wp_terms: %s', number_format_i18n( $terms_count ) ) );
WP_CLI::log( sprintf( 'wp_termmeta: %s (1:%s)', number_format_i18n( $termmeta_count ), $terms_count > 0 ? round( $termmeta_count / $terms_count, 2 ) : 'n/a' ) );
self::print_flat_row( $wpdb->prefix . 'wpdo_term_hp_taxonomy', 'wpdo_term_hp_taxonomy' );
self::print_flat_row( $wpdb->prefix . 'wpdo_term_misc', 'wpdo_term_misc' );
WP_CLI::log( '' );
WP_CLI::log( '── Comment entity ──' );
WP_CLI::log( sprintf( 'Mode: %s', $comment_mode ) );
WP_CLI::log( sprintf( 'wp_comments: %s', number_format_i18n( $comments_count ) ) );
WP_CLI::log( sprintf( 'wp_commentmeta: %s (1:%s)', number_format_i18n( $commentmeta_count ), $comments_count > 0 ? round( $commentmeta_count / $comments_count, 2 ) : 'n/a' ) );
self::print_flat_row( $wpdb->prefix . 'wpdo_comment_hp_review', 'wpdo_comment_hp_review' );
self::print_flat_row( $wpdb->prefix . 'wpdo_comment_misc', 'wpdo_comment_misc' );
WP_CLI::log( '' );
WP_CLI::log( '── Filter chain status ──' );
self::print_filter_status( 'wpdo_term_comment_garbage_filter_enabled', 'Garbage filter (Phase 1)' );
self::print_filter_status( 'wpdo_wc_term_count_filter_enabled', 'WC term count filter (Phase 3)' );
self::print_filter_status( 'wpdo_term_comment_misc_bucket_enabled', 'Misc bucket (Phase 4)' );
WP_CLI::log( '' );
WP_CLI::log( 'Recommendation: ' . self::recommend_next_action( $term_mode, $comment_mode ) );
}
/**
* Recommend next ops action based on current modes.
*
* @param string $term_mode Term entity mode.
* @param string $comment_mode Comment entity mode.
* @return string
*/
private static function recommend_next_action( string $term_mode, string $comment_mode ): string {
if ( 'aeav_only' === $term_mode && 'aeav_only' === $comment_mode ) {
return 'Both entities at aeav_only ✓. Run `termmeta-cleanup` / `commentmeta-cleanup` to drop historical wp_*meta rows; v3.0.0 will DROP the tables.';
}
if ( 'shadow_read' === $term_mode || 'shadow_read' === $comment_mode ) {
return 'Run `term-comment-shadow-report` to verify drift = 0 over 24h before promoting to aeav_only.';
}
if ( 'dual_write' === $term_mode || 'dual_write' === $comment_mode ) {
return 'Promote to shadow_read when ready: `term-promote-mode shadow_read` / `comment-promote-mode shadow_read`.';
}
return 'Both entities at disabled. Promote to dual_write first to enable Hook Bus interception.';
}
/**
* Print a flat table's row count (or "(missing)" when not present).
*
* @param string $table Fully-qualified table name.
* @param string $label Human-readable name.
* @return void
*/
private static function print_flat_row( string $table, string $label ): void {
global $wpdb;
$exists = (bool) $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) );
if ( ! $exists ) {
WP_CLI::log( sprintf( '%-22s%s', $label . ':', '(missing)' ) );
return;
}
$count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}`" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
WP_CLI::log( sprintf( '%-22s%s rows', $label . ':', number_format_i18n( $count ) ) );
}
/**
* Print a filter's enabled/disabled status.
*
* @param string $option_key Option storing the toggle value.
* @param string $label Human-readable name.
* @return void
*/
private static function print_filter_status( string $option_key, string $label ): void {
$enabled = (bool) get_option( $option_key, '1' );
WP_CLI::log( sprintf( '%-32s%s', $label . ':', $enabled ? '✓ enabled' : '✗ disabled' ) );
}
// ── term-comment-shadow-report (v2.12.5 Phase 5) ──────────────────────────
/**
* Run the shadow verifier on demand and print a report.
*
* Uses a configurable sample size (default 100). Each registered term +
* comment entity group is sampled; per-group results are tabulated.
*
* ## OPTIONS
*
* [--samples=<n>]
* : Number of entities to sample per group. Default: 100.
*
* ## EXAMPLES
*
* wp wpdo term-comment-shadow-report
* wp wpdo term-comment-shadow-report --samples=500
*
* @param array $args Positional arguments (unused).
* @param array $assoc_args Named arguments.
*/
public function term_comment_shadow_report( $args, $assoc_args ): void {
unset( $args );
if ( ! class_exists( 'TMDO_Term_Comment_Shadow_Verifier' ) ) {
WP_CLI::error( 'TMDO_Term_Comment_Shadow_Verifier not loaded.' );
}
$samples = isset( $assoc_args['samples'] ) ? max( 1, (int) $assoc_args['samples'] ) : 100;
WP_CLI::log( "Running shadow verifier (sample_size={$samples} per group)…" );
WP_CLI::log( '' );
$results = TMDO_Term_Comment_Shadow_Verifier::run_all( $samples );
if ( empty( $results ) ) {
WP_CLI::warning( 'No groups registered or registry unavailable.' );
return;
}
$rows = array();
foreach ( $results as $group => $r ) {
if ( isset( $r['error'] ) ) {
$rows[] = array(
'group' => $group,
'entity' => '?',
'sampled' => 0,
'matched' => 0,
'diffs' => 0,
'missing_flat' => 0,
'missing_meta' => 0,
'note' => $r['error'],
);
continue;
}
$rows[] = array(
'group' => $r['group'],
'entity' => $r['entity_type'],
'sampled' => $r['sampled'],
'matched' => $r['matched'],
'diffs' => $r['diffs'],
'missing_flat' => $r['missing_flat'],
'missing_meta' => $r['missing_meta'],
'note' => 0 === $r['diffs'] && 0 === $r['missing_flat'] && 0 === $r['missing_meta'] ? '✓ clean' : '⚠ drift',
);
}
WP_CLI\Utils\format_items(
'table',
$rows,
array( 'group', 'entity', 'sampled', 'matched', 'diffs', 'missing_flat', 'missing_meta', 'note' )
);
// Total drift summary.
$total_drift = 0;
foreach ( $results as $r ) {
if ( isset( $r['error'] ) ) {
continue;
}
$total_drift += $r['diffs'] + $r['missing_flat'] + $r['missing_meta'];
}
WP_CLI::log( '' );
if ( 0 === $total_drift ) {
WP_CLI::success( 'Shadow verifier: 0 drift. Safe to promote shadow_read → aeav_only.' );
} else {
WP_CLI::warning( "Shadow verifier: {$total_drift} drift. Investigate before promoting." );
}
}
// ── term-comment-backfill (v2.12.6 Phase 6) ───────────────────────────────
/**
* Backfill historical wp_termmeta / wp_commentmeta into flat tables.
*
* Pivot SQL one-shot per registered group. After v2.12.x activation,
* write-time interception covers new updates — but legacy rows that
* predate v2.12.x are still in wp_*meta only. This command reads them
* and INSERTs into the corresponding flat table (ON DUPLICATE KEY UPDATE
* with COALESCE to preserve any flat-only values).
*
* Safe to re-run: idempotent thanks to ON DUPLICATE KEY UPDATE.
*
* SAFETY: --dry-run / --confirm required (default refuses).
*
* ## OPTIONS
*
* [--group=<group>]
* : Restrict to one group. Default: all.
* ---
* default: all
* options:
* - all
* - hp_taxonomy
* - hp_review
* ---
*
* [--dry-run]
* : Show candidate counts without writing.
*
* [--confirm]
* : Required to actually backfill. Mutually exclusive with --dry-run.
*
* ## EXAMPLES
*
* wp wpdo term-comment-backfill --dry-run
* wp wpdo term-comment-backfill --confirm
* wp wpdo term-comment-backfill --group=hp_taxonomy --confirm
*
* @param array $args Positional arguments (unused).
* @param array $assoc_args Named arguments.
*/
public function term_comment_backfill( $args, $assoc_args ): void {
unset( $args );
$group = isset( $assoc_args['group'] ) ? (string) $assoc_args['group'] : 'all';
$dry_run = isset( $assoc_args['dry-run'] );
$confirm = isset( $assoc_args['confirm'] );
if ( ! class_exists( 'TMDO_Term_Comment_Backfill' ) ) {
WP_CLI::error( 'TMDO_Term_Comment_Backfill not loaded.' );
}
if ( $dry_run && $confirm ) {
WP_CLI::error( '--dry-run and --confirm are mutually exclusive.' );
}
if ( ! $dry_run && ! $confirm ) {
WP_CLI::error(
"Refusing to run without explicit choice. Pass --dry-run or --confirm.\n" .
'Example: wp wpdo term-comment-backfill --dry-run'
);
}
$valid_groups = array( 'all', 'hp_taxonomy', 'hp_review' );
if ( ! in_array( $group, $valid_groups, true ) ) {
WP_CLI::error( 'Invalid --group. Choose: ' . implode( ', ', $valid_groups ) );
}
// Run backfill.
if ( 'all' === $group ) {
$results = TMDO_Term_Comment_Backfill::backfill_all( $dry_run );
} else {
$entity_type = 'hp_review' === $group ? 'comment' : 'term';
$results = array( $group => TMDO_Term_Comment_Backfill::backfill_group( $entity_type, $group, $dry_run ) );
}
// Render table.
$rows = array();
$total_candidates = 0;
$total_written = 0;
foreach ( $results as $r ) {
$rows[] = array(
'group' => $r['group'],
'entity' => $r['entity_type'],
'candidates' => $r['candidates'],
'written' => $r['written'],
'note' => $r['error'] ?? ( $r['dry_run'] ? '[dry-run]' : '✓' ),
);
$total_candidates += (int) $r['candidates'];
$total_written += (int) $r['written'];
}
WP_CLI\Utils\format_items( 'table', $rows, array( 'group', 'entity', 'candidates', 'written', 'note' ) );
if ( class_exists( 'TMDO_Logger' ) && ! $dry_run ) {
TMDO_Logger::info(
'term_comment_backfill',
array(
'group' => $group,
'total_candidates' => $total_candidates,
'total_written' => $total_written,
)
);
}
if ( $dry_run ) {
WP_CLI::success(
sprintf(
'[dry-run] %d candidate row(s) across %d group(s). Re-run with --confirm to apply.',
$total_candidates,
count( $results )
)
);
} else {
WP_CLI::success(
sprintf(
'Backfilled %d row(s) across %d group(s). Run `wp wpdo term-comment-shadow-report` to verify drift = 0.',
$total_written,
count( $results )
)
);
}
}
// ── comment-stress-test (v2.13.2) ─────────────────────────────────────────
/**
* Run / inspect the Comment Stress Tester (CLI equivalent of admin tab).
*
* Wraps TMDO_Comment_Stress_Tester static API. Supports five subcommands:
* status / start / cancel / cleanup / benchmark.
*
* `start` runs synchronously: it loops `run_batch()` until the state machine
* reports `completed` / `failed` / `cancelled` (mirroring how the cron + REST
* polling drives the same state machine). For very large targets, prefer the
* admin tab so the work happens via WP-Cron and you can watch progress.
*
* ## OPTIONS
*
* <subcommand>
* : One of: status, start, cancel, cleanup, benchmark.
*
* [--post-id=<post-id>]
* : Target post ID for `start` (comments will be attached to this post).
*
* [--target=<target>]
* : Number of comments to create for `start`. Default: 100.
*
* [--mode=<mode>]
* : Write mode for `start`. Default: fast.
* ---
* default: fast
* options:
* - fast
* - realistic
* ---
*
* [--batch-size=<n>]
* : Batch size for `start`. Default: 200 (fast) / 20 (realistic recommended).
*
* [--yes]
* : Skip cleanup confirmation prompt.
*
* ## EXAMPLES
*
* wp wpdo comment-stress-test status
* wp wpdo comment-stress-test start --post-id=50 --target=100 --mode=fast
* wp wpdo comment-stress-test start --post-id=50 --target=5 --mode=realistic --batch-size=5
* wp wpdo comment-stress-test cancel
* wp wpdo comment-stress-test cleanup --yes
* wp wpdo comment-stress-test benchmark
*
* @param array $args Positional arguments — [subcommand].
* @param array $assoc_args Named arguments.
*/
public function comment_stress_test( $args, $assoc_args ): void {
if ( ! class_exists( 'TMDO_Comment_Stress_Tester' ) ) {
WP_CLI::error( 'TMDO_Comment_Stress_Tester not loaded.' );
}
if ( empty( $args[0] ) ) {
WP_CLI::error( 'Missing <subcommand>. One of: status, start, cancel, cleanup, benchmark.' );
}
$sub = strtolower( (string) $args[0] );
switch ( $sub ) {
case 'status':
self::cst_status();
return;
case 'start':
self::cst_start( $assoc_args );
return;
case 'cancel':
self::cst_cancel();
return;
case 'cleanup':
self::cst_cleanup( $assoc_args );
return;
case 'benchmark':
self::cst_benchmark();
return;
default:
WP_CLI::error( "Unknown subcommand '{$sub}'. One of: status, start, cancel, cleanup, benchmark." );
}
}
/**
* Print current state machine snapshot.
*
* @return void
*/
private static function cst_status(): void {
$progress = TMDO_Comment_Stress_Tester::get_progress( false );
$rows = array(
array(
'field' => 'status',
'value' => (string) ( $progress['status'] ?? 'idle' ),
),
array(
'field' => 'post_id',
'value' => (string) ( $progress['post_id'] ?? '—' ),
),
array(
'field' => 'mode',
'value' => (string) ( $progress['mode'] ?? '—' ),
),
array(
'field' => 'processed',
'value' => (string) ( $progress['processed'] ?? 0 ),
),
array(
'field' => 'target',
'value' => (string) ( $progress['target'] ?? 0 ),
),
array(
'field' => 'pct',
'value' => ( (string) ( $progress['pct'] ?? 0 ) ) . '%',
),
array(
'field' => 'rate_per_sec',
'value' => (string) ( $progress['rate_per_sec'] ?? 0 ),
),
array(
'field' => 'elapsed_sec',
'value' => (string) ( $progress['elapsed_sec'] ?? 0 ),
),
array(
'field' => 'eta_sec',
'value' => (string) ( $progress['eta_sec'] ?? 0 ),
),
array(
'field' => 'batches_done',
'value' => (string) ( $progress['batches_done'] ?? 0 ),
),
array(
'field' => 'test_comment_count',
'value' => (string) ( $progress['test_comment_count'] ?? 0 ),
),
);
WP_CLI\Utils\format_items( 'table', $rows, array( 'field', 'value' ) );
}
/**
* Start a stress test run synchronously (loops run_batch until terminal).
*
* @param array $assoc_args CLI args (post-id / target / mode / batch-size).
* @return void
*/
private static function cst_start( array $assoc_args ): void {
$post_id = (int) ( $assoc_args['post-id'] ?? 0 );
$target = (int) ( $assoc_args['target'] ?? 100 );
$mode = (string) ( $assoc_args['mode'] ?? 'fast' );
$batch = isset( $assoc_args['batch-size'] )
? (int) $assoc_args['batch-size']
: ( 'realistic' === $mode ? 20 : TMDO_Comment_Stress_Tester::DEFAULT_BATCH_SIZE );
if ( $post_id < 1 ) {
WP_CLI::error( 'Missing or invalid --post-id (must be >= 1).' );
}
if ( $target < 1 || $target > TMDO_Comment_Stress_Tester::MAX_COUNT ) {
WP_CLI::error( '--target must be between 1 and ' . TMDO_Comment_Stress_Tester::MAX_COUNT . '.' );
}
$result = TMDO_Comment_Stress_Tester::start( $post_id, $target, $mode, $batch );
if ( empty( $result['ok'] ) ) {
WP_CLI::error( 'Failed to start: ' . ( $result['error'] ?? 'unknown' ) );
}
WP_CLI::log( "Started: post_id={$post_id} target={$target} mode={$mode} batch_size={$batch}" );
// Drain the state machine synchronously (CLI equivalent of cron pump).
$max_loops = 5000; // Safety cap to avoid runaway loop on bad state.
$loops = 0;
do {
TMDO_Comment_Stress_Tester::run_batch();
$progress = TMDO_Comment_Stress_Tester::get_progress( false );
$status = (string) ( $progress['status'] ?? 'idle' );
if ( in_array( $status, array( 'completed', 'failed', 'cancelled' ), true ) ) {
break;
}
WP_CLI::log(
sprintf(
' batch %d done / processed %d / %d (%s%%) — %s',
(int) ( $progress['batches_done'] ?? 0 ),
(int) ( $progress['processed'] ?? 0 ),
(int) ( $progress['target'] ?? 0 ),
(string) ( $progress['pct'] ?? 0 ),
$status
)
);
++$loops;
} while ( $loops < $max_loops );
$final = TMDO_Comment_Stress_Tester::get_progress( false );
WP_CLI::success(
sprintf(
'Run %s — created %d / %d comment(s) in %d batch(es), %ss elapsed.',
(string) ( $final['status'] ?? 'unknown' ),
(int) ( $final['processed'] ?? 0 ),
(int) ( $final['target'] ?? 0 ),
(int) ( $final['batches_done'] ?? 0 ),
(string) ( $final['elapsed_sec'] ?? 0 )
)
);
}
/**
* Cancel an in-flight run.
*
* @return void
*/
private static function cst_cancel(): void {
$result = TMDO_Comment_Stress_Tester::cancel();
if ( empty( $result['ok'] ) ) {
WP_CLI::error( 'Cancel failed: ' . ( $result['error'] ?? 'unknown' ) );
}
if ( isset( $result['message'] ) && 'no_active_job' === $result['message'] ) {
WP_CLI::warning( 'No active job to cancel.' );
return;
}
WP_CLI::success( 'Cancellation flagged. The next batch tick will mark state as cancelled.' );
}
/**
* Delete all stress-test comments + cascade.
*
* @param array $assoc_args CLI args (--yes to skip prompt).
* @return void
*/
private static function cst_cleanup( array $assoc_args ): void {
$count = TMDO_Comment_Stress_Tester::count_test_comments();
if ( 0 === $count ) {
WP_CLI::success( 'No stress-test comments to clean up.' );
return;
}
if ( empty( $assoc_args['yes'] ) ) {
WP_CLI::confirm(
sprintf(
'About to DELETE %d test comment(s) (email LIKE %%@%s) and their wp_commentmeta + flat-table rows. Continue?',
$count,
TMDO_Comment_Stress_Tester::TEST_EMAIL_DOMAIN
)
);
}
$result = TMDO_Comment_Stress_Tester::cleanup();
delete_option( TMDO_Comment_Stress_Tester::OPT_STATE );
delete_transient( TMDO_Comment_Stress_Tester::CANCEL_FLAG );
WP_CLI::success(
sprintf(
'Deleted %d comment(s), %d commentmeta row(s), %d flat row(s).',
(int) ( $result['deleted_comments'] ?? 0 ),
(int) ( $result['deleted_meta'] ?? 0 ),
(int) ( $result['deleted_flat'] ?? 0 )
)
);
}
/**
* Run benchmark report (write metrics + DB sizes + query perf).
*
* @return void
*/
private static function cst_benchmark(): void {
$state = TMDO_Comment_Stress_Tester::get_state();
$report = TMDO_Comment_Stress_Tester::run_benchmark( $state ?: null );
$query = $report['query'] ?? array();
$db_sizes = $report['db_sizes'] ?? array();
WP_CLI::log( '▍ Query performance' );
$rows = array();
foreach ( $query as $key => $v ) {
$rows[] = array(
'probe' => (string) $key,
'duration_ms' => (string) ( $v['duration_ms'] ?? '—' ),
);
}
WP_CLI\Utils\format_items( 'table', $rows, array( 'probe', 'duration_ms' ) );
WP_CLI::log( '▍ DB sizes (comment-related)' );
$db_rows = array();
foreach ( $db_sizes as $r ) {
$db_rows[] = array(
'table' => (string) ( $r['table'] ?? '' ),
'rows' => (string) ( $r['rows'] ?? 0 ),
'data_mb' => (string) ( $r['data_mb'] ?? '—' ),
'index_mb' => (string) ( $r['index_mb'] ?? '—' ),
'total_mb' => (string) ( $r['total_mb'] ?? '—' ),
);
}
WP_CLI\Utils\format_items( 'table', $db_rows, array( 'table', 'rows', 'data_mb', 'index_mb', 'total_mb' ) );
WP_CLI::success( 'Benchmark complete.' );
}
}
// ── Register subcommands ──────────────────────────────────────────────────────
// (Originally class closed here in v2.12.0; v2.12.5+ adds new methods inside.)
WP_CLI::add_command( 'wpdo termmeta-cleanup', array( 'TMDO_CLI_Term_Comment', 'termmeta_cleanup' ) );
WP_CLI::add_command( 'tmdo termmeta-cleanup', array( 'TMDO_CLI_Term_Comment', 'termmeta_cleanup' ) );
WP_CLI::add_command( 'wpdo commentmeta-cleanup', array( 'TMDO_CLI_Term_Comment', 'commentmeta_cleanup' ) );
WP_CLI::add_command( 'tmdo commentmeta-cleanup', array( 'TMDO_CLI_Term_Comment', 'commentmeta_cleanup' ) );
WP_CLI::add_command( 'wpdo term-promote-mode', array( 'TMDO_CLI_Term_Comment', 'term_promote_mode' ) );
WP_CLI::add_command( 'tmdo term-promote-mode', array( 'TMDO_CLI_Term_Comment', 'term_promote_mode' ) );
WP_CLI::add_command( 'wpdo comment-promote-mode', array( 'TMDO_CLI_Term_Comment', 'comment_promote_mode' ) );
WP_CLI::add_command( 'tmdo comment-promote-mode', array( 'TMDO_CLI_Term_Comment', 'comment_promote_mode' ) );
WP_CLI::add_command( 'wpdo term-comment-diagnose', array( 'TMDO_CLI_Term_Comment', 'term_comment_diagnose' ) );
WP_CLI::add_command( 'tmdo term-comment-diagnose', array( 'TMDO_CLI_Term_Comment', 'term_comment_diagnose' ) );
WP_CLI::add_command( 'wpdo term-comment-shadow-report', array( 'TMDO_CLI_Term_Comment', 'term_comment_shadow_report' ) );
WP_CLI::add_command( 'tmdo term-comment-shadow-report', array( 'TMDO_CLI_Term_Comment', 'term_comment_shadow_report' ) );
WP_CLI::add_command( 'wpdo term-comment-backfill', array( 'TMDO_CLI_Term_Comment', 'term_comment_backfill' ) );
WP_CLI::add_command( 'tmdo term-comment-backfill', array( 'TMDO_CLI_Term_Comment', 'term_comment_backfill' ) );
WP_CLI::add_command( 'wpdo comment-stress-test', array( 'TMDO_CLI_Term_Comment', 'comment_stress_test' ) );
WP_CLI::add_command( 'tmdo comment-stress-test', array( 'TMDO_CLI_Term_Comment', 'comment_stress_test' ) );