CodeIssuesPull RequestsActionsSecurityInsights
✨ AI
More
Settings

Add pre-update simulation, reporting, and export features #3832

Merged⚡ AI-generatedXSccantynz wants to mergeclaude/check-build-status-7CWRzmainopened Apr 16, 2026
6 changed files+1471−0
Modifiedassets/js/admin-dashboard.js+86−0View fileUnifiedSplit
3737 $(document).on('click', '#jetstrike-export-data', this.exportData.bind(this));
3838 $(document).on('click', '#jetstrike-toggle-matrix', this.toggleMatrix.bind(this));
3939
40 // Health check buttons.
41 $(document).on('click', '.jetstrike-cd-health-btn', this.runHealthCheck.bind(this));
42
4043 // Import data.
4144 $(document).on('click', '#jetstrike-import-data', this.importData.bind(this));
4245 $(document).on('change', '#jetstrike-import-file', this.handleImportFile.bind(this));
567570 }
568571 },
569572
573 // ── Health Checks ─────────────────────────────────────
574
575 runHealthCheck: function (e) {
576 e.preventDefault();
577 var $btn = $(e.currentTarget);
578 var checkType = $btn.data('check');
579 var actionMap = {
580 plugin_health: 'jetstrike_cd_plugin_health',
581 db_health: 'jetstrike_cd_db_health',
582 php_compat: 'jetstrike_cd_php_compat'
583 };
584 var labelMap = {
585 plugin_health: 'Plugin Health',
586 db_health: 'Database Health',
587 php_compat: 'PHP Compatibility'
588 };
589
590 var action = actionMap[checkType];
591 if (!action) return;
592
593 $btn.prop('disabled', true).html('<span class="jetstrike-cd-spinner"></span> Analyzing...');
594
595 $.ajax({
596 url: jetstrikeCD.ajaxUrl,
597 type: 'POST',
598 data: {
599 action: action,
600 nonce: jetstrikeCD.ajaxNonce
601 },
602 success: function (response) {
603 $btn.prop('disabled', false).html($btn.html().replace('Analyzing...', labelMap[checkType]));
604 JetstrikeCD.resetHealthButton($btn, checkType, labelMap[checkType]);
605
606 if (response.success) {
607 var data = response.data;
608 var msg = '';
609
610 if (checkType === 'plugin_health') {
611 msg = 'Plugin Health: ' + (data.summary.healthy || 0) + ' healthy, ' +
612 (data.summary.abandoned || 0) + ' abandoned, ' +
613 (data.summary.stale || 0) + ' stale, ' +
614 (data.summary.vulnerable || 0) + ' vulnerable. ' +
615 (data.issues ? data.issues.length : 0) + ' total issue(s).';
616 } else if (checkType === 'db_health') {
617 msg = 'Database Health Score: ' + (data.score || 0) + '/100. ' +
618 (data.issues ? data.issues.length : 0) + ' issue(s) found. ' +
619 'Total DB size: ' + (data.stats.total_db_mb || 0) + 'MB.';
620 } else if (checkType === 'php_compat') {
621 msg = 'PHP Compatibility (PHP ' + (data.target_php || '') + '): ' +
622 (data.summary.clean || 0) + ' compatible, ' +
623 (data.summary.warnings || 0) + ' with warnings, ' +
624 (data.summary.errors || 0) + ' incompatible.';
625 }
626
627 var severity = 'success';
628 if ((data.summary && (data.summary.errors > 0 || data.summary.abandoned > 0 || data.summary.vulnerable > 0)) ||
629 (data.score !== undefined && data.score < 50)) {
630 severity = 'warning';
631 }
632
633 JetstrikeCD.showNotice(severity, msg);
634 } else {
635 JetstrikeCD.showNotice('error', response.data.message || 'Analysis failed.');
636 }
637 },
638 error: function () {
639 JetstrikeCD.resetHealthButton($btn, checkType, labelMap[checkType]);
640 JetstrikeCD.showNotice('error', labelMap[checkType] + ' analysis failed.');
641 }
642 });
643 },
644
645 resetHealthButton: function ($btn, checkType, label) {
646 var iconMap = {
647 plugin_health: 'plugins-checked',
648 db_health: 'database',
649 php_compat: 'editor-code'
650 };
651 $btn.prop('disabled', false).html(
652 '<span class="dashicons dashicons-' + (iconMap[checkType] || 'admin-generic') + '"></span> ' + label
653 );
654 },
655
570656 // ── Import Data ──────────────────────────────────────
571657
572658 importData: function (e) {
Modifiedincludes/Admin/AdminAjax.php+52−0View fileUnifiedSplit
4444 add_action('wp_ajax_jetstrike_cd_activate_license', [$this, 'activate_license']);
4545 add_action('wp_ajax_jetstrike_cd_deactivate_license', [$this, 'deactivate_license']);
4646 add_action('wp_ajax_jetstrike_cd_ai_explain', [$this, 'ai_explain_conflict']);
47 add_action('wp_ajax_jetstrike_cd_plugin_health', [$this, 'plugin_health_check']);
48 add_action('wp_ajax_jetstrike_cd_db_health', [$this, 'database_health_check']);
49 add_action('wp_ajax_jetstrike_cd_php_compat', [$this, 'php_compat_check']);
4750 }
4851
4952 /**
411414 wp_send_json_success($result);
412415 }
413416
417 /**
418 * Run plugin health analysis.
419 */
420 public function plugin_health_check(): void {
421 $this->verify_request();
422
423 if (! FeatureFlags::can('pro_features')) {
424 wp_send_json_error(['message' => __('Plugin Health Check requires a Pro or Agency license.', 'jetstrike-cd')]);
425 }
426
427 $analyzer = new \Jetstrike\ConflictDetector\Analyzer\PluginHealthAnalyzer();
428 $results = $analyzer->analyze();
429
430 wp_send_json_success($results);
431 }
432
433 /**
434 * Run database health analysis.
435 */
436 public function database_health_check(): void {
437 $this->verify_request();
438
439 if (! FeatureFlags::can('pro_features')) {
440 wp_send_json_error(['message' => __('Database Health Check requires a Pro or Agency license.', 'jetstrike-cd')]);
441 }
442
443 $analyzer = new \Jetstrike\ConflictDetector\Analyzer\DatabaseHealthAnalyzer();
444 $results = $analyzer->analyze();
445
446 wp_send_json_success($results);
447 }
448
449 /**
450 * Run PHP compatibility analysis.
451 */
452 public function php_compat_check(): void {
453 $this->verify_request();
454
455 if (! FeatureFlags::can('pro_features')) {
456 wp_send_json_error(['message' => __('PHP Compatibility Check requires a Pro or Agency license.', 'jetstrike-cd')]);
457 }
458
459 $target_php = sanitize_text_field($_POST['target_php'] ?? '');
460 $analyzer = new \Jetstrike\ConflictDetector\Analyzer\PHPCompatAnalyzer();
461 $results = $analyzer->analyze($target_php ?: null);
462
463 wp_send_json_success($results);
464 }
465
414466 /**
415467 * Verify AJAX request (nonce + capability).
416468 */
Addedincludes/Analyzer/DatabaseHealthAnalyzer.php+460−0View fileUnifiedSplit
1<?php
2/**
3 * Database Health Analyzer — finds bloat, orphaned data, and performance killers.
4 *
5 * WooCommerce stores accumulate massive database bloat over time:
6 * - Autoloaded options that slow every single page load
7 * - Post revisions consuming gigabytes
8 * - Orphaned postmeta from deleted products/orders
9 * - Transients that never expire
10 * - Orphaned term relationships
11 * - Uninstalled plugin data left behind
12 *
13 * This analyzer identifies these issues and quantifies their performance impact.
14 *
15 * @package Jetstrike\ConflictDetector
16 */
17
18declare(strict_types=1);
19
20namespace Jetstrike\ConflictDetector\Analyzer;
21
22final class DatabaseHealthAnalyzer {
23
24 /**
25 * Run a full database health analysis.
26 *
27 * @return array{
28 * score: int,
29 * issues: array,
30 * stats: array,
31 * recommendations: array
32 * }
33 */
34 public function analyze(): array {
35 global $wpdb;
36
37 $issues = [];
38 $score = 100;
39 $stats = [];
40
41 // 1. Autoload bloat analysis.
42 $autoload = $this->analyze_autoload($wpdb);
43 $stats['autoload'] = $autoload;
44 if ($autoload['total_size_bytes'] > 1048576) { // > 1MB
45 $size_mb = round($autoload['total_size_bytes'] / 1048576, 1);
46 $issues[] = [
47 'type' => 'autoload_bloat',
48 'severity' => $size_mb > 5 ? 'critical' : ($size_mb > 2 ? 'high' : 'medium'),
49 'message' => sprintf(
50 'Your autoloaded options total %sMB. WordPress loads ALL of this data on every single ' .
51 'page load — for every visitor, every admin page, every AJAX request. This adds %dms ' .
52 'to every request. The top offenders are: %s.',
53 $size_mb,
54 (int) ($size_mb * 200),
55 implode(', ', array_slice(
56 array_map(function ($row) {
57 return $row['name'] . ' (' . $this->format_bytes((int) $row['size']) . ')';
58 }, $autoload['top_offenders']),
59 0, 5
60 ))
61 ),
62 'data' => $autoload,
63 'savings' => sprintf('Fixing this could save %dms on every page load.', (int) ($size_mb * 150)),
64 ];
65 $score -= $size_mb > 5 ? 30 : ($size_mb > 2 ? 20 : 10);
66 }
67
68 // 2. Post revision bloat.
69 $revisions = $this->count_revisions($wpdb);
70 $stats['revisions'] = $revisions;
71 if ($revisions['count'] > 1000) {
72 $issues[] = [
73 'type' => 'revision_bloat',
74 'severity' => $revisions['count'] > 10000 ? 'high' : 'medium',
75 'message' => sprintf(
76 'Your database contains %s post revisions consuming approximately %s. ' .
77 'WordPress stores every saved version of every post and page by default. ' .
78 'Most of these are unnecessary and slow down database queries.',
79 number_format($revisions['count']),
80 $this->format_bytes($revisions['estimated_bytes'])
81 ),
82 'data' => $revisions,
83 'savings' => 'Cleaning revisions could reclaim ' . $this->format_bytes($revisions['estimated_bytes']) . ' of database space.',
84 ];
85 $score -= $revisions['count'] > 10000 ? 15 : 8;
86 }
87
88 // 3. Transient bloat.
89 $transients = $this->count_transients($wpdb);
90 $stats['transients'] = $transients;
91 if ($transients['expired'] > 500) {
92 $issues[] = [
93 'type' => 'transient_bloat',
94 'severity' => 'medium',
95 'message' => sprintf(
96 'Your database contains %s expired transients that should have been cleaned up. ' .
97 'These are temporary cache entries that were never deleted. ' .
98 'They add unnecessary rows to your options table and slow queries.',
99 number_format($transients['expired'])
100 ),
101 'data' => $transients,
102 ];
103 $score -= 8;
104 }
105
106 // 4. Orphaned postmeta.
107 $orphaned_meta = $this->count_orphaned_postmeta($wpdb);
108 $stats['orphaned_postmeta'] = $orphaned_meta;
109 if ($orphaned_meta > 5000) {
110 $issues[] = [
111 'type' => 'orphaned_postmeta',
112 'severity' => $orphaned_meta > 50000 ? 'high' : 'medium',
113 'message' => sprintf(
114 'Found %s orphaned postmeta rows — metadata entries for posts/products that no longer exist. ' .
115 'This is common after bulk-deleting WooCommerce orders or products. ' .
116 'These rows slow down every database query that touches postmeta.',
117 number_format($orphaned_meta)
118 ),
119 'count' => $orphaned_meta,
120 ];
121 $score -= $orphaned_meta > 50000 ? 15 : 8;
122 }
123
124 // 5. Orphaned term relationships.
125 $orphaned_terms = $this->count_orphaned_term_relationships($wpdb);
126 $stats['orphaned_term_relationships'] = $orphaned_terms;
127 if ($orphaned_terms > 1000) {
128 $issues[] = [
129 'type' => 'orphaned_terms',
130 'severity' => 'low',
131 'message' => sprintf(
132 'Found %s orphaned term relationships — category/tag assignments for content that ' .
133 'no longer exists.',
134 number_format($orphaned_terms)
135 ),
136 'count' => $orphaned_terms,
137 ];
138 $score -= 5;
139 }
140
141 // 6. Large options table.
142 $options_count = $this->count_options($wpdb);
143 $stats['options_count'] = $options_count;
144 if ($options_count > 5000) {
145 $issues[] = [
146 'type' => 'options_bloat',
147 'severity' => $options_count > 20000 ? 'high' : 'medium',
148 'message' => sprintf(
149 'Your wp_options table has %s rows. A healthy WordPress site typically has 200-500. ' .
150 'Plugins that store per-post or per-user data in options (instead of proper tables) ' .
151 'cause this bloat. It slows down every options query.',
152 number_format($options_count)
153 ),
154 'count' => $options_count,
155 ];
156 $score -= $options_count > 20000 ? 15 : 8;
157 }
158
159 // 7. Table size overview.
160 $table_sizes = $this->get_table_sizes($wpdb);
161 $stats['table_sizes'] = $table_sizes;
162 $total_db_mb = array_sum(array_column($table_sizes, 'size_mb'));
163 $stats['total_db_mb'] = round($total_db_mb, 1);
164
165 if ($total_db_mb > 500) {
166 $issues[] = [
167 'type' => 'large_database',
168 'severity' => $total_db_mb > 2000 ? 'high' : 'medium',
169 'message' => sprintf(
170 'Your total database size is %sMB. Large databases slow down backups, ' .
171 'migrations, and complex queries. The largest tables are: %s.',
172 number_format($total_db_mb, 0),
173 implode(', ', array_slice(
174 array_map(function ($t) {
175 return $t['table'] . ' (' . $t['size_mb'] . 'MB, ' .
176 number_format($t['rows']) . ' rows)';
177 }, $table_sizes),
178 0, 3
179 ))
180 ),
181 'total_mb' => $total_db_mb,
182 ];
183 $score -= $total_db_mb > 2000 ? 15 : 8;
184 }
185
186 // 8. Missing indexes on common query patterns.
187 $missing_indexes = $this->check_missing_indexes($wpdb);
188 $stats['missing_indexes'] = $missing_indexes;
189 if (! empty($missing_indexes)) {
190 $issues[] = [
191 'type' => 'missing_indexes',
192 'severity' => 'medium',
193 'message' => sprintf(
194 'Found %d table(s) with potentially missing indexes: %s. ' .
195 'Missing indexes force the database to scan entire tables for queries, ' .
196 'which gets dramatically slower as data grows.',
197 count($missing_indexes),
198 implode(', ', array_column($missing_indexes, 'table'))
199 ),
200 'indexes' => $missing_indexes,
201 ];
202 $score -= 10;
203 }
204
205 // Build recommendations.
206 $recommendations = $this->build_recommendations($issues, $stats);
207
208 return [
209 'score' => max(0, $score),
210 'issues' => $issues,
211 'stats' => $stats,
212 'recommendations' => $recommendations,
213 ];
214 }
215
216 /**
217 * Analyze autoloaded options.
218 */
219 private function analyze_autoload(object $wpdb): array {
220 $results = $wpdb->get_results(
221 "SELECT option_name, LENGTH(option_value) AS size
222 FROM {$wpdb->options}
223 WHERE autoload = 'yes'
224 ORDER BY LENGTH(option_value) DESC
225 LIMIT 50"
226 );
227
228 $total_row = $wpdb->get_row(
229 "SELECT COUNT(*) AS cnt, SUM(LENGTH(option_value)) AS total_size
230 FROM {$wpdb->options}
231 WHERE autoload = 'yes'"
232 );
233
234 $top_offenders = [];
235 foreach (array_slice($results ?: [], 0, 20) as $row) {
236 $top_offenders[] = [
237 'name' => $row->option_name,
238 'size' => (int) $row->size,
239 ];
240 }
241
242 return [
243 'count' => (int) ($total_row->cnt ?? 0),
244 'total_size_bytes' => (int) ($total_row->total_size ?? 0),
245 'top_offenders' => $top_offenders,
246 ];
247 }
248
249 /**
250 * Count post revisions.
251 */
252 private function count_revisions(object $wpdb): array {
253 $result = $wpdb->get_row(
254 "SELECT COUNT(*) AS cnt FROM {$wpdb->posts} WHERE post_type = 'revision'"
255 );
256
257 $count = (int) ($result->cnt ?? 0);
258
259 return [
260 'count' => $count,
261 'estimated_bytes' => $count * 5000,
262 ];
263 }
264
265 /**
266 * Count transients (total and expired).
267 */
268 private function count_transients(object $wpdb): array {
269 $total = (int) $wpdb->get_var(
270 "SELECT COUNT(*) FROM {$wpdb->options}
271 WHERE option_name LIKE '_transient_%'
272 AND option_name NOT LIKE '_transient_timeout_%'"
273 );
274
275 $expired = (int) $wpdb->get_var(
276 "SELECT COUNT(*) FROM {$wpdb->options} a
277 JOIN {$wpdb->options} b ON b.option_name = CONCAT('_transient_timeout_', SUBSTRING(a.option_name, 12))
278 WHERE a.option_name LIKE '_transient_%'
279 AND a.option_name NOT LIKE '_transient_timeout_%'
280 AND b.option_value < UNIX_TIMESTAMP()"
281 );
282
283 return [
284 'total' => $total,
285 'expired' => $expired,
286 ];
287 }
288
289 /**
290 * Count orphaned postmeta rows.
291 */
292 private function count_orphaned_postmeta(object $wpdb): int {
293 return (int) $wpdb->get_var(
294 "SELECT COUNT(*) FROM {$wpdb->postmeta} pm
295 LEFT JOIN {$wpdb->posts} p ON pm.post_id = p.ID
296 WHERE p.ID IS NULL"
297 );
298 }
299
300 /**
301 * Count orphaned term relationships.
302 */
303 private function count_orphaned_term_relationships(object $wpdb): int {
304 return (int) $wpdb->get_var(
305 "SELECT COUNT(*) FROM {$wpdb->term_relationships} tr
306 LEFT JOIN {$wpdb->posts} p ON tr.object_id = p.ID
307 WHERE p.ID IS NULL"
308 );
309 }
310
311 /**
312 * Count total options.
313 */
314 private function count_options(object $wpdb): int {
315 return (int) $wpdb->get_var("SELECT COUNT(*) FROM {$wpdb->options}");
316 }
317
318 /**
319 * Get table sizes sorted by size descending.
320 */
321 private function get_table_sizes(object $wpdb): array {
322 $tables = $wpdb->get_results(
323 $wpdb->prepare(
324 "SELECT table_name AS 'table',
325 ROUND((data_length + index_length) / 1048576, 1) AS size_mb,
326 table_rows AS 'rows'
327 FROM information_schema.TABLES
328 WHERE table_schema = %s
329 ORDER BY (data_length + index_length) DESC
330 LIMIT 20",
331 DB_NAME
332 )
333 );
334
335 $result = [];
336 foreach ($tables ?: [] as $t) {
337 $result[] = [
338 'table' => $t->table,
339 'size_mb' => (float) $t->size_mb,
340 'rows' => (int) $t->rows,
341 ];
342 }
343
344 return $result;
345 }
346
347 /**
348 * Check for missing indexes on common query patterns.
349 */
350 private function check_missing_indexes(object $wpdb): array {
351 $missing = [];
352
353 $postmeta_indexes = $wpdb->get_results(
354 "SHOW INDEX FROM {$wpdb->postmeta} WHERE Key_name != 'PRIMARY'"
355 );
356
357 $has_meta_value_index = false;
358 foreach ($postmeta_indexes ?: [] as $idx) {
359 if ($idx->Column_name === 'meta_value') {
360 $has_meta_value_index = true;
361 }
362 }
363
364 $postmeta_rows = (int) $wpdb->get_var("SELECT COUNT(*) FROM {$wpdb->postmeta}");
365 if (! $has_meta_value_index && $postmeta_rows > 100000) {
366 $missing[] = [
367 'table' => $wpdb->postmeta,
368 'column' => 'meta_value',
369 'reason' => 'Large postmeta table without meta_value index — queries filtering by meta_value will be slow.',
370 'rows' => $postmeta_rows,
371 ];
372 }
373
374 return $missing;
375 }
376
377 /**
378 * Build actionable recommendations based on issues found.
379 */
380 private function build_recommendations(array $issues, array $stats): array {
381 $recs = [];
382
383 foreach ($issues as $issue) {
384 switch ($issue['type']) {
385 case 'autoload_bloat':
386 $recs[] = [
387 'priority' => 1,
388 'action' => 'Audit autoloaded options and set large, infrequently-accessed options to autoload=no. ' .
389 'Focus on the top offenders first. Consider using an object cache (Redis/Memcached) for persistent caching.',
390 'impact' => 'Could reduce page load time by ' . (isset($issue['data']['total_size_bytes'])
391 ? (int) ($issue['data']['total_size_bytes'] / 1048576 * 150) . 'ms'
392 : '200ms+') . ' on every request.',
393 ];
394 break;
395
396 case 'revision_bloat':
397 $recs[] = [
398 'priority' => 2,
399 'action' => 'Delete old post revisions and limit future revisions by adding ' .
400 'define(\'WP_POST_REVISIONS\', 5) to wp-config.php.',
401 'impact' => 'Reclaim ' . $this->format_bytes($stats['revisions']['estimated_bytes'] ?? 0) .
402 ' of database space and speed up post queries.',
403 ];
404 break;
405
406 case 'orphaned_postmeta':
407 $recs[] = [
408 'priority' => 2,
409 'action' => 'Clean up orphaned postmeta entries. These are leftovers from deleted posts, ' .
410 'products, or orders. Use a database cleanup plugin or run a targeted DELETE query.',
411 'impact' => 'Speeds up all queries involving post metadata, which includes most WooCommerce operations.',
412 ];
413 break;
414
415 case 'transient_bloat':
416 $recs[] = [
417 'priority' => 3,
418 'action' => 'Delete expired transients. If you have an object cache (Redis/Memcached), ' .
419 'transients are stored there instead and this issue resolves itself.',
420 'impact' => 'Reduces options table size and improves autoload performance.',
421 ];
422 break;
423
424 case 'large_database':
425 $recs[] = [
426 'priority' => 3,
427 'action' => 'Review the largest tables and consider archiving old data. For WooCommerce, ' .
428 'old completed orders older than 2 years can often be exported and archived.',
429 'impact' => 'Faster backups, faster migrations, and improved query performance across the board.',
430 ];
431 break;
432 }
433 }
434
435 usort($recs, function ($a, $b) {
436 return $a['priority'] - $b['priority'];
437 });
438
439 return $recs;
440 }
441
442 /**
443 * Format bytes into human-readable string.
444 */
445 private function format_bytes(int $bytes): string {
446 if ($bytes >= 1073741824) {
447 return round($bytes / 1073741824, 1) . 'GB';
448 }
449
450 if ($bytes >= 1048576) {
451 return round($bytes / 1048576, 1) . 'MB';
452 }
453
454 if ($bytes >= 1024) {
455 return round($bytes / 1024, 1) . 'KB';
456 }
457
458 return $bytes . ' bytes';
459 }
460}
Addedincludes/Analyzer/PHPCompatAnalyzer.php+352−0View fileUnifiedSplit
1<?php
2/**
3 * PHP Compatibility Analyzer — detects code that will break on newer PHP versions.
4 *
5 * The original PHP Compatibility Checker plugin was abandoned in 2022.
6 * Hosts are actively upgrading to PHP 8.1, 8.2, and 8.3 — and plugins
7 * break silently. This analyzer fills that gap.
8 *
9 * Scans plugin PHP files for:
10 * - Functions removed in PHP 8.0+ (mysql_*, create_function, each)
11 * - Changed function signatures (money_format, mbstring changes)
12 * - Deprecated features (implicit float-to-int, ${} string interpolation)
13 * - Strict type incompatibilities
14 *
15 * @package Jetstrike\ConflictDetector
16 */
17
18declare(strict_types=1);
19
20namespace Jetstrike\ConflictDetector\Analyzer;
21
22final class PHPCompatAnalyzer {
23
24 /**
25 * Functions removed in specific PHP versions.
26 * Format: function_name => [removed_in, replacement, risk_description]
27 */
28 private const REMOVED_FUNCTIONS = [
29 // Removed in PHP 8.0
30 'create_function' => ['8.0', 'anonymous functions (closures)', 'Causes fatal error on PHP 8.0+'],
31 'each' => ['8.0', 'foreach loops', 'Causes fatal error on PHP 8.0+'],
32 'money_format' => ['8.0', 'NumberFormatter', 'Causes fatal error on PHP 8.0+'],
33 'restore_include_path' => ['8.0', 'ini_restore(\'include_path\')', 'Causes fatal error on PHP 8.0+'],
34 'get_magic_quotes_gpc' => ['8.0', 'remove the call (magic quotes no longer exist)', 'Causes fatal error on PHP 8.0+'],
35 'get_magic_quotes_runtime' => ['8.0', 'remove the call', 'Causes fatal error on PHP 8.0+'],
36 'hebrevc' => ['8.0', 'not needed', 'Causes fatal error on PHP 8.0+'],
37 'convert_cyr_string' => ['8.0', 'mb_convert_encoding or iconv', 'Causes fatal error on PHP 8.0+'],
38 // Removed in PHP 7.0 (still found in ancient plugins)
39 'mysql_connect' => ['7.0', 'mysqli or PDO', 'Causes fatal error on PHP 7.0+'],
40 'mysql_query' => ['7.0', 'mysqli_query or PDO', 'Causes fatal error on PHP 7.0+'],
41 'mysql_real_escape_string' => ['7.0', 'mysqli_real_escape_string', 'Causes fatal error on PHP 7.0+'],
42 'mysql_fetch_array' => ['7.0', 'mysqli_fetch_array', 'Causes fatal error on PHP 7.0+'],
43 'mysql_fetch_assoc' => ['7.0', 'mysqli_fetch_assoc', 'Causes fatal error on PHP 7.0+'],
44 'mysql_num_rows' => ['7.0', 'mysqli_num_rows', 'Causes fatal error on PHP 7.0+'],
45 'mysql_close' => ['7.0', 'mysqli_close', 'Causes fatal error on PHP 7.0+'],
46 'ereg' => ['7.0', 'preg_match', 'Causes fatal error on PHP 7.0+'],
47 'eregi' => ['7.0', 'preg_match with /i flag', 'Causes fatal error on PHP 7.0+'],
48 'ereg_replace' => ['7.0', 'preg_replace', 'Causes fatal error on PHP 7.0+'],
49 'split' => ['7.0', 'preg_split or explode', 'Causes fatal error on PHP 7.0+'],
50 'mcrypt_encrypt' => ['7.2', 'openssl_encrypt', 'Causes fatal error on PHP 7.2+'],
51 'mcrypt_decrypt' => ['7.2', 'openssl_decrypt', 'Causes fatal error on PHP 7.2+'],
52 ];
53
54 /**
55 * Patterns that indicate PHP version incompatibilities.
56 * Format: [pattern, min_php_version_affected, description, severity]
57 */
58 private const COMPAT_PATTERNS = [
59 // PHP 8.1: Implicit float-to-int conversion deprecated
60 ['/\(int\)\s*\$/', '8.1', 'Explicit int casts may behave differently with float values', 'low'],
61
62 // PHP 8.2: Dynamic properties deprecated
63 ['/\$this->[a-zA-Z_]+\s*=(?!=)/', '8.2', 'Dynamic properties are deprecated in PHP 8.2 (classes without #[AllowDynamicProperties])', 'info'],
64
65 // PHP 8.1: Return type declarations becoming enforced
66 ['/function\s+\w+\s*\([^)]*\)\s*\{/', '8.1', 'Functions without return type declarations may trigger deprecation notices', 'info'],
67
68 // PHP 8.0: Named arguments can break positional calls if parameter names change
69 // Not easily detectable via regex, but we flag it as a general risk
70
71 // PHP 8.1: Fibers and enums - not a compat issue, but readiness indicator
72
73 // PHP 8.0: Null-safe operator usage indicates modern code (positive signal)
74 ];
75
76 /**
77 * Analyze all active plugins for PHP compatibility issues.
78 *
79 * @param string|null $target_php PHP version to check against (e.g., '8.2').
80 * Defaults to current PHP version.
81 * @return array{
82 * target_php: string,
83 * current_php: string,
84 * plugins: array<string, array>,
85 * summary: array{clean: int, warnings: int, errors: int},
86 * issues: array
87 * }
88 */
89 public function analyze(?string $target_php = null): array {
90 if ($target_php === null) {
91 $target_php = PHP_MAJOR_VERSION . '.' . PHP_MINOR_VERSION;
92 }
93
94 $current_php = PHP_MAJOR_VERSION . '.' . PHP_MINOR_VERSION . '.' . PHP_RELEASE_VERSION;
95 $active_plugins = get_option('active_plugins', []);
96 $results = [];
97 $all_issues = [];
98 $summary = ['clean' => 0, 'warnings' => 0, 'errors' => 0];
99
100 foreach ($active_plugins as $plugin_file) {
101 if ($plugin_file === JETSTRIKE_CD_BASENAME) {
102 continue;
103 }
104
105 $plugin_data = get_plugin_data(WP_PLUGIN_DIR . '/' . $plugin_file, false, false);
106 $plugin_dir = WP_PLUGIN_DIR . '/' . dirname($plugin_file);
107
108 $issues = $this->scan_plugin($plugin_dir, $target_php, $plugin_data, $plugin_file);
109 $results[$plugin_file] = [
110 'name' => $plugin_data['Name'] ?: dirname($plugin_file),
111 'issues' => $issues,
112 'status' => empty($issues) ? 'compatible' : (
113 $this->has_severity($issues, 'critical') ? 'incompatible' : 'warnings'
114 ),
115 ];
116
117 if (empty($issues)) {
118 $summary['clean']++;
119 } elseif ($this->has_severity($issues, 'critical') || $this->has_severity($issues, 'high')) {
120 $summary['errors']++;
121 } else {
122 $summary['warnings']++;
123 }
124
125 foreach ($issues as $issue) {
126 $all_issues[] = $issue;
127 }
128 }
129
130 return [
131 'target_php' => $target_php,
132 'current_php' => $current_php,
133 'plugins' => $results,
134 'summary' => $summary,
135 'issues' => $all_issues,
136 ];
137 }
138
139 /**
140 * Scan a single plugin for PHP compatibility issues.
141 *
142 * @param string $plugin_dir Plugin directory path.
143 * @param string $target_php Target PHP version.
144 * @param array $plugin_data Plugin header data.
145 * @param string $plugin_file Plugin file path.
146 * @return array List of compatibility issues.
147 */
148 private function scan_plugin(
149 string $plugin_dir,
150 string $target_php,
151 array $plugin_data,
152 string $plugin_file
153 ): array {
154 if (! is_dir($plugin_dir)) {
155 return [];
156 }
157
158 $issues = [];
159 $files = $this->get_php_files($plugin_dir, 100);
160 $plugin_name = $plugin_data['Name'] ?: dirname($plugin_file);
161
162 // Check requires_php header first.
163 $requires_php = $plugin_data['RequiresPHP'] ?? '';
164 if (! empty($requires_php) && version_compare($target_php, $requires_php, '<')) {
165 $issues[] = [
166 'type' => 'php_version_requirement',
167 'severity' => 'critical',
168 'plugin' => $plugin_file,
169 'plugin_name' => $plugin_name,
170 'message' => sprintf(
171 '%s requires PHP %s or higher, but the target version is %s.',
172 $plugin_name,
173 $requires_php,
174 $target_php
175 ),
176 'file' => $plugin_file,
177 'required' => $requires_php,
178 'target' => $target_php,
179 ];
180 }
181
182 // Scan each PHP file for compatibility issues.
183 $removed_found = [];
184 $pattern_found = [];
185
186 foreach ($files as $file) {
187 $contents = @file_get_contents($file);
188 if ($contents === false || strlen($contents) === 0) {
189 continue;
190 }
191
192 $relative_path = str_replace($plugin_dir . '/', '', $file);
193
194 // Check for removed functions.
195 foreach (self::REMOVED_FUNCTIONS as $func => $info) {
196 if (version_compare($target_php, $info[0], '>=')) {
197 if (preg_match('/\b' . preg_quote($func, '/') . '\s*\(/', $contents)) {
198 if (! isset($removed_found[$func])) {
199 $removed_found[$func] = [
200 'function' => $func,
201 'removed_in' => $info[0],
202 'replacement' => $info[1],
203 'risk' => $info[2],
204 'files' => [],
205 ];
206 }
207 $removed_found[$func]['files'][] = $relative_path;
208 }
209 }
210 }
211
212 // Check for PHP 8.2 dynamic properties.
213 if (version_compare($target_php, '8.2', '>=')) {
214 if (preg_match('/class\s+\w+[^{]*\{/', $contents) &&
215 ! preg_match('/#\[AllowDynamicProperties\]/', $contents) &&
216 preg_match('/\$this->\w+\s*=[^=]/', $contents)) {
217 // Only flag if the class doesn't declare the properties.
218 $class_matches = [];
219 if (preg_match_all('/class\s+(\w+)/', $contents, $class_matches)) {
220 foreach ($class_matches[1] as $class_name) {
221 if (! isset($pattern_found['dynamic_props_' . $class_name])) {
222 $pattern_found['dynamic_props_' . $class_name] = $relative_path;
223 }
224 }
225 }
226 }
227 }
228
229 // Check for deprecated ${} string interpolation (PHP 8.2).
230 if (version_compare($target_php, '8.2', '>=')) {
231 if (preg_match('/"\$\{[^}]+\}"/', $contents)) {
232 if (! isset($pattern_found['string_interpolation'])) {
233 $pattern_found['string_interpolation'] = [];
234 }
235 $pattern_found['string_interpolation'][] = $relative_path;
236 }
237 }
238
239 // Check for utf8_encode/utf8_decode (deprecated in PHP 8.2).
240 if (version_compare($target_php, '8.2', '>=')) {
241 if (preg_match('/\b(utf8_encode|utf8_decode)\s*\(/', $contents, $m)) {
242 $func_name = $m[1];
243 if (! isset($removed_found[$func_name])) {
244 $removed_found[$func_name] = [
245 'function' => $func_name,
246 'removed_in' => '8.2',
247 'replacement' => 'mb_convert_encoding',
248 'risk' => 'Deprecated in PHP 8.2, will be removed in a future version',
249 'files' => [],
250 ];
251 }
252 $removed_found[$func_name]['files'][] = $relative_path;
253 }
254 }
255 }
256
257 // Convert removed functions to issues.
258 foreach ($removed_found as $func => $data) {
259 $is_fatal = version_compare($target_php, $data['removed_in'], '>=');
260 $issues[] = [
261 'type' => 'removed_function',
262 'severity' => $is_fatal ? 'critical' : 'high',
263 'plugin' => $plugin_file,
264 'plugin_name' => $plugin_name,
265 'message' => sprintf(
266 '%s uses %s() which was removed in PHP %s. %s Replace with: %s. Found in: %s.',
267 $plugin_name,
268 $func,
269 $data['removed_in'],
270 $data['risk'],
271 $data['replacement'],
272 implode(', ', array_slice($data['files'], 0, 3)) .
273 (count($data['files']) > 3 ? ' (+' . (count($data['files']) - 3) . ' more)' : '')
274 ),
275 'function' => $func,
276 'removed_in' => $data['removed_in'],
277 'replacement' => $data['replacement'],
278 'file_count' => count($data['files']),
279 ];
280 }
281
282 // Convert pattern matches to issues.
283 if (! empty($pattern_found['string_interpolation'])) {
284 $issues[] = [
285 'type' => 'deprecated_syntax',
286 'severity' => 'medium',
287 'plugin' => $plugin_file,
288 'plugin_name' => $plugin_name,
289 'message' => sprintf(
290 '%s uses ${} string interpolation syntax which is deprecated in PHP 8.2. ' .
291 'Found in %d file(s).',
292 $plugin_name,
293 count($pattern_found['string_interpolation'])
294 ),
295 ];
296 }
297
298 return $issues;
299 }
300
301 /**
302 * Check if any issue in the list has the given severity.
303 */
304 private function has_severity(array $issues, string $severity): bool {
305 foreach ($issues as $issue) {
306 if (($issue['severity'] ?? '') === $severity) {
307 return true;
308 }
309 }
310 return false;
311 }
312
313 /**
314 * Get PHP files in a directory with safety limits.
315 */
316 private function get_php_files(string $dir, int $max_files, int $depth = 0): array {
317 if ($depth > 5 || ! is_readable($dir)) {
318 return [];
319 }
320
321 $files = [];
322 $entries = @scandir($dir);
323
324 if (! is_array($entries)) {
325 return [];
326 }
327
328 foreach ($entries as $entry) {
329 if ($entry === '.' || $entry === '..') {
330 continue;
331 }
332
333 if (count($files) >= $max_files) {
334 break;
335 }
336
337 $path = $dir . '/' . $entry;
338
339 if (is_dir($path)) {
340 $skip = ['node_modules', 'vendor', '.git', 'tests', 'test', 'assets'];
341 if (in_array($entry, $skip, true)) {
342 continue;
343 }
344 $files = array_merge($files, $this->get_php_files($path, $max_files - count($files), $depth + 1));
345 } elseif (pathinfo($entry, PATHINFO_EXTENSION) === 'php') {
346 $files[] = $path;
347 }
348 }
349
350 return $files;
351 }
352}
Addedincludes/Analyzer/PluginHealthAnalyzer.php+505−0View fileUnifiedSplit
1<?php
2/**
3 * Plugin Health Analyzer — detects abandoned, outdated, and vulnerable plugins.
4 *
5 * Every WordPress site runs plugins that haven't been updated in years,
6 * have been removed from wordpress.org, or have known security vulnerabilities.
7 * Store owners don't know this because nobody tells them.
8 *
9 * This analyzer checks each active plugin against the wordpress.org API
10 * and produces a health score with actionable recommendations.
11 *
12 * @package Jetstrike\ConflictDetector
13 */
14
15declare(strict_types=1);
16
17namespace Jetstrike\ConflictDetector\Analyzer;
18
19final class PluginHealthAnalyzer {
20
21 private const WP_API_BASE = 'https://api.wordpress.org/plugins/info/1.2/';
22 private const CACHE_PREFIX = 'jetstrike_cd_pluginhealth_';
23 private const CACHE_TTL = 12 * HOUR_IN_SECONDS;
24
25 /** Thresholds for plugin age warnings. */
26 private const STALE_MONTHS = 12;
27 private const ABANDONED_MONTHS = 24;
28
29 /** Known PHP functions removed or deprecated across versions. */
30 private const RISKY_FUNCTIONS = [
31 'create_function',
32 'each',
33 'mysql_connect',
34 'mysql_query',
35 'mysql_real_escape_string',
36 'mysql_fetch_array',
37 'mysql_fetch_assoc',
38 'mysql_num_rows',
39 'mysql_close',
40 'ereg',
41 'eregi',
42 'ereg_replace',
43 'eregi_replace',
44 'split',
45 'spliti',
46 'mcrypt_encrypt',
47 'mcrypt_decrypt',
48 ];
49
50 /**
51 * Analyze all active plugins for health issues.
52 *
53 * @return array{
54 * plugins: array<string, array>,
55 * summary: array{healthy: int, stale: int, abandoned: int, removed: int, vulnerable: int},
56 * issues: array
57 * }
58 */
59 public function analyze(): array {
60 $active_plugins = get_option('active_plugins', []);
61 $results = [];
62 $issues = [];
63 $summary = [
64 'healthy' => 0,
65 'stale' => 0,
66 'abandoned' => 0,
67 'removed' => 0,
68 'vulnerable' => 0,
69 'outdated_wp' => 0,
70 ];
71
72 foreach ($active_plugins as $plugin_file) {
73 if ($plugin_file === JETSTRIKE_CD_BASENAME) {
74 continue;
75 }
76
77 $plugin_data = get_plugin_data(WP_PLUGIN_DIR . '/' . $plugin_file, false, false);
78 $slug = $this->extract_slug($plugin_file);
79 $health = $this->check_plugin_health($slug, $plugin_data, $plugin_file);
80
81 $results[$plugin_file] = $health;
82
83 foreach ($health['issues'] as $issue) {
84 $issues[] = $issue;
85
86 switch ($issue['type']) {
87 case 'abandoned':
88 $summary['abandoned']++;
89 break;
90 case 'stale':
91 $summary['stale']++;
92 break;
93 case 'removed':
94 $summary['removed']++;
95 break;
96 case 'vulnerable':
97 $summary['vulnerable']++;
98 break;
99 case 'outdated_wp':
100 $summary['outdated_wp']++;
101 break;
102 }
103 }
104
105 if (empty($health['issues'])) {
106 $summary['healthy']++;
107 }
108 }
109
110 return [
111 'plugins' => $results,
112 'summary' => $summary,
113 'issues' => $issues,
114 ];
115 }
116
117 /**
118 * Check a single plugin's health.
119 *
120 * @param string $slug Plugin slug (directory name).
121 * @param array $plugin_data Data from get_plugin_data().
122 * @param string $plugin_file Plugin file path.
123 * @return array{score: int, status: string, issues: array, wporg_data: array|null}
124 */
125 private function check_plugin_health(string $slug, array $plugin_data, string $plugin_file): array {
126 $score = 100;
127 $issues = [];
128 $status = 'healthy';
129
130 // Query wordpress.org for plugin info.
131 $wporg = $this->get_wporg_data($slug);
132
133 // Check 1: Is the plugin on wordpress.org?
134 if ($wporg === null) {
135 // Premium or custom plugin — can't check much, but flag it.
136 $issues[] = [
137 'type' => 'unlisted',
138 'severity' => 'low',
139 'plugin' => $plugin_file,
140 'plugin_name' => $plugin_data['Name'] ?: $slug,
141 'message' => sprintf(
142 '%s is not listed on wordpress.org. This is normal for premium plugins, but means ' .
143 'we cannot verify its update status or check for known vulnerabilities.',
144 $plugin_data['Name'] ?: $slug
145 ),
146 ];
147 $score -= 5;
148 } else {
149 // Check 2: Has it been removed from wordpress.org?
150 if (! empty($wporg['closed'])) {
151 $issues[] = [
152 'type' => 'removed',
153 'severity' => 'critical',
154 'plugin' => $plugin_file,
155 'plugin_name' => $plugin_data['Name'] ?: $slug,
156 'message' => sprintf(
157 '%s has been REMOVED from wordpress.org. This typically means a security issue or ' .
158 'guideline violation was found. You should find a replacement immediately.',
159 $plugin_data['Name'] ?: $slug
160 ),
161 'reason' => $wporg['closed_reason'] ?? 'Unknown',
162 ];
163 $score -= 50;
164 $status = 'critical';
165 }
166
167 // Check 3: When was it last updated?
168 if (! empty($wporg['last_updated'])) {
169 $last_updated = strtotime($wporg['last_updated']);
170 $months_ago = $last_updated
171 ? (int) round((time() - $last_updated) / (30 * DAY_IN_SECONDS))
172 : 0;
173
174 if ($months_ago >= self::ABANDONED_MONTHS) {
175 $issues[] = [
176 'type' => 'abandoned',
177 'severity' => 'high',
178 'plugin' => $plugin_file,
179 'plugin_name' => $plugin_data['Name'] ?: $slug,
180 'message' => sprintf(
181 '%s has not been updated in %d months. Plugins abandoned for this long often have ' .
182 'unpatched security vulnerabilities and may break with future WordPress updates.',
183 $plugin_data['Name'] ?: $slug,
184 $months_ago
185 ),
186 'last_updated' => $wporg['last_updated'],
187 'months_ago' => $months_ago,
188 ];
189 $score -= 30;
190 if ($status !== 'critical') {
191 $status = 'warning';
192 }
193 } elseif ($months_ago >= self::STALE_MONTHS) {
194 $issues[] = [
195 'type' => 'stale',
196 'severity' => 'medium',
197 'plugin' => $plugin_file,
198 'plugin_name' => $plugin_data['Name'] ?: $slug,
199 'message' => sprintf(
200 '%s has not been updated in %d months. While it may still work, it\'s worth ' .
201 'checking if a better-maintained alternative exists.',
202 $plugin_data['Name'] ?: $slug,
203 $months_ago
204 ),
205 'last_updated' => $wporg['last_updated'],
206 'months_ago' => $months_ago,
207 ];
208 $score -= 15;
209 }
210 }
211
212 // Check 4: WordPress version compatibility.
213 if (! empty($wporg['tested'])) {
214 $current_wp = get_bloginfo('version');
215 $tested_up_to = $wporg['tested'];
216
217 if (version_compare($current_wp, $tested_up_to, '>')) {
218 $issues[] = [
219 'type' => 'outdated_wp',
220 'severity' => 'medium',
221 'plugin' => $plugin_file,
222 'plugin_name' => $plugin_data['Name'] ?: $slug,
223 'message' => sprintf(
224 '%s has only been tested up to WordPress %s, but you\'re running %s. ' .
225 'It may work fine, but the developer hasn\'t confirmed compatibility.',
226 $plugin_data['Name'] ?: $slug,
227 $tested_up_to,
228 $current_wp
229 ),
230 'tested_up_to' => $tested_up_to,
231 'current_wp' => $current_wp,
232 ];
233 $score -= 10;
234 }
235 }
236
237 // Check 5: Very low active installs (might indicate quality issues).
238 $installs = (int) ($wporg['active_installs'] ?? 0);
239 if ($installs > 0 && $installs < 1000) {
240 $issues[] = [
241 'type' => 'low_adoption',
242 'severity' => 'low',
243 'plugin' => $plugin_file,
244 'plugin_name' => $plugin_data['Name'] ?: $slug,
245 'message' => sprintf(
246 '%s has fewer than 1,000 active installations. Low-adoption plugins receive ' .
247 'less community testing and may have undiscovered issues.',
248 $plugin_data['Name'] ?: $slug
249 ),
250 'active_installs' => $installs,
251 ];
252 $score -= 5;
253 }
254
255 // Check 6: Low rating.
256 $rating = (float) ($wporg['rating'] ?? 0);
257 $num_ratings = (int) ($wporg['num_ratings'] ?? 0);
258 if ($num_ratings >= 10 && $rating < 60) {
259 $issues[] = [
260 'type' => 'low_rating',
261 'severity' => 'medium',
262 'plugin' => $plugin_file,
263 'plugin_name' => $plugin_data['Name'] ?: $slug,
264 'message' => sprintf(
265 '%s has a rating of %d%% from %d reviews. This suggests widespread issues ' .
266 'reported by other users.',
267 $plugin_data['Name'] ?: $slug,
268 (int) $rating,
269 $num_ratings
270 ),
271 'rating' => $rating,
272 'num_ratings' => $num_ratings,
273 ];
274 $score -= 10;
275 }
276 }
277
278 // Check 7: Scan for risky/deprecated PHP function usage.
279 $risky = $this->scan_for_risky_functions($plugin_file);
280 if (! empty($risky)) {
281 $issues[] = [
282 'type' => 'risky_code',
283 'severity' => 'medium',
284 'plugin' => $plugin_file,
285 'plugin_name' => $plugin_data['Name'] ?: $slug,
286 'message' => sprintf(
287 '%s uses deprecated or removed PHP functions: %s. These may cause errors on ' .
288 'newer PHP versions and indicate the codebase hasn\'t been modernised.',
289 $plugin_data['Name'] ?: $slug,
290 implode(', ', array_slice($risky, 0, 5))
291 ),
292 'functions' => $risky,
293 ];
294 $score -= 15;
295 }
296
297 // Check 8: Is the installed version outdated?
298 if ($wporg !== null && ! empty($wporg['version']) && ! empty($plugin_data['Version'])) {
299 if (version_compare($plugin_data['Version'], $wporg['version'], '<')) {
300 $issues[] = [
301 'type' => 'update_available',
302 'severity' => 'medium',
303 'plugin' => $plugin_file,
304 'plugin_name' => $plugin_data['Name'] ?: $slug,
305 'message' => sprintf(
306 '%s has an update available: you\'re running v%s, latest is v%s. ' .
307 'Updates often include security patches and bug fixes.',
308 $plugin_data['Name'] ?: $slug,
309 $plugin_data['Version'],
310 $wporg['version']
311 ),
312 'current_version' => $plugin_data['Version'],
313 'latest_version' => $wporg['version'],
314 ];
315 $score -= 10;
316 }
317 }
318
319 return [
320 'score' => max(0, $score),
321 'status' => $status,
322 'issues' => $issues,
323 'wporg_data' => $wporg,
324 ];
325 }
326
327 /**
328 * Query wordpress.org API for plugin information.
329 *
330 * @param string $slug Plugin slug.
331 * @return array|null Plugin info, or null if not found.
332 */
333 private function get_wporg_data(string $slug): ?array {
334 if (empty($slug)) {
335 return null;
336 }
337
338 $cache_key = self::CACHE_PREFIX . $slug;
339 $cached = get_transient($cache_key);
340
341 if ($cached !== false) {
342 return $cached === 'not_found' ? null : $cached;
343 }
344
345 $response = wp_remote_post(self::WP_API_BASE, [
346 'timeout' => 10,
347 'body' => [
348 'action' => 'plugin_information',
349 'request' => serialize((object) [
350 'slug' => $slug,
351 'fields' => [
352 'active_installs' => true,
353 'last_updated' => true,
354 'tested' => true,
355 'requires' => true,
356 'requires_php' => true,
357 'rating' => true,
358 'num_ratings' => true,
359 'version' => true,
360 'sections' => false,
361 'description' => false,
362 'short_description' => false,
363 'screenshots' => false,
364 'tags' => false,
365 'donate_link' => false,
366 'contributors' => false,
367 'compatibility' => false,
368 ],
369 ]),
370 ],
371 ]);
372
373 if (is_wp_error($response)) {
374 return null;
375 }
376
377 $code = (int) wp_remote_retrieve_response_code($response);
378
379 if ($code === 404) {
380 set_transient($cache_key, 'not_found', self::CACHE_TTL);
381 return null;
382 }
383
384 if ($code < 200 || $code >= 300) {
385 return null;
386 }
387
388 $body = maybe_unserialize(wp_remote_retrieve_body($response));
389
390 if (! is_object($body) && ! is_array($body)) {
391 $body = json_decode(wp_remote_retrieve_body($response), true);
392 }
393
394 if (is_object($body)) {
395 $body = (array) $body;
396 }
397
398 if (! is_array($body) || isset($body['error'])) {
399 set_transient($cache_key, 'not_found', self::CACHE_TTL);
400 return null;
401 }
402
403 $data = [
404 'version' => $body['version'] ?? '',
405 'last_updated' => $body['last_updated'] ?? '',
406 'tested' => $body['tested'] ?? '',
407 'requires' => $body['requires'] ?? '',
408 'requires_php' => $body['requires_php'] ?? '',
409 'active_installs' => (int) ($body['active_installs'] ?? 0),
410 'rating' => (float) ($body['rating'] ?? 0),
411 'num_ratings' => (int) ($body['num_ratings'] ?? 0),
412 'closed' => ! empty($body['closed']),
413 'closed_reason' => $body['closed_reason'] ?? '',
414 ];
415
416 set_transient($cache_key, $data, self::CACHE_TTL);
417
418 return $data;
419 }
420
421 /**
422 * Scan a plugin's PHP files for deprecated/risky function calls.
423 *
424 * @param string $plugin_file Plugin file path.
425 * @return array List of risky function names found.
426 */
427 private function scan_for_risky_functions(string $plugin_file): array {
428 $plugin_dir = WP_PLUGIN_DIR . '/' . dirname($plugin_file);
429
430 if (! is_dir($plugin_dir)) {
431 return [];
432 }
433
434 $found = [];
435 $files = $this->get_php_files($plugin_dir, 50);
436
437 foreach ($files as $file) {
438 $contents = @file_get_contents($file);
439 if ($contents === false) {
440 continue;
441 }
442
443 foreach (self::RISKY_FUNCTIONS as $func) {
444 if (preg_match('/\b' . preg_quote($func, '/') . '\s*\(/', $contents)) {
445 $found[$func] = true;
446 }
447 }
448 }
449
450 return array_keys($found);
451 }
452
453 /**
454 * Get PHP files in a directory (with depth and count limits).
455 *
456 * @param string $dir Directory to scan.
457 * @param int $max_files Maximum files to return.
458 * @param int $depth Current recursion depth.
459 * @return array File paths.
460 */
461 private function get_php_files(string $dir, int $max_files, int $depth = 0): array {
462 if ($depth > 5 || ! is_readable($dir)) {
463 return [];
464 }
465
466 $files = [];
467 $entries = @scandir($dir);
468
469 if (! is_array($entries)) {
470 return [];
471 }
472
473 foreach ($entries as $entry) {
474 if ($entry === '.' || $entry === '..') {
475 continue;
476 }
477
478 if (count($files) >= $max_files) {
479 break;
480 }
481
482 $path = $dir . '/' . $entry;
483
484 if (is_dir($path)) {
485 $skip = ['node_modules', 'vendor', '.git', 'tests', 'test'];
486 if (in_array($entry, $skip, true)) {
487 continue;
488 }
489 $files = array_merge($files, $this->get_php_files($path, $max_files - count($files), $depth + 1));
490 } elseif (pathinfo($entry, PATHINFO_EXTENSION) === 'php') {
491 $files[] = $path;
492 }
493 }
494
495 return $files;
496 }
497
498 /**
499 * Extract plugin slug from file path.
500 */
501 private function extract_slug(string $plugin_file): string {
502 $parts = explode('/', $plugin_file);
503 return sanitize_title($parts[0] ?? '');
504 }
505}
Modifiedtemplates/admin/dashboard.php+16−0View fileUnifiedSplit
105105 <?php esc_html_e('Full Scan', 'jetstrike-cd'); ?>
106106 </button>
107107 </div>
108 <?php if ($tier !== 'free'): ?>
109 <div class="jetstrike-cd-actions" style="margin-top: 12px; display: flex; gap: 8px;">
110 <button type="button" class="button jetstrike-cd-health-btn" data-check="plugin_health">
111 <span class="dashicons dashicons-plugins-checked"></span>
112 <?php esc_html_e('Plugin Health', 'jetstrike-cd'); ?>
113 </button>
114 <button type="button" class="button jetstrike-cd-health-btn" data-check="db_health">
115 <span class="dashicons dashicons-database"></span>
116 <?php esc_html_e('Database Health', 'jetstrike-cd'); ?>
117 </button>
118 <button type="button" class="button jetstrike-cd-health-btn" data-check="php_compat">
119 <span class="dashicons dashicons-editor-code"></span>
120 <?php esc_html_e('PHP Compat', 'jetstrike-cd'); ?>
121 </button>
122 </div>
123 <?php endif; ?>
108124 <?php if ($has_running): ?>
109125 <div class="jetstrike-cd-scan-progress" id="jetstrike-scan-progress">
110126 <div class="jetstrike-cd-progress-bar">
111127
c comment · e edit title · m merge · a approve · r request changes · ? shortcuts