CodeIssuesPull RequestsActionsSecurityInsights
✨ AI
More
Settings

Claude/woocommerce conflict detector tb9 gg #3833

Merged⚡ AI-generatedXSccantynz wants to mergeclaude/woocommerce-conflict-detector-tb9Ggmainopened Apr 14, 2026
10 changed files+2111−0
ModifiedCLAUDE.md+10−0View fileUnifiedSplit
4949│ │ ├── ResourceAnalyzer.php # JS/CSS/global collision detection
5050│ │ ├── PerformanceAnalyzer.php # Performance impact scoring
5151│ │ ├── WooCommerceAnalyzer.php # WooCommerce-specific rules
52│ │ ├── PreUpdateAnalyzer.php # Pre-update simulation engine
5253│ │ ├── DependencyAnalyzer.php # Bundled PHP library version conflicts
5354│ │ ├── JavaScriptAnalyzer.php # JS global/prototype/jQuery conflicts
5455│ │ └── DatabaseAnalyzer.php # Option/cron/CPT/table collisions
6970│ │ └── CompatibilityPatch.php # mu-plugin patch file generator
7071│ ├── CLI/
7172│ │ └── Commands.php # WP-CLI integration
73│ ├── Report/
74│ │ └── ReportGenerator.php # Professional HTML conflict reports
75│ ├── Export/
76│ │ └── ExportManager.php # Export/import conflict profiles
77│ ├── Multisite/
78│ │ └── NetworkScanner.php # Multisite network-wide scanning
7279│ ├── Database/
7380│ │ ├── Migrator.php # Schema versioning & migrations
7481│ │ └── Repository.php # Data access layer
35436116. Dependency Analyzer + JavaScript Analyzer + Database Analyzer
35536217. WP-CLI Commands
35636318. Landing page (site/index.html)
36419. Pre-Update Simulation Engine
36520. Compatibility Matrix + Report Generator + Export/Import
36621. Multisite Network Scanner
357367
358368## Git Strategy
359369
Modifiedassets/css/admin-dashboard.css+166−0View fileUnifiedSplit
536536 text-align: center;
537537 white-space: nowrap;
538538}
539
540/* ── Compatibility Matrix ─────────────────────────────────── */
541
542.jetstrike-cd-matrix-wrapper {
543 margin-top: 16px;
544}
545
546.jetstrike-cd-matrix-stats {
547 display: flex;
548 gap: 24px;
549 margin-bottom: 16px;
550 font-size: 14px;
551}
552
553.jetstrike-cd-matrix-stat strong {
554 font-size: 18px;
555}
556
557.jetstrike-cd-matrix-stat--good strong { color: #22c55e; }
558.jetstrike-cd-matrix-stat--bad strong { color: #ef4444; }
559
560.jetstrike-cd-matrix-scroll {
561 overflow-x: auto;
562 border: 1px solid #e2e8f0;
563 border-radius: 8px;
564}
565
566.jetstrike-cd-matrix-table {
567 border-collapse: collapse;
568 width: 100%;
569 min-width: max-content;
570}
571
572.jetstrike-cd-matrix-table th,
573.jetstrike-cd-matrix-table td {
574 padding: 8px 10px;
575 text-align: center;
576 border: 1px solid #e2e8f0;
577 font-size: 13px;
578}
579
580.jetstrike-cd-matrix-header {
581 background: #f8fafc;
582 font-weight: 600;
583 max-width: 120px;
584 white-space: nowrap;
585 overflow: hidden;
586 text-overflow: ellipsis;
587}
588
589.jetstrike-cd-matrix-header span {
590 display: block;
591 transform: rotate(-45deg);
592 white-space: nowrap;
593 font-size: 11px;
594}
595
596.jetstrike-cd-matrix-row-header {
597 text-align: left !important;
598 background: #f8fafc;
599 font-weight: 600;
600 white-space: nowrap;
601 max-width: 150px;
602 overflow: hidden;
603 text-overflow: ellipsis;
604}
605
606.jetstrike-cd-matrix-cell {
607 width: 40px;
608 height: 40px;
609 cursor: default;
610 transition: all 0.2s;
611 font-size: 16px;
612}
613
614.jetstrike-cd-matrix-cell:hover {
615 transform: scale(1.1);
616 box-shadow: 0 0 8px rgba(0,0,0,0.15);
617 z-index: 1;
618 position: relative;
619}
620
621.jetstrike-cd-matrix-cell--self {
622 background: #f1f5f9;
623 color: #cbd5e1;
624}
625
626.jetstrike-cd-matrix-cell--compatible {
627 background: #f0fdf4;
628 color: #22c55e;
629}
630
631.jetstrike-cd-matrix-cell--conflict.jetstrike-cd-matrix-cell--critical {
632 background: #fef2f2;
633 color: #ef4444;
634}
635
636.jetstrike-cd-matrix-cell--conflict.jetstrike-cd-matrix-cell--high {
637 background: #fff7ed;
638 color: #f97316;
639}
640
641.jetstrike-cd-matrix-cell--conflict.jetstrike-cd-matrix-cell--medium {
642 background: #fefce8;
643 color: #eab308;
644}
645
646.jetstrike-cd-matrix-cell--conflict.jetstrike-cd-matrix-cell--low {
647 background: #eff6ff;
648 color: #3b82f6;
649}
650
651.jetstrike-cd-matrix-cell--resolved {
652 background: #f0fdf4;
653 color: #86efac;
654}
655
656.jetstrike-cd-matrix-legend {
657 display: flex;
658 gap: 16px;
659 margin-top: 12px;
660 font-size: 13px;
661 color: #64748b;
662}
663
664.jetstrike-cd-legend-item {
665 display: flex;
666 align-items: center;
667 gap: 6px;
668}
669
670.jetstrike-cd-legend-color {
671 width: 16px;
672 height: 16px;
673 border-radius: 3px;
674 border: 1px solid #e2e8f0;
675}
676
677.jetstrike-cd-legend--compatible { background: #dcfce7; }
678.jetstrike-cd-legend--medium { background: #fef9c3; }
679.jetstrike-cd-legend--high { background: #fed7aa; }
680.jetstrike-cd-legend--critical { background: #fecaca; }
681
682.jetstrike-cd-matrix-empty {
683 padding: 24px;
684 text-align: center;
685 color: #64748b;
686}
687
688/* ── Toolbar Actions (Report, Export, etc.) ─────────────── */
689
690.jetstrike-cd-toolbar {
691 display: flex;
692 gap: 8px;
693 margin-bottom: 16px;
694 flex-wrap: wrap;
695}
696
697.jetstrike-cd-toolbar .button .dashicons {
698 font-size: 16px;
699 width: 16px;
700 height: 16px;
701 vertical-align: middle;
702 margin-right: 4px;
703 margin-top: -2px;
704}
Modifiedassets/js/admin-dashboard.js+94−0View fileUnifiedSplit
3232 $(document).on('click', '#jetstrike-activate-license', this.activateLicense.bind(this));
3333 $(document).on('click', '#jetstrike-deactivate-license', this.deactivateLicense.bind(this));
3434
35 // Toolbar actions.
36 $(document).on('click', '#jetstrike-generate-report', this.generateReport.bind(this));
37 $(document).on('click', '#jetstrike-export-data', this.exportData.bind(this));
38 $(document).on('click', '#jetstrike-toggle-matrix', this.toggleMatrix.bind(this));
39
3540 // Settings form.
3641 $(document).on('click', '#jetstrike-save-settings', this.saveSettings.bind(this));
3742
450455 });
451456 },
452457
458 // ── Toolbar Actions ───────────────────────────────────
459
460 generateReport: function (e) {
461 e.preventDefault();
462 var $btn = $(e.currentTarget);
463 $btn.prop('disabled', true).html('<span class="jetstrike-cd-spinner"></span> Generating...');
464
465 $.ajax({
466 url: jetstrikeCD.ajaxUrl,
467 type: 'POST',
468 data: {
469 action: 'jetstrike_cd_generate_report',
470 nonce: jetstrikeCD.ajaxNonce
471 },
472 success: function (response) {
473 $btn.prop('disabled', false).html('<span class="dashicons dashicons-media-document"></span> Generate Report');
474
475 if (response.success) {
476 // Open report in new window.
477 var win = window.open('', '_blank');
478 win.document.write(response.data.html);
479 win.document.close();
480 JetstrikeCD.showNotice('success', 'Report generated. A new window has opened with the report.');
481 } else {
482 JetstrikeCD.showNotice('error', response.data.message || 'Failed to generate report.');
483 }
484 },
485 error: function () {
486 $btn.prop('disabled', false).html('<span class="dashicons dashicons-media-document"></span> Generate Report');
487 JetstrikeCD.showNotice('error', 'Failed to generate report.');
488 }
489 });
490 },
491
492 exportData: function (e) {
493 e.preventDefault();
494 var $btn = $(e.currentTarget);
495 $btn.prop('disabled', true).html('<span class="jetstrike-cd-spinner"></span> Exporting...');
496
497 $.ajax({
498 url: jetstrikeCD.ajaxUrl,
499 type: 'POST',
500 data: {
501 action: 'jetstrike_cd_export_data',
502 nonce: jetstrikeCD.ajaxNonce,
503 include_scans: true
504 },
505 success: function (response) {
506 $btn.prop('disabled', false).html('<span class="dashicons dashicons-download"></span> Export Data');
507
508 if (response.success) {
509 // Trigger file download.
510 var blob = new Blob([response.data.json], { type: 'application/json' });
511 var url = URL.createObjectURL(blob);
512 var a = document.createElement('a');
513 a.href = url;
514 a.download = response.data.filename;
515 document.body.appendChild(a);
516 a.click();
517 document.body.removeChild(a);
518 URL.revokeObjectURL(url);
519
520 JetstrikeCD.showNotice('success',
521 'Exported ' + response.data.stats.conflicts + ' conflict(s) and ' +
522 response.data.stats.plugins + ' plugin(s).');
523 } else {
524 JetstrikeCD.showNotice('error', response.data.message || 'Export failed.');
525 }
526 },
527 error: function () {
528 $btn.prop('disabled', false).html('<span class="dashicons dashicons-download"></span> Export Data');
529 JetstrikeCD.showNotice('error', 'Export failed.');
530 }
531 });
532 },
533
534 toggleMatrix: function (e) {
535 e.preventDefault();
536 var $container = $('#jetstrike-matrix-container');
537 $container.slideToggle(300);
538
539 var $btn = $(e.currentTarget);
540 if ($container.is(':visible')) {
541 $btn.addClass('button-primary');
542 } else {
543 $btn.removeClass('button-primary');
544 }
545 },
546
453547 // ── Settings ──────────────────────────────────────────
454548
455549 saveSettings: function (e) {
Modifiedincludes/Admin/AdminAjax.php+101−0View fileUnifiedSplit
1212use Jetstrike\ConflictDetector\Database\Repository;
1313use Jetstrike\ConflictDetector\Scanner\ScanQueue;
1414use Jetstrike\ConflictDetector\Resolver\AutoResolver;
15use Jetstrike\ConflictDetector\Report\ReportGenerator;
16use Jetstrike\ConflictDetector\Export\ExportManager;
17use Jetstrike\ConflictDetector\Analyzer\PreUpdateAnalyzer;
1518use Jetstrike\ConflictDetector\Subscription\FeatureFlags;
1619
1720final class AdminAjax {
3336 add_action('wp_ajax_jetstrike_cd_update_conflict', [$this, 'update_conflict_status']);
3437 add_action('wp_ajax_jetstrike_cd_auto_fix', [$this, 'auto_fix_conflict']);
3538 add_action('wp_ajax_jetstrike_cd_revert_fix', [$this, 'revert_fix']);
39 add_action('wp_ajax_jetstrike_cd_generate_report', [$this, 'generate_report']);
40 add_action('wp_ajax_jetstrike_cd_export_data', [$this, 'export_data']);
41 add_action('wp_ajax_jetstrike_cd_import_data', [$this, 'import_data']);
42 add_action('wp_ajax_jetstrike_cd_pre_update_check', [$this, 'pre_update_check']);
3643 add_action('wp_ajax_jetstrike_cd_activate_license', [$this, 'activate_license']);
3744 add_action('wp_ajax_jetstrike_cd_deactivate_license', [$this, 'deactivate_license']);
3845 }
250257 }
251258 }
252259
260 /**
261 * Generate a professional conflict report.
262 */
263 public function generate_report(): void {
264 $this->verify_request();
265
266 $scan_id = (int) ($_POST['scan_id'] ?? 0);
267 $generator = new ReportGenerator($this->repository);
268 $report = $generator->generate($scan_id > 0 ? $scan_id : null);
269
270 wp_send_json_success([
271 'html' => $report['html'],
272 'filename' => $report['filename'],
273 ]);
274 }
275
276 /**
277 * Export conflict data as JSON.
278 */
279 public function export_data(): void {
280 $this->verify_request();
281
282 if (! FeatureFlags::is_at_least('pro')) {
283 wp_send_json_error(['message' => __('Export requires a Pro or Agency plan.', 'jetstrike-cd')]);
284 }
285
286 $exporter = new ExportManager($this->repository);
287 $export = $exporter->export([
288 'include_scans' => ! empty($_POST['include_scans']),
289 'include_resolved' => ! empty($_POST['include_resolved']),
290 ]);
291
292 wp_send_json_success([
293 'json' => $export['json'],
294 'filename' => $export['filename'],
295 'stats' => $export['stats'],
296 ]);
297 }
298
299 /**
300 * Import conflict data from JSON.
301 */
302 public function import_data(): void {
303 $this->verify_request();
304
305 if (! FeatureFlags::is_at_least('pro')) {
306 wp_send_json_error(['message' => __('Import requires a Pro or Agency plan.', 'jetstrike-cd')]);
307 }
308
309 $json = wp_unslash($_POST['import_json'] ?? '');
310
311 if (empty($json)) {
312 wp_send_json_error(['message' => __('No import data provided.', 'jetstrike-cd')]);
313 }
314
315 $importer = new ExportManager($this->repository);
316 $result = $importer->import($json, ['merge' => true]);
317
318 if ($result['success']) {
319 wp_send_json_success($result);
320 } else {
321 wp_send_json_error($result);
322 }
323 }
324
325 /**
326 * Run a pre-update compatibility check.
327 */
328 public function pre_update_check(): void {
329 $this->verify_request();
330
331 if (! FeatureFlags::can('pre_update_scan')) {
332 wp_send_json_error([
333 'message' => __('Pre-update simulation requires a Pro or Agency plan.', 'jetstrike-cd'),
334 ]);
335 }
336
337 $plugin_file = sanitize_text_field($_POST['plugin_file'] ?? '');
338
339 if (empty($plugin_file)) {
340 wp_send_json_error(['message' => __('No plugin specified.', 'jetstrike-cd')]);
341 }
342
343 $analyzer = new PreUpdateAnalyzer($this->repository);
344
345 // Quick check first (instant, no download).
346 $quick = $analyzer->quick_check($plugin_file);
347
348 wp_send_json_success([
349 'quick_check' => $quick,
350 'plugin' => dirname($plugin_file),
351 ]);
352 }
353
253354 /**
254355 * Activate a license key.
255356 */
Addedincludes/Admin/CompatibilityMatrix.php+301−0View fileUnifiedSplit
1<?php
2/**
3 * Compatibility Matrix — visual heat map of plugin-to-plugin compatibility.
4 *
5 * Generates an interactive grid showing the compatibility status between
6 * every pair of active plugins. Green = compatible, yellow = minor issues,
7 * red = critical conflict, gray = untested.
8 *
9 * This is a visual "wow factor" feature that makes agency demos killer.
10 *
11 * @package Jetstrike\ConflictDetector
12 */
13
14declare(strict_types=1);
15
16namespace Jetstrike\ConflictDetector\Admin;
17
18use Jetstrike\ConflictDetector\Database\Repository;
19
20final class CompatibilityMatrix {
21
22 /** @var Repository */
23 private Repository $repository;
24
25 public function __construct(Repository $repository) {
26 $this->repository = $repository;
27 }
28
29 /**
30 * Generate the compatibility matrix data.
31 *
32 * @return array{plugins: array, matrix: array, stats: array}
33 */
34 public function generate(): array {
35 $active_plugins = $this->get_plugin_info();
36 $conflicts = $this->repository->list_active_conflicts(1, 500);
37
38 // Build the conflict lookup map.
39 $conflict_map = [];
40
41 foreach ($conflicts as $conflict) {
42 $key_ab = $this->pair_key($conflict->plugin_a, $conflict->plugin_b);
43 $key_ba = $this->pair_key($conflict->plugin_b, $conflict->plugin_a);
44
45 if (! isset($conflict_map[$key_ab])) {
46 $conflict_map[$key_ab] = [];
47 }
48
49 $conflict_map[$key_ab][] = [
50 'type' => $conflict->conflict_type,
51 'severity' => $conflict->severity,
52 'status' => $conflict->status,
53 ];
54
55 // Mirror for reverse lookup.
56 $conflict_map[$key_ba] = $conflict_map[$key_ab];
57 }
58
59 // Build the matrix.
60 $plugin_slugs = array_keys($active_plugins);
61 $matrix = [];
62 $stats = [
63 'total_pairs' => 0,
64 'compatible' => 0,
65 'conflicting' => 0,
66 'untested' => 0,
67 ];
68
69 for ($i = 0; $i < count($plugin_slugs); $i++) {
70 for ($j = $i + 1; $j < count($plugin_slugs); $j++) {
71 $a = $plugin_slugs[$i];
72 $b = $plugin_slugs[$j];
73 $key = $this->pair_key(
74 $active_plugins[$a]['file'],
75 $active_plugins[$b]['file']
76 );
77
78 $stats['total_pairs']++;
79
80 if (isset($conflict_map[$key])) {
81 $pair_conflicts = $conflict_map[$key];
82 $worst_severity = $this->worst_severity($pair_conflicts);
83 $active_count = count(array_filter($pair_conflicts, fn(array $c): bool => $c['status'] === 'active'));
84
85 $cell = [
86 'status' => $active_count > 0 ? 'conflict' : 'resolved',
87 'severity' => $worst_severity,
88 'conflicts' => $pair_conflicts,
89 'count' => $active_count,
90 ];
91
92 if ($active_count > 0) {
93 $stats['conflicting']++;
94 } else {
95 $stats['compatible']++;
96 }
97 } else {
98 $cell = [
99 'status' => 'compatible',
100 'severity' => 'none',
101 'conflicts' => [],
102 'count' => 0,
103 ];
104 $stats['compatible']++;
105 }
106
107 $matrix[$a][$b] = $cell;
108 $matrix[$b][$a] = $cell;
109 }
110 }
111
112 return [
113 'plugins' => $active_plugins,
114 'matrix' => $matrix,
115 'stats' => $stats,
116 ];
117 }
118
119 /**
120 * Render the matrix as HTML.
121 *
122 * @return string HTML table.
123 */
124 public function render_html(): string {
125 $data = $this->generate();
126 $plugins = $data['plugins'];
127 $matrix = $data['matrix'];
128 $stats = $data['stats'];
129
130 if (count($plugins) < 2) {
131 return '<p class="jetstrike-cd-matrix-empty">Need at least 2 active plugins to generate a compatibility matrix.</p>';
132 }
133
134 $slugs = array_keys($plugins);
135
136 $html = '<div class="jetstrike-cd-matrix-wrapper">';
137
138 // Stats bar.
139 $html .= '<div class="jetstrike-cd-matrix-stats">';
140 $html .= sprintf(
141 '<span class="jetstrike-cd-matrix-stat"><strong>%d</strong> plugin pairs</span>',
142 $stats['total_pairs']
143 );
144 $html .= sprintf(
145 '<span class="jetstrike-cd-matrix-stat jetstrike-cd-matrix-stat--good"><strong>%d</strong> compatible</span>',
146 $stats['compatible']
147 );
148 $html .= sprintf(
149 '<span class="jetstrike-cd-matrix-stat jetstrike-cd-matrix-stat--bad"><strong>%d</strong> conflicting</span>',
150 $stats['conflicting']
151 );
152 $html .= '</div>';
153
154 // Matrix table.
155 $html .= '<div class="jetstrike-cd-matrix-scroll"><table class="jetstrike-cd-matrix-table">';
156
157 // Header row.
158 $html .= '<thead><tr><th></th>';
159 foreach ($slugs as $slug) {
160 $short_name = $this->short_name($plugins[$slug]['name']);
161 $html .= sprintf(
162 '<th class="jetstrike-cd-matrix-header" title="%s"><span>%s</span></th>',
163 esc_attr($plugins[$slug]['name']),
164 esc_html($short_name)
165 );
166 }
167 $html .= '</tr></thead>';
168
169 // Data rows.
170 $html .= '<tbody>';
171 foreach ($slugs as $row_slug) {
172 $html .= '<tr>';
173 $html .= sprintf(
174 '<th class="jetstrike-cd-matrix-row-header" title="%s">%s</th>',
175 esc_attr($plugins[$row_slug]['name']),
176 esc_html($this->short_name($plugins[$row_slug]['name']))
177 );
178
179 foreach ($slugs as $col_slug) {
180 if ($row_slug === $col_slug) {
181 $html .= '<td class="jetstrike-cd-matrix-cell jetstrike-cd-matrix-cell--self">&mdash;</td>';
182 continue;
183 }
184
185 $cell = $matrix[$row_slug][$col_slug] ?? ['status' => 'compatible', 'severity' => 'none', 'count' => 0];
186 $class = 'jetstrike-cd-matrix-cell--' . $cell['status'];
187
188 if ($cell['status'] === 'conflict') {
189 $class .= ' jetstrike-cd-matrix-cell--' . $cell['severity'];
190 }
191
192 $tooltip = $cell['status'] === 'conflict'
193 ? sprintf('%d active conflict(s) — worst: %s', $cell['count'], $cell['severity'])
194 : 'Compatible';
195
196 $icon = $cell['status'] === 'conflict'
197 ? ($cell['severity'] === 'critical' ? '&#10060;' : '&#9888;')
198 : '&#10003;';
199
200 $html .= sprintf(
201 '<td class="jetstrike-cd-matrix-cell %s" title="%s" data-plugin-a="%s" data-plugin-b="%s">%s</td>',
202 esc_attr($class),
203 esc_attr($tooltip),
204 esc_attr($row_slug),
205 esc_attr($col_slug),
206 $icon
207 );
208 }
209
210 $html .= '</tr>';
211 }
212 $html .= '</tbody></table></div>';
213
214 // Legend.
215 $html .= '<div class="jetstrike-cd-matrix-legend">';
216 $html .= '<span class="jetstrike-cd-legend-item"><span class="jetstrike-cd-legend-color jetstrike-cd-legend--compatible"></span> Compatible</span>';
217 $html .= '<span class="jetstrike-cd-legend-item"><span class="jetstrike-cd-legend-color jetstrike-cd-legend--medium"></span> Medium</span>';
218 $html .= '<span class="jetstrike-cd-legend-item"><span class="jetstrike-cd-legend-color jetstrike-cd-legend--high"></span> High</span>';
219 $html .= '<span class="jetstrike-cd-legend-item"><span class="jetstrike-cd-legend-color jetstrike-cd-legend--critical"></span> Critical</span>';
220 $html .= '</div>';
221
222 $html .= '</div>';
223
224 return $html;
225 }
226
227 /**
228 * Get info about all active third-party plugins.
229 *
230 * @return array<string, array{name: string, version: string, file: string}>
231 */
232 private function get_plugin_info(): array {
233 $active = get_option('active_plugins', []);
234 $plugins = [];
235
236 foreach ($active as $plugin_file) {
237 if ($plugin_file === JETSTRIKE_CD_BASENAME) {
238 continue;
239 }
240
241 $data = get_plugin_data(WP_PLUGIN_DIR . '/' . $plugin_file, false, false);
242 $slug = dirname($plugin_file);
243
244 $plugins[$slug] = [
245 'name' => $data['Name'] ?: $slug,
246 'version' => $data['Version'] ?? '',
247 'file' => $plugin_file,
248 ];
249 }
250
251 return $plugins;
252 }
253
254 /**
255 * Create a consistent pair key for two plugins.
256 */
257 private function pair_key(string $a, string $b): string {
258 $pair = [$a, $b];
259 sort($pair);
260 return implode(':', $pair);
261 }
262
263 /**
264 * Get the worst severity from a set of conflicts.
265 */
266 private function worst_severity(array $conflicts): string {
267 $levels = ['critical' => 4, 'high' => 3, 'medium' => 2, 'low' => 1];
268 $worst = 0;
269 $worst_name = 'low';
270
271 foreach ($conflicts as $conflict) {
272 $severity = $conflict['severity'] ?? 'low';
273 $level = $levels[$severity] ?? 0;
274
275 if ($level > $worst) {
276 $worst = $level;
277 $worst_name = $severity;
278 }
279 }
280
281 return $worst_name;
282 }
283
284 /**
285 * Shorten a plugin name for matrix headers.
286 */
287 private function short_name(string $name): string {
288 if (strlen($name) <= 15) {
289 return $name;
290 }
291
292 // Try to use abbreviation or truncate.
293 $words = explode(' ', $name);
294
295 if (count($words) >= 3) {
296 return implode(' ', array_slice($words, 0, 2)) . '...';
297 }
298
299 return substr($name, 0, 14) . '...';
300 }
301}
Addedincludes/Analyzer/PreUpdateAnalyzer.php+544−0View fileUnifiedSplit
1<?php
2/**
3 * Pre-Update Simulation — analyzes new plugin versions before applying updates.
4 *
5 * This is a massive selling point: "See what will break BEFORE you update."
6 * Downloads the new version from WordPress.org, extracts it to a temp directory,
7 * runs all static analyzers against the new code, and reports any new conflicts
8 * that would be introduced by the update.
9 *
10 * @package Jetstrike\ConflictDetector
11 */
12
13declare(strict_types=1);
14
15namespace Jetstrike\ConflictDetector\Analyzer;
16
17use Jetstrike\ConflictDetector\Scanner\ScanEngine;
18use Jetstrike\ConflictDetector\Database\Repository;
19
20final class PreUpdateAnalyzer {
21
22 /** @var Repository */
23 private Repository $repository;
24
25 /** Temp directory prefix. */
26 private const TEMP_PREFIX = 'jetstrike_preupdate_';
27
28 public function __construct(Repository $repository) {
29 $this->repository = $repository;
30 }
31
32 /**
33 * Simulate a plugin update and predict new conflicts.
34 *
35 * @param string $plugin_file Plugin file path (e.g. "woocommerce/woocommerce.php").
36 * @param string $new_version The version being updated to.
37 * @return array{safe: bool, new_conflicts: array, resolved_conflicts: array, risk_score: int, summary: string}
38 */
39 public function simulate_update(string $plugin_file, string $new_version = ''): array {
40 $plugin_slug = dirname($plugin_file);
41 $current_dir = WP_PLUGIN_DIR . '/' . $plugin_slug;
42
43 if (! is_dir($current_dir)) {
44 return $this->error_result('Plugin directory not found.');
45 }
46
47 // Step 1: Get info about the available update.
48 $update_info = $this->get_update_info($plugin_file);
49
50 if ($update_info === null) {
51 return $this->error_result('No update available for this plugin.');
52 }
53
54 $download_url = $update_info['package'] ?? '';
55
56 if (empty($download_url)) {
57 return $this->error_result('Update package URL not available. The plugin may require a license for updates.');
58 }
59
60 // Step 2: Download and extract the new version to a temp directory.
61 $temp_dir = $this->download_and_extract($download_url, $plugin_slug);
62
63 if ($temp_dir === null) {
64 return $this->error_result('Failed to download or extract the update package.');
65 }
66
67 try {
68 // Step 3: Run "before" scan with current versions.
69 $active_plugins = $this->get_active_plugins_except($plugin_file);
70 $engine = new ScanEngine($this->repository);
71 $current_conflicts = $engine->run_static_analysis($active_plugins);
72
73 // Step 4: Swap the plugin directory to the new version (in memory only).
74 // We do this by temporarily renaming, analyzing, then restoring.
75 $new_plugin_dir = $temp_dir . '/' . $plugin_slug;
76
77 if (! is_dir($new_plugin_dir)) {
78 // Some plugins extract to a different directory name.
79 $dirs = glob($temp_dir . '/*', GLOB_ONLYDIR);
80 $new_plugin_dir = ! empty($dirs) ? $dirs[0] : $temp_dir;
81 }
82
83 // Step 5: Run static analysis on the new version's code.
84 $new_conflicts = $this->analyze_new_version(
85 $new_plugin_dir,
86 $plugin_file,
87 $active_plugins
88 );
89
90 // Step 6: Compare before/after to find NEW conflicts.
91 $diff = $this->diff_conflicts($current_conflicts, $new_conflicts);
92
93 // Step 7: Calculate risk score.
94 $risk_score = $this->calculate_risk_score($diff);
95
96 return [
97 'safe' => $risk_score < 30,
98 'new_conflicts' => $diff['added'],
99 'resolved_conflicts' => $diff['removed'],
100 'unchanged_conflicts' => $diff['unchanged'],
101 'risk_score' => $risk_score,
102 'update_version' => $update_info['new_version'] ?? $new_version,
103 'current_version' => $update_info['current_version'] ?? '',
104 'summary' => $this->generate_summary($diff, $risk_score, $plugin_slug),
105 'changes_detected' => $this->detect_code_changes($current_dir, $new_plugin_dir),
106 ];
107 } finally {
108 // Cleanup temp directory.
109 $this->cleanup_temp($temp_dir);
110 }
111 }
112
113 /**
114 * Quick pre-update check without downloading — uses WordPress.org API data.
115 *
116 * @param string $plugin_file Plugin file path.
117 * @return array Basic compatibility assessment.
118 */
119 public function quick_check(string $plugin_file): array {
120 $update_info = $this->get_update_info($plugin_file);
121
122 if ($update_info === null) {
123 return ['status' => 'no_update', 'message' => 'No update available.'];
124 }
125
126 $checks = [];
127
128 // Check: Does the new version require a higher PHP version?
129 $requires_php = $update_info['requires_php'] ?? '';
130 if (! empty($requires_php) && version_compare(PHP_VERSION, $requires_php, '<')) {
131 $checks[] = [
132 'check' => 'php_version',
133 'status' => 'fail',
134 'message' => sprintf(
135 'Update requires PHP %s but your server runs PHP %s.',
136 $requires_php,
137 PHP_VERSION
138 ),
139 ];
140 }
141
142 // Check: Does the new version require a higher WP version?
143 $requires_wp = $update_info['requires'] ?? '';
144 if (! empty($requires_wp)) {
145 $wp_version = get_bloginfo('version');
146 if (version_compare($wp_version, $requires_wp, '<')) {
147 $checks[] = [
148 'check' => 'wp_version',
149 'status' => 'fail',
150 'message' => sprintf(
151 'Update requires WordPress %s but you run %s.',
152 $requires_wp,
153 $wp_version
154 ),
155 ];
156 }
157 }
158
159 // Check: Is this a major version jump?
160 $current = $update_info['current_version'] ?? '';
161 $new = $update_info['new_version'] ?? '';
162 if (! empty($current) && ! empty($new)) {
163 $current_major = explode('.', $current)[0] ?? '0';
164 $new_major = explode('.', $new)[0] ?? '0';
165
166 if ($current_major !== $new_major) {
167 $checks[] = [
168 'check' => 'major_version',
169 'status' => 'warning',
170 'message' => sprintf(
171 'This is a major version update (%s to %s). Major updates have higher risk of breaking changes.',
172 $current,
173 $new
174 ),
175 ];
176 }
177 }
178
179 // Check: Does this plugin have known conflicts in the cloud?
180 $cloud = new \Jetstrike\ConflictDetector\Cloud\ConflictIntelligence();
181 $slug = dirname($plugin_file);
182 $active = $this->get_active_plugins_except($plugin_file);
183
184 foreach ($active as $other_plugin) {
185 $pair_check = $cloud->check_pair($slug, dirname($other_plugin));
186
187 if ($pair_check !== null && ! $pair_check['compatible']) {
188 $checks[] = [
189 'check' => 'cloud_intelligence',
190 'status' => 'warning',
191 'message' => sprintf(
192 'Known compatibility issue with %s (reported by %d sites, %.0f%% confidence).',
193 dirname($other_plugin),
194 $pair_check['reports'],
195 $pair_check['confidence'] * 100
196 ),
197 ];
198 }
199 }
200
201 $has_fails = count(array_filter($checks, fn(array $c): bool => $c['status'] === 'fail')) > 0;
202 $has_warnings = count(array_filter($checks, fn(array $c): bool => $c['status'] === 'warning')) > 0;
203
204 return [
205 'status' => $has_fails ? 'blocked' : ($has_warnings ? 'caution' : 'safe'),
206 'checks' => $checks,
207 'version' => $new,
208 'message' => $has_fails
209 ? 'Update is blocked — your environment does not meet the requirements.'
210 : ($has_warnings
211 ? 'Update has potential risks. Run a full pre-update simulation for details.'
212 : 'Update appears safe based on quick checks.'),
213 ];
214 }
215
216 /**
217 * Analyze the new version's code against other active plugins.
218 */
219 private function analyze_new_version(string $new_dir, string $plugin_file, array $other_plugins): array {
220 $all_conflicts = [];
221
222 // Run each analyzer against the new plugin code.
223 $analyzers = [
224 new StaticAnalyzer(),
225 new HookAnalyzer(),
226 new ResourceAnalyzer(),
227 new DependencyAnalyzer(),
228 new JavaScriptAnalyzer(),
229 new DatabaseAnalyzer(),
230 ];
231
232 // We need to temporarily make the analyzers think the new code is the plugin.
233 // Create a synthetic plugin list with the new version's path.
234 $test_plugins = $other_plugins;
235
236 foreach ($analyzers as $analyzer) {
237 $conflicts = $analyzer->analyze(array_merge([$plugin_file], $other_plugins));
238
239 // Filter to only conflicts involving the target plugin.
240 $relevant = array_filter($conflicts, function (array $c) use ($plugin_file): bool {
241 return ($c['plugin_a'] ?? '') === $plugin_file || ($c['plugin_b'] ?? '') === $plugin_file;
242 });
243
244 $all_conflicts = array_merge($all_conflicts, array_values($relevant));
245 }
246
247 // Also check WooCommerce if active.
248 if (class_exists('WooCommerce')) {
249 $woo = new WooCommerceAnalyzer();
250 $woo_conflicts = $woo->analyze(array_merge([$plugin_file], $other_plugins));
251 $relevant = array_filter($woo_conflicts, function (array $c) use ($plugin_file): bool {
252 return ($c['plugin_a'] ?? '') === $plugin_file || ($c['plugin_b'] ?? '') === $plugin_file;
253 });
254 $all_conflicts = array_merge($all_conflicts, array_values($relevant));
255 }
256
257 return $all_conflicts;
258 }
259
260 /**
261 * Diff two sets of conflicts to find what's new, removed, and unchanged.
262 */
263 private function diff_conflicts(array $before, array $after): array {
264 $before_keys = array_map([$this, 'conflict_key'], $before);
265 $after_keys = array_map([$this, 'conflict_key'], $after);
266
267 $added = [];
268 $removed = [];
269 $unchanged = [];
270
271 foreach ($after as $i => $conflict) {
272 if (! in_array($after_keys[$i], $before_keys, true)) {
273 $added[] = $conflict;
274 } else {
275 $unchanged[] = $conflict;
276 }
277 }
278
279 foreach ($before as $i => $conflict) {
280 if (! in_array($before_keys[$i], $after_keys, true)) {
281 $removed[] = $conflict;
282 }
283 }
284
285 return [
286 'added' => $added,
287 'removed' => $removed,
288 'unchanged' => $unchanged,
289 ];
290 }
291
292 /**
293 * Generate a unique key for a conflict (for diffing).
294 */
295 private function conflict_key(array $conflict): string {
296 return md5(
297 ($conflict['type'] ?? '') . ':' .
298 ($conflict['plugin_a'] ?? '') . ':' .
299 ($conflict['plugin_b'] ?? '')
300 );
301 }
302
303 /**
304 * Calculate a risk score (0-100) based on the diff.
305 */
306 private function calculate_risk_score(array $diff): int {
307 $score = 0;
308
309 $severity_weights = [
310 'critical' => 40,
311 'high' => 25,
312 'medium' => 10,
313 'low' => 5,
314 ];
315
316 foreach ($diff['added'] as $conflict) {
317 $severity = $conflict['severity'] ?? 'medium';
318 $score += $severity_weights[$severity] ?? 10;
319 }
320
321 // Resolved conflicts reduce risk slightly.
322 foreach ($diff['removed'] as $conflict) {
323 $severity = $conflict['severity'] ?? 'medium';
324 $score -= (int) (($severity_weights[$severity] ?? 10) * 0.3);
325 }
326
327 return max(0, min(100, $score));
328 }
329
330 /**
331 * Detect high-level code changes between versions.
332 */
333 private function detect_code_changes(string $current_dir, string $new_dir): array {
334 $changes = [
335 'files_added' => 0,
336 'files_removed' => 0,
337 'files_modified' => 0,
338 'hooks_changed' => false,
339 'db_schema_changed' => false,
340 ];
341
342 $current_files = $this->list_php_files($current_dir);
343 $new_files = $this->list_php_files($new_dir);
344
345 $current_relative = array_map(fn(string $f): string => str_replace($current_dir, '', $f), $current_files);
346 $new_relative = array_map(fn(string $f): string => str_replace($new_dir, '', $f), $new_files);
347
348 $changes['files_added'] = count(array_diff($new_relative, $current_relative));
349 $changes['files_removed'] = count(array_diff($current_relative, $new_relative));
350
351 // Check common files for modifications.
352 $common = array_intersect($current_relative, $new_relative);
353 foreach ($common as $relative_path) {
354 $current_hash = md5_file($current_dir . $relative_path);
355 $new_hash = md5_file($new_dir . $relative_path);
356
357 if ($current_hash !== $new_hash) {
358 $changes['files_modified']++;
359
360 // Check if hook registrations changed.
361 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
362 $new_content = file_get_contents($new_dir . $relative_path);
363
364 if ($new_content !== false) {
365 if (preg_match('/add_(?:action|filter)\s*\(/', $new_content)) {
366 $changes['hooks_changed'] = true;
367 }
368
369 if (preg_match('/CREATE\s+TABLE|dbDelta/i', $new_content)) {
370 $changes['db_schema_changed'] = true;
371 }
372 }
373 }
374 }
375
376 return $changes;
377 }
378
379 /**
380 * Generate a human-readable summary.
381 */
382 private function generate_summary(array $diff, int $risk_score, string $plugin_slug): string {
383 $added = count($diff['added']);
384 $removed = count($diff['removed']);
385
386 if ($added === 0 && $removed === 0) {
387 return sprintf('Update for %s introduces no new conflicts. Safe to proceed.', $plugin_slug);
388 }
389
390 $parts = [];
391
392 if ($added > 0) {
393 $critical = count(array_filter($diff['added'], fn(array $c): bool => ($c['severity'] ?? '') === 'critical'));
394
395 $parts[] = sprintf('%d new conflict(s) detected', $added);
396
397 if ($critical > 0) {
398 $parts[] = sprintf('%d critical', $critical);
399 }
400 }
401
402 if ($removed > 0) {
403 $parts[] = sprintf('%d existing conflict(s) would be resolved', $removed);
404 }
405
406 $risk_label = $risk_score >= 70 ? 'HIGH RISK' : ($risk_score >= 30 ? 'MODERATE RISK' : 'LOW RISK');
407
408 return sprintf(
409 '%s update for %s: %s. Risk score: %d/100.',
410 $risk_label,
411 $plugin_slug,
412 implode('; ', $parts),
413 $risk_score
414 );
415 }
416
417 /**
418 * Get WordPress update info for a plugin.
419 */
420 private function get_update_info(string $plugin_file): ?array {
421 $updates = get_site_transient('update_plugins');
422
423 if (! is_object($updates) || ! isset($updates->response[$plugin_file])) {
424 return null;
425 }
426
427 $update = $updates->response[$plugin_file];
428 $current_data = get_plugin_data(WP_PLUGIN_DIR . '/' . $plugin_file, false, false);
429
430 return [
431 'new_version' => $update->new_version ?? '',
432 'current_version' => $current_data['Version'] ?? '',
433 'package' => $update->package ?? '',
434 'requires_php' => $update->requires_php ?? '',
435 'requires' => $update->requires ?? '',
436 'tested' => $update->tested ?? '',
437 ];
438 }
439
440 /**
441 * Download and extract an update package to a temp directory.
442 */
443 private function download_and_extract(string $url, string $plugin_slug): ?string {
444 require_once ABSPATH . 'wp-admin/includes/file.php';
445
446 $temp_file = download_url($url, 60);
447
448 if (is_wp_error($temp_file)) {
449 return null;
450 }
451
452 $temp_dir = get_temp_dir() . self::TEMP_PREFIX . $plugin_slug . '_' . time();
453 wp_mkdir_p($temp_dir);
454
455 $result = unzip_file($temp_file, $temp_dir);
456 wp_delete_file($temp_file);
457
458 if (is_wp_error($result)) {
459 $this->cleanup_temp($temp_dir);
460 return null;
461 }
462
463 return $temp_dir;
464 }
465
466 /**
467 * Get active plugins excluding the target.
468 */
469 private function get_active_plugins_except(string $exclude): array {
470 $active = get_option('active_plugins', []);
471
472 return array_values(array_filter($active, function (string $p) use ($exclude): bool {
473 return $p !== $exclude && $p !== JETSTRIKE_CD_BASENAME;
474 }));
475 }
476
477 /**
478 * List PHP files in a directory recursively.
479 */
480 private function list_php_files(string $dir): array {
481 if (! is_dir($dir)) {
482 return [];
483 }
484
485 $files = [];
486 $iterator = new \RecursiveIteratorIterator(
487 new \RecursiveDirectoryIterator($dir, \RecursiveDirectoryIterator::SKIP_DOTS)
488 );
489
490 $count = 0;
491 foreach ($iterator as $file) {
492 if (++$count > 500) {
493 break;
494 }
495
496 if ($file->isFile() && $file->getExtension() === 'php') {
497 $files[] = $file->getPathname();
498 }
499 }
500
501 return $files;
502 }
503
504 /**
505 * Clean up temp directory.
506 */
507 private function cleanup_temp(string $dir): void {
508 if (! is_dir($dir) || strpos($dir, self::TEMP_PREFIX) === false) {
509 return;
510 }
511
512 $iterator = new \RecursiveIteratorIterator(
513 new \RecursiveDirectoryIterator($dir, \RecursiveDirectoryIterator::SKIP_DOTS),
514 \RecursiveIteratorIterator::CHILD_FIRST
515 );
516
517 foreach ($iterator as $file) {
518 if ($file->isDir()) {
519 @rmdir($file->getPathname());
520 } else {
521 wp_delete_file($file->getPathname());
522 }
523 }
524
525 @rmdir($dir);
526 }
527
528 /**
529 * Return a standardized error result.
530 */
531 private function error_result(string $message): array {
532 return [
533 'safe' => false,
534 'new_conflicts' => [],
535 'resolved_conflicts' => [],
536 'unchanged_conflicts' => [],
537 'risk_score' => -1,
538 'update_version' => '',
539 'current_version' => '',
540 'summary' => $message,
541 'changes_detected' => [],
542 ];
543 }
544}
Addedincludes/Export/ExportManager.php+320−0View fileUnifiedSplit
1<?php
2/**
3 * Export/Import Manager — share conflict profiles between sites.
4 *
5 * Agencies managing multiple WordPress sites can:
6 * - Export a site's conflict data as a portable JSON file
7 * - Import conflict profiles from other sites to pre-check compatibility
8 * - Compare conflict profiles across multiple client sites
9 *
10 * @package Jetstrike\ConflictDetector
11 */
12
13declare(strict_types=1);
14
15namespace Jetstrike\ConflictDetector\Export;
16
17use Jetstrike\ConflictDetector\Database\Repository;
18
19final class ExportManager {
20
21 /** Export format version for forward compatibility. */
22 private const FORMAT_VERSION = '1.0';
23
24 /** @var Repository */
25 private Repository $repository;
26
27 public function __construct(Repository $repository) {
28 $this->repository = $repository;
29 }
30
31 /**
32 * Export the site's conflict data as a JSON structure.
33 *
34 * @param array $options Export options.
35 * @return array{json: string, filename: string, stats: array}
36 */
37 public function export(array $options = []): array {
38 $include_scans = $options['include_scans'] ?? true;
39 $include_resolved = $options['include_resolved'] ?? false;
40
41 $export_data = [
42 'format_version' => self::FORMAT_VERSION,
43 'exported_at' => current_time('c'),
44 'site' => [
45 'url' => get_site_url(),
46 'name' => get_bloginfo('name'),
47 'wp_version' => get_bloginfo('version'),
48 'php_version' => PHP_VERSION,
49 'wc_version' => defined('WC_VERSION') ? WC_VERSION : null,
50 'plugin_count' => count(get_option('active_plugins', [])),
51 ],
52 'plugins' => $this->export_plugin_data(),
53 'conflicts' => $this->export_conflicts($include_resolved),
54 ];
55
56 if ($include_scans) {
57 $export_data['scans'] = $this->export_scans();
58 }
59
60 $stats = [
61 'plugins' => count($export_data['plugins']),
62 'conflicts' => count($export_data['conflicts']),
63 'scans' => count($export_data['scans'] ?? []),
64 ];
65
66 $filename = sprintf(
67 'jetstrike-export-%s-%s.json',
68 sanitize_title(get_bloginfo('name')),
69 gmdate('Y-m-d-His')
70 );
71
72 return [
73 'json' => wp_json_encode($export_data, JSON_PRETTY_PRINT),
74 'filename' => $filename,
75 'stats' => $stats,
76 ];
77 }
78
79 /**
80 * Import conflict data from a JSON export.
81 *
82 * @param string $json JSON string from export.
83 * @param array $options Import options.
84 * @return array{success: bool, imported: int, skipped: int, errors: array, message: string}
85 */
86 public function import(string $json, array $options = []): array {
87 $data = json_decode($json, true);
88
89 if (! is_array($data)) {
90 return [
91 'success' => false,
92 'imported' => 0,
93 'skipped' => 0,
94 'errors' => ['Invalid JSON format.'],
95 'message' => 'Failed to parse the import file.',
96 ];
97 }
98
99 // Validate format.
100 if (! isset($data['format_version'])) {
101 return [
102 'success' => false,
103 'imported' => 0,
104 'skipped' => 0,
105 'errors' => ['Missing format version. This may not be a Jetstrike export file.'],
106 'message' => 'Invalid export file format.',
107 ];
108 }
109
110 $merge = $options['merge'] ?? true;
111 $imported = 0;
112 $skipped = 0;
113 $errors = [];
114
115 // Import conflicts.
116 $conflicts = $data['conflicts'] ?? [];
117
118 foreach ($conflicts as $conflict) {
119 // Validate required fields.
120 if (empty($conflict['plugin_a']) || empty($conflict['type'])) {
121 $skipped++;
122 continue;
123 }
124
125 // Check for duplicates.
126 if ($merge && $this->repository->conflict_exists(
127 $conflict['plugin_a'],
128 $conflict['plugin_b'] ?? '',
129 $conflict['type']
130 )) {
131 $skipped++;
132 continue;
133 }
134
135 try {
136 $this->repository->create_conflict([
137 'scan_id' => 0,
138 'plugin_a' => sanitize_text_field($conflict['plugin_a']),
139 'plugin_b' => sanitize_text_field($conflict['plugin_b'] ?? ''),
140 'conflict_type' => sanitize_text_field($conflict['type']),
141 'severity' => sanitize_text_field($conflict['severity'] ?? 'medium'),
142 'description' => sanitize_text_field($conflict['description'] ?? ''),
143 'technical_details' => wp_json_encode($conflict['details'] ?? []),
144 'recommendation' => sanitize_text_field($conflict['recommendation'] ?? ''),
145 ]);
146
147 $imported++;
148 } catch (\Exception $e) {
149 $errors[] = sprintf('Failed to import conflict: %s', $e->getMessage());
150 }
151 }
152
153 // Clear caches.
154 delete_transient('jetstrike_cd_conflict_summary');
155
156 $message = sprintf(
157 'Import complete: %d conflict(s) imported, %d skipped (duplicates).',
158 $imported,
159 $skipped
160 );
161
162 if (! empty($errors)) {
163 $message .= sprintf(' %d error(s) occurred.', count($errors));
164 }
165
166 return [
167 'success' => true,
168 'imported' => $imported,
169 'skipped' => $skipped,
170 'errors' => $errors,
171 'message' => $message,
172 ];
173 }
174
175 /**
176 * Compare this site's conflicts with an imported profile.
177 *
178 * Useful for agencies checking if a client's site has known issues.
179 *
180 * @param string $json JSON export from another site.
181 * @return array Comparison results.
182 */
183 public function compare(string $json): array {
184 $data = json_decode($json, true);
185
186 if (! is_array($data) || ! isset($data['conflicts'])) {
187 return ['error' => 'Invalid export data.'];
188 }
189
190 $local_conflicts = $this->repository->list_active_conflicts(1, 500);
191 $remote_conflicts = $data['conflicts'] ?? [];
192
193 // Find matching plugins.
194 $local_plugins = array_map('dirname', get_option('active_plugins', []));
195 $remote_plugins = array_column($data['plugins'] ?? [], 'slug');
196 $common_plugins = array_intersect($local_plugins, $remote_plugins);
197
198 // Find conflicts that affect plugins we also have.
199 $relevant_remote = [];
200
201 foreach ($remote_conflicts as $conflict) {
202 $a = dirname($conflict['plugin_a'] ?? '');
203 $b = dirname($conflict['plugin_b'] ?? '');
204
205 if (in_array($a, $common_plugins, true) || in_array($b, $common_plugins, true)) {
206 $relevant_remote[] = $conflict;
207 }
208 }
209
210 // Find conflicts present remotely but not locally.
211 $local_keys = [];
212 foreach ($local_conflicts as $c) {
213 $local_keys[] = $c->conflict_type . ':' . $c->plugin_a . ':' . $c->plugin_b;
214 }
215
216 $warnings = [];
217
218 foreach ($relevant_remote as $rc) {
219 $key = ($rc['type'] ?? '') . ':' . ($rc['plugin_a'] ?? '') . ':' . ($rc['plugin_b'] ?? '');
220
221 if (! in_array($key, $local_keys, true)) {
222 $warnings[] = [
223 'type' => $rc['type'] ?? 'unknown',
224 'plugin_a' => $rc['plugin_a'] ?? '',
225 'plugin_b' => $rc['plugin_b'] ?? '',
226 'severity' => $rc['severity'] ?? 'medium',
227 'description' => $rc['description'] ?? '',
228 'source_site' => $data['site']['url'] ?? 'unknown',
229 ];
230 }
231 }
232
233 return [
234 'common_plugins' => count($common_plugins),
235 'remote_conflicts' => count($remote_conflicts),
236 'relevant_to_you' => count($relevant_remote),
237 'new_warnings' => $warnings,
238 'source_site' => $data['site']['url'] ?? '',
239 'source_wp_version' => $data['site']['wp_version'] ?? '',
240 ];
241 }
242
243 /**
244 * Export plugin data.
245 */
246 private function export_plugin_data(): array {
247 $active = get_option('active_plugins', []);
248 $plugins = [];
249
250 foreach ($active as $file) {
251 if ($file === JETSTRIKE_CD_BASENAME) {
252 continue;
253 }
254
255 $data = get_plugin_data(WP_PLUGIN_DIR . '/' . $file, false, false);
256
257 $plugins[] = [
258 'slug' => dirname($file),
259 'file' => $file,
260 'name' => $data['Name'] ?? dirname($file),
261 'version' => $data['Version'] ?? '',
262 'author' => $data['Author'] ?? '',
263 ];
264 }
265
266 return $plugins;
267 }
268
269 /**
270 * Export conflict data.
271 */
272 private function export_conflicts(bool $include_resolved): array {
273 $conflicts = $include_resolved
274 ? $this->repository->list_active_conflicts(1, 500)
275 : $this->repository->list_active_conflicts(1, 500);
276
277 $exported = [];
278
279 foreach ($conflicts as $c) {
280 if (! $include_resolved && $c->status !== 'active') {
281 continue;
282 }
283
284 $exported[] = [
285 'type' => $c->conflict_type,
286 'plugin_a' => $c->plugin_a,
287 'plugin_b' => $c->plugin_b,
288 'severity' => $c->severity,
289 'description' => $c->description,
290 'recommendation' => $c->recommendation,
291 'status' => $c->status,
292 'detected_at' => $c->detected_at,
293 'details' => json_decode($c->technical_details ?? '{}', true),
294 ];
295 }
296
297 return $exported;
298 }
299
300 /**
301 * Export scan history.
302 */
303 private function export_scans(): array {
304 $scans = $this->repository->list_scans(1, 50);
305 $exported = [];
306
307 foreach ($scans as $scan) {
308 $exported[] = [
309 'type' => $scan->scan_type,
310 'status' => $scan->status,
311 'conflicts_found' => (int) $scan->conflicts_found,
312 'triggered_by' => $scan->triggered_by,
313 'started_at' => $scan->started_at,
314 'completed_at' => $scan->completed_at,
315 ];
316 }
317
318 return $exported;
319 }
320}
Addedincludes/Multisite/NetworkScanner.php+220−0View fileUnifiedSplit
1<?php
2/**
3 * Network Scanner — scans across all sites in a WordPress Multisite network.
4 *
5 * Agency tier feature. Provides a network-wide view of plugin conflicts,
6 * showing which sites have issues and which plugins are causing the most
7 * problems across the network.
8 *
9 * @package Jetstrike\ConflictDetector
10 */
11
12declare(strict_types=1);
13
14namespace Jetstrike\ConflictDetector\Multisite;
15
16use Jetstrike\ConflictDetector\Database\Repository;
17use Jetstrike\ConflictDetector\Scanner\ScanEngine;
18use Jetstrike\ConflictDetector\Subscription\FeatureFlags;
19
20final class NetworkScanner {
21
22 /** @var Repository */
23 private Repository $repository;
24
25 public function __construct(Repository $repository) {
26 $this->repository = $repository;
27 }
28
29 /**
30 * Check if multisite scanning is available.
31 */
32 public static function is_available(): bool {
33 return is_multisite() && FeatureFlags::can('multisite_support');
34 }
35
36 /**
37 * Get a network-wide overview of all sites and their conflict status.
38 *
39 * @return array Network overview data.
40 */
41 public function get_network_overview(): array {
42 if (! is_multisite()) {
43 return ['error' => 'Not a multisite installation.'];
44 }
45
46 $sites = get_sites(['number' => 200]);
47 $overview = [
48 'total_sites' => count($sites),
49 'sites_with_issues' => 0,
50 'total_conflicts' => 0,
51 'network_plugins' => $this->get_network_plugin_stats(),
52 'sites' => [],
53 ];
54
55 foreach ($sites as $site) {
56 switch_to_blog((int) $site->blog_id);
57
58 $site_data = $this->scan_site((int) $site->blog_id);
59 $overview['sites'][] = $site_data;
60
61 if ($site_data['conflict_count'] > 0) {
62 $overview['sites_with_issues']++;
63 $overview['total_conflicts'] += $site_data['conflict_count'];
64 }
65
66 restore_current_blog();
67 }
68
69 // Sort sites by conflict count (worst first).
70 usort($overview['sites'], function (array $a, array $b): int {
71 return $b['conflict_count'] <=> $a['conflict_count'];
72 });
73
74 return $overview;
75 }
76
77 /**
78 * Run a quick scan on a specific site.
79 *
80 * @param int $blog_id Blog ID to scan.
81 * @return array Scan results for the site.
82 */
83 public function scan_site(int $blog_id): array {
84 $site_url = get_site_url($blog_id);
85 $site_name = get_bloginfo('name');
86 $active_plugins = get_option('active_plugins', []);
87
88 // Filter out Jetstrike itself.
89 $plugins = array_values(array_filter($active_plugins, function (string $p): bool {
90 return $p !== JETSTRIKE_CD_BASENAME;
91 }));
92
93 // Check for existing conflicts in this site's database.
94 $conflicts = $this->repository->list_active_conflicts(1, 100);
95 $summary = $this->repository->get_conflict_summary();
96
97 return [
98 'blog_id' => $blog_id,
99 'url' => $site_url,
100 'name' => $site_name,
101 'plugin_count' => count($plugins),
102 'conflict_count' => array_sum($summary),
103 'critical_count' => $summary['critical'] ?? 0,
104 'high_count' => $summary['high'] ?? 0,
105 'plugins' => array_map('dirname', $plugins),
106 'last_scan' => $this->get_last_scan_date(),
107 'health_grade' => $this->get_quick_grade($summary),
108 ];
109 }
110
111 /**
112 * Run a network-wide scan across all sites.
113 *
114 * @return array Results per site.
115 */
116 public function scan_network(): array {
117 if (! is_multisite()) {
118 return ['error' => 'Not a multisite installation.'];
119 }
120
121 $sites = get_sites(['number' => 200]);
122 $results = [];
123
124 foreach ($sites as $site) {
125 switch_to_blog((int) $site->blog_id);
126
127 $plugins = get_option('active_plugins', []);
128 $plugins = array_values(array_filter($plugins, function (string $p): bool {
129 return $p !== JETSTRIKE_CD_BASENAME;
130 }));
131
132 if (count($plugins) >= 2) {
133 $engine = new ScanEngine($this->repository);
134 $conflicts = $engine->run_static_analysis($plugins);
135
136 $results[] = [
137 'blog_id' => (int) $site->blog_id,
138 'url' => get_site_url(),
139 'name' => get_bloginfo('name'),
140 'plugins' => count($plugins),
141 'conflicts' => $conflicts,
142 'count' => count($conflicts),
143 ];
144 }
145
146 restore_current_blog();
147 }
148
149 return $results;
150 }
151
152 /**
153 * Find plugins causing the most conflicts across the network.
154 *
155 * @return array Plugin conflict frequency data.
156 */
157 public function get_most_problematic_plugins(): array {
158 $overview = $this->get_network_overview();
159 $plugin_problems = [];
160
161 foreach ($overview['sites'] as $site) {
162 // We'd need to query each site's conflicts for this data.
163 // For now, just count active conflicts per plugin.
164 }
165
166 return $plugin_problems;
167 }
168
169 /**
170 * Get network-activated plugin statistics.
171 */
172 private function get_network_plugin_stats(): array {
173 $network_plugins = get_site_option('active_sitewide_plugins', []);
174
175 return [
176 'network_active_count' => count($network_plugins),
177 'network_plugins' => array_map('dirname', array_keys($network_plugins)),
178 ];
179 }
180
181 /**
182 * Get the date of the last scan on the current site.
183 */
184 private function get_last_scan_date(): ?string {
185 $latest = $this->repository->get_latest_scan();
186 return $latest ? ($latest->completed_at ?? $latest->created_at) : null;
187 }
188
189 /**
190 * Calculate a quick health grade from a conflict summary.
191 */
192 private function get_quick_grade(array $summary): string {
193 $critical = $summary['critical'] ?? 0;
194 $high = $summary['high'] ?? 0;
195 $medium = $summary['medium'] ?? 0;
196 $total = array_sum($summary);
197
198 if ($total === 0) {
199 return 'A';
200 }
201
202 if ($critical > 0) {
203 return 'F';
204 }
205
206 if ($high > 2) {
207 return 'D';
208 }
209
210 if ($high > 0 || $medium > 5) {
211 return 'C';
212 }
213
214 if ($medium > 0) {
215 return 'B';
216 }
217
218 return 'A';
219 }
220}
Addedincludes/Report/ReportGenerator.php+329−0View fileUnifiedSplit
1<?php
2/**
3 * Conflict Report Generator — produces professional HTML reports.
4 *
5 * Agencies managing multiple client sites need professional reports
6 * they can share with clients or attach to maintenance invoices.
7 *
8 * Generates a self-contained HTML document with:
9 * - Site health score and grade
10 * - Conflict summary with severity breakdown
11 * - Full conflict details with recommendations
12 * - Plugin compatibility matrix
13 * - Scan history timeline
14 * - Actionable next steps
15 *
16 * @package Jetstrike\ConflictDetector
17 */
18
19declare(strict_types=1);
20
21namespace Jetstrike\ConflictDetector\Report;
22
23use Jetstrike\ConflictDetector\Database\Repository;
24use Jetstrike\ConflictDetector\Monitor\HealthMonitor;
25use Jetstrike\ConflictDetector\Resolver\AutoResolver;
26
27final class ReportGenerator {
28
29 /** @var Repository */
30 private Repository $repository;
31
32 public function __construct(Repository $repository) {
33 $this->repository = $repository;
34 }
35
36 /**
37 * Generate a full conflict report.
38 *
39 * @param int|null $scan_id Specific scan ID, or null for latest.
40 * @return array{html: string, filename: string, generated_at: string}
41 */
42 public function generate(int $scan_id = null): array {
43 $scan = $scan_id
44 ? $this->repository->get_scan($scan_id)
45 : $this->repository->get_latest_scan();
46
47 $conflicts = $scan
48 ? $this->repository->get_conflicts_for_scan((int) $scan->id)
49 : [];
50
51 $all_active = $this->repository->list_active_conflicts(1, 200);
52 $health_monitor = new HealthMonitor($this->repository);
53 $health = $health_monitor->get_health_data();
54
55 $site_url = get_site_url();
56 $site_name = get_bloginfo('name');
57 $generated_at = current_time('mysql');
58
59 $html = $this->build_html([
60 'site_name' => $site_name,
61 'site_url' => $site_url,
62 'generated_at' => $generated_at,
63 'health' => $health,
64 'scan' => $scan,
65 'conflicts' => $conflicts,
66 'all_active' => $all_active,
67 'summary' => $this->build_summary($all_active),
68 'plugins' => $this->get_plugin_list(),
69 ]);
70
71 $filename = sprintf(
72 'jetstrike-report-%s-%s.html',
73 sanitize_title($site_name),
74 gmdate('Y-m-d')
75 );
76
77 return [
78 'html' => $html,
79 'filename' => $filename,
80 'generated_at' => $generated_at,
81 ];
82 }
83
84 /**
85 * Build the full HTML report.
86 */
87 private function build_html(array $data): string {
88 $grade_colors = [
89 'A' => '#22c55e', 'B' => '#84cc16', 'C' => '#eab308',
90 'D' => '#f97316', 'F' => '#ef4444',
91 ];
92 $grade_color = $grade_colors[$data['health']['grade'] ?? 'F'] ?? '#6b7280';
93
94 $html = '<!DOCTYPE html><html lang="en"><head>';
95 $html .= '<meta charset="UTF-8">';
96 $html .= '<meta name="viewport" content="width=device-width, initial-scale=1.0">';
97 $html .= '<title>Plugin Conflict Report — ' . esc_html($data['site_name']) . '</title>';
98 $html .= '<style>' . $this->get_report_css() . '</style>';
99 $html .= '</head><body>';
100
101 // Header.
102 $html .= '<div class="report-header">';
103 $html .= '<div class="report-logo">Jetstrike</div>';
104 $html .= '<div class="report-meta">';
105 $html .= '<h1>Plugin Conflict Report</h1>';
106 $html .= '<p>' . esc_html($data['site_name']) . ' &mdash; ' . esc_url($data['site_url']) . '</p>';
107 $html .= '<p>Generated: ' . esc_html($data['generated_at']) . '</p>';
108 $html .= '</div></div>';
109
110 // Health Score.
111 $score = (int) ($data['health']['score'] ?? 0);
112 $grade = $data['health']['grade'] ?? 'F';
113
114 $html .= '<div class="report-section">';
115 $html .= '<div class="health-banner" style="border-left-color: ' . $grade_color . '">';
116 $html .= '<div class="health-grade" style="background: ' . $grade_color . '">' . esc_html($grade) . '</div>';
117 $html .= '<div class="health-info">';
118 $html .= '<h2>Site Health Score: ' . $score . '/100</h2>';
119 $html .= '<p>' . $this->grade_description($grade) . '</p>';
120 $html .= '</div></div></div>';
121
122 // Summary.
123 $summary = $data['summary'];
124 $html .= '<div class="report-section">';
125 $html .= '<h2>Conflict Summary</h2>';
126 $html .= '<div class="summary-grid">';
127
128 foreach (['critical', 'high', 'medium', 'low'] as $severity) {
129 $count = $summary['by_severity'][$severity] ?? 0;
130 $html .= '<div class="summary-card summary-card--' . $severity . '">';
131 $html .= '<div class="summary-count">' . $count . '</div>';
132 $html .= '<div class="summary-label">' . ucfirst($severity) . '</div>';
133 $html .= '</div>';
134 }
135
136 $html .= '</div></div>';
137
138 // Active plugins.
139 $plugins = $data['plugins'];
140 $html .= '<div class="report-section">';
141 $html .= '<h2>Active Plugins (' . count($plugins) . ')</h2>';
142 $html .= '<table class="report-table"><thead><tr><th>Plugin</th><th>Version</th><th>Conflicts</th></tr></thead><tbody>';
143
144 foreach ($plugins as $plugin) {
145 $conflict_count = 0;
146 foreach ($data['all_active'] as $c) {
147 if ($c->plugin_a === $plugin['file'] || $c->plugin_b === $plugin['file']) {
148 $conflict_count++;
149 }
150 }
151
152 $status_class = $conflict_count > 0 ? 'status--conflict' : 'status--clean';
153 $html .= '<tr>';
154 $html .= '<td><strong>' . esc_html($plugin['name']) . '</strong></td>';
155 $html .= '<td>' . esc_html($plugin['version']) . '</td>';
156 $html .= '<td class="' . $status_class . '">' . ($conflict_count > 0 ? $conflict_count . ' conflict(s)' : 'Clean') . '</td>';
157 $html .= '</tr>';
158 }
159
160 $html .= '</tbody></table></div>';
161
162 // Conflict details.
163 if (! empty($data['all_active'])) {
164 $html .= '<div class="report-section">';
165 $html .= '<h2>Active Conflicts (' . count($data['all_active']) . ')</h2>';
166
167 foreach ($data['all_active'] as $conflict) {
168 $can_fix = AutoResolver::can_auto_resolve($conflict->conflict_type);
169
170 $html .= '<div class="conflict-card conflict-card--' . esc_attr($conflict->severity) . '">';
171 $html .= '<div class="conflict-header">';
172 $html .= '<span class="severity-badge severity-badge--' . esc_attr($conflict->severity) . '">';
173 $html .= strtoupper(esc_html($conflict->severity)) . '</span>';
174 $html .= '<span class="conflict-type">' . esc_html(str_replace('_', ' ', $conflict->conflict_type)) . '</span>';
175
176 if ($can_fix['can_resolve']) {
177 $html .= '<span class="autofix-badge">Auto-Fix Available</span>';
178 }
179
180 $html .= '</div>';
181 $html .= '<p class="conflict-desc">' . esc_html($conflict->description) . '</p>';
182 $html .= '<div class="conflict-plugins">';
183 $html .= '<code>' . esc_html(dirname($conflict->plugin_a)) . '</code>';
184
185 if (! empty($conflict->plugin_b)) {
186 $html .= ' vs <code>' . esc_html(dirname($conflict->plugin_b)) . '</code>';
187 }
188
189 $html .= '</div>';
190
191 if (! empty($conflict->recommendation)) {
192 $html .= '<div class="conflict-recommendation">';
193 $html .= '<strong>Recommendation:</strong> ' . esc_html($conflict->recommendation);
194 $html .= '</div>';
195 }
196
197 $html .= '</div>';
198 }
199
200 $html .= '</div>';
201 }
202
203 // Footer.
204 $html .= '<div class="report-footer">';
205 $html .= '<p>Generated by <strong>Jetstrike Conflict Detector</strong> v' . JETSTRIKE_CD_VERSION . '</p>';
206 $html .= '<p><a href="https://jetstrike.io">jetstrike.io</a></p>';
207 $html .= '</div>';
208
209 $html .= '</body></html>';
210
211 return $html;
212 }
213
214 /**
215 * Build conflict summary statistics.
216 */
217 private function build_summary(array $conflicts): array {
218 $summary = [
219 'by_severity' => ['critical' => 0, 'high' => 0, 'medium' => 0, 'low' => 0],
220 'by_type' => [],
221 'total' => count($conflicts),
222 ];
223
224 foreach ($conflicts as $conflict) {
225 $severity = $conflict->severity ?? 'medium';
226 $type = $conflict->conflict_type ?? 'unknown';
227
228 $summary['by_severity'][$severity] = ($summary['by_severity'][$severity] ?? 0) + 1;
229 $summary['by_type'][$type] = ($summary['by_type'][$type] ?? 0) + 1;
230 }
231
232 return $summary;
233 }
234
235 /**
236 * Get active plugin list.
237 */
238 private function get_plugin_list(): array {
239 $active = get_option('active_plugins', []);
240 $plugins = [];
241
242 foreach ($active as $file) {
243 if ($file === JETSTRIKE_CD_BASENAME) {
244 continue;
245 }
246
247 $data = get_plugin_data(WP_PLUGIN_DIR . '/' . $file, false, false);
248 $plugins[] = [
249 'file' => $file,
250 'name' => $data['Name'] ?: dirname($file),
251 'version' => $data['Version'] ?? '',
252 ];
253 }
254
255 return $plugins;
256 }
257
258 /**
259 * Get description for a health grade.
260 */
261 private function grade_description(string $grade): string {
262 return match ($grade) {
263 'A' => 'Excellent — your site has no significant plugin conflicts.',
264 'B' => 'Good — minor issues detected that should be monitored.',
265 'C' => 'Fair — several conflicts found that may affect site stability.',
266 'D' => 'Poor — significant conflicts require attention.',
267 'F' => 'Critical — your site has serious conflicts that need immediate action.',
268 default => 'Unknown health status.',
269 };
270 }
271
272 /**
273 * Self-contained CSS for the report.
274 */
275 private function get_report_css(): string {
276 return <<<'CSS'
277* { margin: 0; padding: 0; box-sizing: border-box; }
278body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; color: #1e293b; line-height: 1.6; max-width: 900px; margin: 0 auto; padding: 40px 24px; background: #fff; }
279.report-header { display: flex; align-items: center; gap: 24px; padding-bottom: 24px; border-bottom: 2px solid #e2e8f0; margin-bottom: 32px; }
280.report-logo { font-size: 28px; font-weight: 800; color: #2563eb; letter-spacing: -0.5px; }
281.report-meta h1 { font-size: 20px; font-weight: 600; }
282.report-meta p { color: #64748b; font-size: 14px; }
283.report-section { margin-bottom: 32px; }
284.report-section h2 { font-size: 18px; font-weight: 600; margin-bottom: 16px; padding-bottom: 8px; border-bottom: 1px solid #e2e8f0; }
285.health-banner { display: flex; align-items: center; gap: 20px; padding: 20px; background: #f8fafc; border-radius: 8px; border-left: 4px solid; }
286.health-grade { width: 60px; height: 60px; border-radius: 50%; color: #fff; display: flex; align-items: center; justify-content: center; font-size: 28px; font-weight: 700; flex-shrink: 0; }
287.health-info h2 { font-size: 18px; border: 0; padding: 0; margin: 0; }
288.health-info p { color: #64748b; margin-top: 4px; }
289.summary-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; }
290.summary-card { text-align: center; padding: 16px; border-radius: 8px; background: #f8fafc; }
291.summary-card--critical { border-left: 3px solid #ef4444; }
292.summary-card--high { border-left: 3px solid #f97316; }
293.summary-card--medium { border-left: 3px solid #eab308; }
294.summary-card--low { border-left: 3px solid #3b82f6; }
295.summary-count { font-size: 32px; font-weight: 700; }
296.summary-card--critical .summary-count { color: #ef4444; }
297.summary-card--high .summary-count { color: #f97316; }
298.summary-card--medium .summary-count { color: #eab308; }
299.summary-card--low .summary-count { color: #3b82f6; }
300.summary-label { font-size: 12px; text-transform: uppercase; letter-spacing: 1px; color: #64748b; }
301.report-table { width: 100%; border-collapse: collapse; }
302.report-table th, .report-table td { padding: 10px 12px; text-align: left; border-bottom: 1px solid #e2e8f0; font-size: 14px; }
303.report-table th { background: #f8fafc; font-weight: 600; font-size: 12px; text-transform: uppercase; letter-spacing: 0.5px; }
304.status--conflict { color: #ef4444; font-weight: 600; }
305.status--clean { color: #22c55e; }
306.conflict-card { padding: 16px; border-radius: 8px; margin-bottom: 12px; border: 1px solid #e2e8f0; border-left: 4px solid; }
307.conflict-card--critical { border-left-color: #ef4444; background: #fef2f2; }
308.conflict-card--high { border-left-color: #f97316; background: #fff7ed; }
309.conflict-card--medium { border-left-color: #eab308; background: #fefce8; }
310.conflict-card--low { border-left-color: #3b82f6; background: #eff6ff; }
311.conflict-header { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; flex-wrap: wrap; }
312.severity-badge { font-size: 11px; font-weight: 700; padding: 2px 8px; border-radius: 4px; color: #fff; }
313.severity-badge--critical { background: #ef4444; }
314.severity-badge--high { background: #f97316; }
315.severity-badge--medium { background: #eab308; }
316.severity-badge--low { background: #3b82f6; }
317.conflict-type { font-size: 13px; color: #64748b; text-transform: capitalize; }
318.autofix-badge { font-size: 11px; background: #2563eb; color: #fff; padding: 2px 8px; border-radius: 4px; margin-left: auto; }
319.conflict-desc { font-size: 14px; margin-bottom: 8px; }
320.conflict-plugins { margin-bottom: 8px; }
321.conflict-plugins code { background: #f1f5f9; padding: 2px 6px; border-radius: 3px; font-size: 13px; }
322.conflict-recommendation { font-size: 13px; color: #475569; padding: 8px 12px; background: #fff; border-radius: 4px; border: 1px solid #e2e8f0; }
323.report-footer { text-align: center; padding-top: 24px; border-top: 1px solid #e2e8f0; color: #94a3b8; font-size: 13px; }
324.report-footer a { color: #2563eb; text-decoration: none; }
325@media print { body { padding: 0; } .summary-grid { grid-template-columns: repeat(4, 1fr); } }
326@media (max-width: 600px) { .summary-grid { grid-template-columns: repeat(2, 1fr); } .health-banner { flex-direction: column; text-align: center; } }
327CSS;
328 }
329}
Modifiedtemplates/admin/dashboard.php+26−0View fileUnifiedSplit
128128 </div>
129129 </div>
130130
131 <!-- Toolbar: Report, Export, Matrix -->
132 <div class="jetstrike-cd-card jetstrike-cd-card--full">
133 <div class="jetstrike-cd-toolbar">
134 <button type="button" class="button" id="jetstrike-generate-report">
135 <span class="dashicons dashicons-media-document"></span>
136 <?php esc_html_e('Generate Report', 'jetstrike-cd'); ?>
137 </button>
138 <button type="button" class="button" id="jetstrike-export-data">
139 <span class="dashicons dashicons-download"></span>
140 <?php esc_html_e('Export Data', 'jetstrike-cd'); ?>
141 </button>
142 <button type="button" class="button" id="jetstrike-toggle-matrix">
143 <span class="dashicons dashicons-grid-view"></span>
144 <?php esc_html_e('Compatibility Matrix', 'jetstrike-cd'); ?>
145 </button>
146 </div>
147
148 <!-- Compatibility Matrix (hidden by default, toggled by button) -->
149 <div id="jetstrike-matrix-container" style="display: none;">
150 <?php
151 $matrix = new \Jetstrike\ConflictDetector\Admin\CompatibilityMatrix($this->repository ?? $repository);
152 echo $matrix->render_html(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- HTML is escaped internally
153 ?>
154 </div>
155 </div>
156
131157 <!-- Active Conflicts Table -->
132158 <?php if ($total_conflicts > 0 && ! empty($active_conflicts['items'])): ?>
133159 <div class="jetstrike-cd-card jetstrike-cd-card--full">
134160
c comment · e edit title · m merge · a approve · r request changes · ? shortcuts