Claude/continue previous work h hk dk #3830
45 changed files+10154−93
Added.github/workflows/ci.yml+47−0View fileUnifiedSplit
@@ -0,0 +1,47 @@
1name: CI
2
3on:
4 push:
5 branches: [main, 'claude/**']
6 pull_request:
7 branches: [main]
8
9jobs:
10 lint:
11 name: PHP lint (PHP ${{ matrix.php }})
12 runs-on: ubuntu-latest
13 strategy:
14 fail-fast: false
15 matrix:
16 php: ['7.4', '8.0', '8.1', '8.2', '8.3']
17 steps:
18 - name: Checkout
19 uses: actions/checkout@v4
20
21 - name: Set up PHP ${{ matrix.php }}
22 uses: shivammathur/setup-php@v2
23 with:
24 php-version: ${{ matrix.php }}
25 coverage: none
26 tools: composer:v2
27
28 - name: Validate composer.json
29 run: composer validate --strict --no-check-lock
30
31 - name: Lint PHP files
32 run: |
33 set -e
34 fail=0
35 while IFS= read -r -d '' file; do
36 if ! php -l "$file" > /dev/null 2>&1; then
37 echo "::error file=$file::PHP syntax error"
38 php -l "$file" || true
39 fail=1
40 fi
41 done < <(find . -type f -name '*.php' -not -path './vendor/*' -not -path './.git/*' -print0)
42 exit $fail
43
44 - name: Verify autoloader wiring
45 run: |
46 composer dump-autoload --no-interaction
47 php -r 'require "vendor/autoload.php"; echo "Autoloader OK\n";'
ModifiedCLAUDE.md+10−0View fileUnifiedSplit
@@ -49,6 +49,7 @@ jetstrike-conflict-detector/
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
@@ -69,6 +70,12 @@ jetstrike-conflict-detector/
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
@@ -354,6 +361,9 @@ All under namespace `jetstrike/v1`:
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
ModifiedREADME.md+118−2View fileUnifiedSplit
@@ -1,2 +1,118 @@
1# Intelligent-Plugin-Conflict-Detector
21. Intelligent Plugin Conflict Detector Store owners deal with their shops breaking whenever they update WooCommerce or install new plugins, and have to waste hours deactivating plugins one by one to find the culprit. They need an intelligent conflict detector that runs in the background, tests plugin combinations in a safe environment
1# Intelligent Plugin Conflict Detector
2
3A WordPress plugin that intelligently detects plugin conflicts, tests plugin
4combinations in a safe background environment, alerts store owners *before*
5conflicts cause downtime, and provides one-click rollback to a previously
6known-good state.
7
8## Features
9
10| Feature | Details |
11|---|---|
12| **Background Testing** | WP-Cron tests run every 6 hours (configurable) and also immediately after any plugin is activated or updated |
13| **Conflict Detection** | Probes the site home URL and the admin health endpoint for HTTP 5xx errors; scans the PHP error log for fatal errors introduced by recent plugin changes |
14| **Snapshot Manager** | Automatically captures the active-plugin list before every change; supports manual snapshots from the dashboard |
15| **One-Click Rollback** | Restore any snapshot with a single button; the current state is auto-snapshotted first so the rollback itself is reversible |
16| **Admin Dashboard** | Visual conflict list with severity badges, snapshot table, rollback history, and a "Run Test Now" button |
17| **Email Alerts** | Optional email notifications to the site admin whenever conflicts are detected |
18| **Auto Rollback** | Optional setting to automatically roll back when a critical conflict is detected |
19| **Plugins List Integration** | Highlights plugins with active conflicts directly on the Plugins screen |
20
21## Requirements
22
23* WordPress 5.8+
24* PHP 7.4+
25* WooCommerce (optional – the plugin works with any combination of plugins)
26
27## Installation
28
291. Upload the `intelligent-plugin-conflict-detector` folder to `/wp-content/plugins/`.
302. Activate the plugin through **Plugins → Installed Plugins**.
313. Navigate to **Tools → Conflict Detector** to view the dashboard.
32
33## Usage
34
35### Dashboard
36
37Open **Tools → Conflict Detector** in the WordPress admin:
38
39* **Conflicts tab** – lists all active (unresolved) conflicts with severity, plugin name, type, and message. Each conflict can be individually resolved. A *Clear All* button removes the entire log.
40* **Snapshots tab** – lists all captured plugin-state snapshots. Click **Rollback** next to any snapshot to restore that state.
41* **Rollback History tab** – audit trail of every rollback that has been performed.
42* **Settings link** – navigates to the settings page.
43
44### Settings
45
46Open **Tools → Conflict Detector → Settings** (or click the Settings tab):
47
48| Option | Default | Description |
49|---|---|---|
50| Email Notifications | Off | Send an email when conflicts are detected |
51| Alert Email Address | Admin email | Recipient for conflict alert emails |
52| Auto Rollback | Off | Automatically restore the previous snapshot on a critical conflict |
53| Scan Interval | 6 hours | How often the background scanner runs |
54
55### Running Tests Manually
56
57Click **Run Test Now** on the dashboard status bar. The detector will immediately probe the site home URL and admin health endpoint for HTTP errors and scan the error log, then display any new conflicts.
58
59### Creating Snapshots Manually
60
61Click **Create Snapshot** on the dashboard status bar. Enter an optional label and the current active-plugin list will be captured.
62
63## Developer Hooks
64
65| Hook | Type | Description |
66|---|---|---|
67| `ipcd_conflict_recorded` | Action | Fires after a conflict is recorded. Args: `$conflict` (array), `$conflict_id` (string) |
68| `ipcd_conflicts_detected` | Action | Fires after a test run finds conflicts. Args: `$plugin_basename`, `$event`, `$conflict_ids[]` |
69| `ipcd_before_test` | Action | Fires before a background test run. Args: `$plugin_basename`, `$event` |
70| `ipcd_after_test` | Action | Fires after a background test run. Args: `$plugin_basename`, `$event`, `$conflict_ids[]` |
71| `ipcd_rollback_complete` | Action | Fires after a successful rollback. Args: `$snapshot_id`, `$pre_rollback_id` |
72
73## Development
74
75```bash
76# Install PHP dependencies (PHPUnit + Brain\Monkey)
77cd intelligent-plugin-conflict-detector
78composer install
79
80# Run all unit tests
81./vendor/bin/phpunit
82```
83
84The test suite uses [Brain\Monkey](https://brain-wp.github.io/BrainMonkey/) to mock WordPress functions so no live WordPress installation is required.
85
86## File Structure
87
88```
89intelligent-plugin-conflict-detector/
90├── intelligent-plugin-conflict-detector.php Main plugin bootstrap
91├── includes/
92│ ├── class-plugin-state-manager.php Snapshot capture & restore
93│ ├── class-conflict-detector.php Conflict recording & retrieval
94│ ├── class-background-tester.php WP-Cron based background testing
95│ ├── class-rollback-manager.php One-click rollback
96│ └── class-notification-manager.php Admin notices & email alerts
97├── admin/
98│ ├── class-admin.php Admin menus & AJAX handlers
99│ ├── views/
100│ │ ├── dashboard.php Dashboard view
101│ │ └── settings.php Settings view
102│ └── assets/
103│ ├── css/admin.css
104│ └── js/admin.js
105├── tests/
106│ ├── bootstrap.php
107│ └── Unit/
108│ ├── ConflictDetectorTest.php
109│ ├── RollbackManagerTest.php
110│ ├── BackgroundTesterTest.php
111│ └── NotificationManagerTest.php
112├── composer.json
113└── phpunit.xml
114```
115
116## License
117
118GPL-2.0-or-later – see [https://www.gnu.org/licenses/gpl-2.0.html](https://www.gnu.org/licenses/gpl-2.0.html).
Modifiedassets/js/admin-dashboard.js+298−3View fileUnifiedSplit
@@ -35,6 +35,21 @@
3535 $(document).on('click', '#jetstrike-activate-license', this.activateLicense.bind(this));
3636 $(document).on('click', '#jetstrike-deactivate-license', this.deactivateLicense.bind(this));
3737
38 // Toolbar actions.
39 $(document).on('click', '#jetstrike-generate-report', this.generateReport.bind(this));
40 $(document).on('click', '#jetstrike-export-data', this.exportData.bind(this));
41 $(document).on('click', '#jetstrike-toggle-matrix', this.toggleMatrix.bind(this));
42
43 // Health check buttons.
44 $(document).on('click', '.jetstrike-cd-health-btn', this.runHealthCheck.bind(this));
45
46 // Import data.
47 $(document).on('click', '#jetstrike-import-data', this.importData.bind(this));
48 $(document).on('change', '#jetstrike-import-file', this.handleImportFile.bind(this));
49
50 // Pre-update check.
51 $(document).on('click', '.jetstrike-cd-preupdate-btn', this.preUpdateCheck.bind(this));
52
3853 // Settings form.
3954 $(document).on('click', '#jetstrike-save-settings', this.saveSettings.bind(this));
4055
@@ -203,14 +218,30 @@
203218 }
204219 autoFixCell += '</td>';
205220
221 var descriptionHtml = '';
222 if (conflict.ai_explanation) {
223 descriptionHtml = '<strong>' + JetstrikeCD.escapeHtml(conflict.ai_explanation) + '</strong>';
224 if (conflict.ai_impact) {
225 descriptionHtml += '<br><span style="color: #b91c1c; font-weight: 600;">' +
226 JetstrikeCD.escapeHtml(conflict.ai_impact) + '</span>';
227 }
228 descriptionHtml += '<br><em style="color: #94a3b8; font-size: 12px;">Technical: ' +
229 JetstrikeCD.escapeHtml(conflict.description) + '</em>';
230 } else {
231 descriptionHtml = '<strong>' + JetstrikeCD.escapeHtml(conflict.description) + '</strong>';
232 }
233
234 if (conflict.recommendation) {
235 descriptionHtml += '<br><em class="jetstrike-cd-recommendation">' +
236 JetstrikeCD.escapeHtml(conflict.recommendation) + '</em>';
237 }
238
206239 $table.append(
207240 '<tr data-conflict-id="' + conflict.id + '">' +
208241 '<td><span class="jetstrike-cd-badge jetstrike-cd-badge--' + conflict.severity + '">' +
209242 JetstrikeCD.capitalize(conflict.severity) + '</span></td>' +
210243 '<td>' + JetstrikeCD.escapeHtml(conflict.conflict_type.replace(/_/g, ' ')) + '</td>' +
211 '<td><strong>' + JetstrikeCD.escapeHtml(conflict.description) + '</strong>' +
212 (conflict.recommendation ? '<br><em class="jetstrike-cd-recommendation">' +
213 JetstrikeCD.escapeHtml(conflict.recommendation) + '</em>' : '') + '</td>' +
244 '<td>' + descriptionHtml + '</td>' +
214245 '<td><code>' + JetstrikeCD.escapeHtml(JetstrikeCD.dirname(conflict.plugin_a)) + '</code>' +
215246 pluginB + '</td>' +
216247 autoFixCell +
@@ -478,6 +509,269 @@
478509 });
479510 },
480511
512 // ── Toolbar Actions ───────────────────────────────────
513
514 generateReport: function (e) {
515 e.preventDefault();
516 var $btn = $(e.currentTarget);
517 $btn.prop('disabled', true).html('<span class="jetstrike-cd-spinner"></span> Generating...');
518
519 $.ajax({
520 url: jetstrikeCD.ajaxUrl,
521 type: 'POST',
522 data: {
523 action: 'jetstrike_cd_generate_report',
524 nonce: jetstrikeCD.ajaxNonce
525 },
526 success: function (response) {
527 $btn.prop('disabled', false).html('<span class="dashicons dashicons-media-document"></span> Generate Report');
528
529 if (response.success) {
530 // Open report in new window.
531 var win = window.open('', '_blank');
532 win.document.write(response.data.html);
533 win.document.close();
534 JetstrikeCD.showNotice('success', 'Report generated. A new window has opened with the report.');
535 } else {
536 JetstrikeCD.showNotice('error', response.data.message || 'Failed to generate report.');
537 }
538 },
539 error: function () {
540 $btn.prop('disabled', false).html('<span class="dashicons dashicons-media-document"></span> Generate Report');
541 JetstrikeCD.showNotice('error', 'Failed to generate report.');
542 }
543 });
544 },
545
546 exportData: function (e) {
547 e.preventDefault();
548 var $btn = $(e.currentTarget);
549 $btn.prop('disabled', true).html('<span class="jetstrike-cd-spinner"></span> Exporting...');
550
551 $.ajax({
552 url: jetstrikeCD.ajaxUrl,
553 type: 'POST',
554 data: {
555 action: 'jetstrike_cd_export_data',
556 nonce: jetstrikeCD.ajaxNonce,
557 include_scans: true
558 },
559 success: function (response) {
560 $btn.prop('disabled', false).html('<span class="dashicons dashicons-download"></span> Export Data');
561
562 if (response.success) {
563 // Trigger file download.
564 var blob = new Blob([response.data.json], { type: 'application/json' });
565 var url = URL.createObjectURL(blob);
566 var a = document.createElement('a');
567 a.href = url;
568 a.download = response.data.filename;
569 document.body.appendChild(a);
570 a.click();
571 document.body.removeChild(a);
572 URL.revokeObjectURL(url);
573
574 JetstrikeCD.showNotice('success',
575 'Exported ' + response.data.stats.conflicts + ' conflict(s) and ' +
576 response.data.stats.plugins + ' plugin(s).');
577 } else {
578 JetstrikeCD.showNotice('error', response.data.message || 'Export failed.');
579 }
580 },
581 error: function () {
582 $btn.prop('disabled', false).html('<span class="dashicons dashicons-download"></span> Export Data');
583 JetstrikeCD.showNotice('error', 'Export failed.');
584 }
585 });
586 },
587
588 toggleMatrix: function (e) {
589 e.preventDefault();
590 var $container = $('#jetstrike-matrix-container');
591 $container.slideToggle(300);
592
593 var $btn = $(e.currentTarget);
594 if ($container.is(':visible')) {
595 $btn.addClass('button-primary');
596 } else {
597 $btn.removeClass('button-primary');
598 }
599 },
600
601 // ── Health Checks ─────────────────────────────────────
602
603 runHealthCheck: function (e) {
604 e.preventDefault();
605 var $btn = $(e.currentTarget);
606 var checkType = $btn.data('check');
607 var actionMap = {
608 plugin_health: 'jetstrike_cd_plugin_health',
609 db_health: 'jetstrike_cd_db_health',
610 php_compat: 'jetstrike_cd_php_compat'
611 };
612 var labelMap = {
613 plugin_health: 'Plugin Health',
614 db_health: 'Database Health',
615 php_compat: 'PHP Compatibility'
616 };
617
618 var action = actionMap[checkType];
619 if (!action) return;
620
621 $btn.prop('disabled', true).html('<span class="jetstrike-cd-spinner"></span> Analyzing...');
622
623 $.ajax({
624 url: jetstrikeCD.ajaxUrl,
625 type: 'POST',
626 data: {
627 action: action,
628 nonce: jetstrikeCD.ajaxNonce
629 },
630 success: function (response) {
631 $btn.prop('disabled', false).html($btn.html().replace('Analyzing...', labelMap[checkType]));
632 JetstrikeCD.resetHealthButton($btn, checkType, labelMap[checkType]);
633
634 if (response.success) {
635 var data = response.data;
636 var msg = '';
637
638 if (checkType === 'plugin_health') {
639 msg = 'Plugin Health: ' + (data.summary.healthy || 0) + ' healthy, ' +
640 (data.summary.abandoned || 0) + ' abandoned, ' +
641 (data.summary.stale || 0) + ' stale, ' +
642 (data.summary.vulnerable || 0) + ' vulnerable. ' +
643 (data.issues ? data.issues.length : 0) + ' total issue(s).';
644 } else if (checkType === 'db_health') {
645 msg = 'Database Health Score: ' + (data.score || 0) + '/100. ' +
646 (data.issues ? data.issues.length : 0) + ' issue(s) found. ' +
647 'Total DB size: ' + (data.stats.total_db_mb || 0) + 'MB.';
648 } else if (checkType === 'php_compat') {
649 msg = 'PHP Compatibility (PHP ' + (data.target_php || '') + '): ' +
650 (data.summary.clean || 0) + ' compatible, ' +
651 (data.summary.warnings || 0) + ' with warnings, ' +
652 (data.summary.errors || 0) + ' incompatible.';
653 }
654
655 var severity = 'success';
656 if ((data.summary && (data.summary.errors > 0 || data.summary.abandoned > 0 || data.summary.vulnerable > 0)) ||
657 (data.score !== undefined && data.score < 50)) {
658 severity = 'warning';
659 }
660
661 JetstrikeCD.showNotice(severity, msg);
662 } else {
663 JetstrikeCD.showNotice('error', response.data.message || 'Analysis failed.');
664 }
665 },
666 error: function () {
667 JetstrikeCD.resetHealthButton($btn, checkType, labelMap[checkType]);
668 JetstrikeCD.showNotice('error', labelMap[checkType] + ' analysis failed.');
669 }
670 });
671 },
672
673 resetHealthButton: function ($btn, checkType, label) {
674 var iconMap = {
675 plugin_health: 'plugins-checked',
676 db_health: 'database',
677 php_compat: 'editor-code'
678 };
679 $btn.prop('disabled', false).html(
680 '<span class="dashicons dashicons-' + (iconMap[checkType] || 'admin-generic') + '"></span> ' + label
681 );
682 },
683
684 // ── Import Data ──────────────────────────────────────
685
686 importData: function (e) {
687 e.preventDefault();
688 $('#jetstrike-import-file').click();
689 },
690
691 handleImportFile: function (e) {
692 var file = e.target.files[0];
693 if (!file) return;
694
695 var reader = new FileReader();
696 reader.onload = function (evt) {
697 var json;
698 try {
699 json = evt.target.result;
700 JSON.parse(json); // Validate JSON.
701 } catch (err) {
702 JetstrikeCD.showNotice('error', 'Invalid JSON file. Please select a valid Jetstrike export file.');
703 return;
704 }
705
706 $.ajax({
707 url: jetstrikeCD.ajaxUrl,
708 type: 'POST',
709 data: {
710 action: 'jetstrike_cd_import_data',
711 nonce: jetstrikeCD.ajaxNonce,
712 import_data: json
713 },
714 success: function (response) {
715 if (response.success) {
716 JetstrikeCD.showNotice('success',
717 'Imported ' + (response.data.imported || 0) + ' conflict(s). ' +
718 (response.data.skipped || 0) + ' duplicate(s) skipped.');
719 setTimeout(function () { window.location.reload(); }, 2000);
720 } else {
721 JetstrikeCD.showNotice('error', response.data.message || 'Import failed.');
722 }
723 },
724 error: function () {
725 JetstrikeCD.showNotice('error', 'Import failed. Please try again.');
726 }
727 });
728 };
729 reader.readAsText(file);
730
731 // Reset the file input so the same file can be re-selected.
732 e.target.value = '';
733 },
734
735 // ── Pre-Update Check ─────────────────────────────────
736
737 preUpdateCheck: function (e) {
738 e.preventDefault();
739 var $btn = $(e.currentTarget);
740 var pluginFile = $btn.data('plugin');
741
742 if (!pluginFile) return;
743
744 $btn.prop('disabled', true).html('<span class="jetstrike-cd-spinner"></span> Checking...');
745
746 $.ajax({
747 url: jetstrikeCD.ajaxUrl,
748 type: 'POST',
749 data: {
750 action: 'jetstrike_cd_pre_update_check',
751 nonce: jetstrikeCD.ajaxNonce,
752 plugin: pluginFile
753 },
754 success: function (response) {
755 $btn.prop('disabled', false).html('<span class="dashicons dashicons-shield"></span> Pre-Update Check');
756
757 if (response.success) {
758 var data = response.data;
759 var riskClass = data.risk_level === 'dangerous' ? 'error' :
760 (data.risk_level === 'risky' ? 'warning' : 'success');
761 var msg = 'Risk Score: ' + data.risk_score + '/100 (' + data.risk_level + '). ' +
762 (data.new_conflicts || 0) + ' potential new conflict(s) detected.';
763 JetstrikeCD.showNotice(riskClass, msg);
764 } else {
765 JetstrikeCD.showNotice('error', response.data.message || 'Pre-update check failed.');
766 }
767 },
768 error: function () {
769 $btn.prop('disabled', false).html('<span class="dashicons dashicons-shield"></span> Pre-Update Check');
770 JetstrikeCD.showNotice('error', 'Pre-update check failed.');
771 }
772 });
773 },
774
481775 // ── Settings ──────────────────────────────────────────
482776
483777 saveSettings: function (e) {
@@ -489,6 +783,7 @@
489783 // Checkboxes.
490784 settings.auto_scan_enabled = $form.find('[name="auto_scan_enabled"]').is(':checked');
491785 settings.email_alerts = $form.find('[name="email_alerts"]').is(':checked');
786 settings.autofix_beta_enabled = $form.find('[name="autofix_beta_enabled"]').is(':checked');
492787
493788 // Text/select fields.
494789 settings.scan_frequency = $form.find('[name="scan_frequency"]').val();
Addedincludes/AI/ExplanationGenerator.php+540−0View fileUnifiedSplit
@@ -0,0 +1,540 @@
1
2/**
3 * AI-powered conflict explanation generator.
4 *
5 * Takes raw technical conflict data and produces plain-English explanations
6 * that non-technical store owners can understand, including estimated
7 * business impact. Also generates narrative report summaries.
8 *
9 * Uses the Jetstrike AI API (backed by Claude) for generation.
10 * Falls back to template-based explanations when the API is unavailable
11 * or when the site has no AI credits remaining.
12 *
13 * @package Jetstrike\ConflictDetector
14 */
15
16declare(strict_types=1);
17
18namespace Jetstrike\ConflictDetector\AI;
19
20final class ExplanationGenerator {
21
22 private const API_ENDPOINT = 'https://api.jetstrike.io/v1/ai/explain';
23 private const CACHE_PREFIX = 'jetstrike_cd_ai_';
24 private const CACHE_TTL = 7 * DAY_IN_SECONDS;
25
26 /**
27 * Generate a plain-English explanation for a single conflict.
28 *
29 * @param object $conflict Conflict row from the database.
30 * @return array{explanation: string, impact: string, source: string}
31 */
32 public function explain_conflict(object $conflict): array {
33 $cache_key = self::CACHE_PREFIX . 'explain_' . md5(
34 $conflict->conflict_type . $conflict->plugin_a . $conflict->plugin_b .
35 ($conflict->technical_details ?? '')
36 );
37
38 $cached = get_transient($cache_key);
39 if ($cached !== false) {
40 return $cached;
41 }
42
43 $details = json_decode($conflict->technical_details ?? '{}', true);
44 if (! is_array($details)) {
45 $details = [];
46 }
47
48 $result = $this->call_ai_api($conflict, $details);
49
50 if ($result === null) {
51 $result = $this->generate_fallback($conflict, $details);
52 }
53
54 set_transient($cache_key, $result, self::CACHE_TTL);
55
56 return $result;
57 }
58
59 /**
60 * Generate plain-English explanations for multiple conflicts at once.
61 *
62 * @param array $conflicts Array of conflict objects.
63 * @return array<int, array{explanation: string, impact: string, source: string}>
64 * Keyed by conflict ID.
65 */
66 public function explain_batch(array $conflicts): array {
67 $results = [];
68
69 foreach ($conflicts as $conflict) {
70 $id = (int) ($conflict->id ?? 0);
71 $results[$id] = $this->explain_conflict($conflict);
72 }
73
74 return $results;
75 }
76
77 /**
78 * Generate a narrative executive summary for a full scan report.
79 *
80 * @param array $conflicts All active conflicts.
81 * @param array $health_data Health score data.
82 * @param string $site_name Site name.
83 * @param int $plugin_count Number of active plugins.
84 * @return array{summary: string, source: string}
85 */
86 public function generate_executive_summary(
87 array $conflicts,
88 array $health_data,
89 string $site_name,
90 int $plugin_count
91 ): array {
92 $cache_key = self::CACHE_PREFIX . 'summary_' . md5(
93 $site_name . $plugin_count . count($conflicts) .
94 ($health_data['score'] ?? 0)
95 );
96
97 $cached = get_transient($cache_key);
98 if ($cached !== false) {
99 return $cached;
100 }
101
102 $severity_counts = ['critical' => 0, 'high' => 0, 'medium' => 0, 'low' => 0];
103 $conflict_types = [];
104
105 foreach ($conflicts as $c) {
106 $sev = $c->severity ?? 'medium';
107 $severity_counts[$sev] = ($severity_counts[$sev] ?? 0) + 1;
108 $type = $c->conflict_type ?? 'unknown';
109 $conflict_types[$type] = ($conflict_types[$type] ?? 0) + 1;
110 }
111
112 $prompt = $this->build_summary_prompt(
113 $site_name,
114 $plugin_count,
115 $health_data,
116 $severity_counts,
117 $conflict_types,
118 $conflicts
119 );
120
121 $response = $this->send_to_api($prompt, 'executive_summary');
122
123 if ($response !== null) {
124 $result = [
125 'summary' => $response,
126 'source' => 'ai',
127 ];
128 } else {
129 $result = [
130 'summary' => $this->fallback_summary(
131 $site_name, $plugin_count, $health_data, $severity_counts
132 ),
133 'source' => 'template',
134 ];
135 }
136
137 set_transient($cache_key, $result, self::CACHE_TTL);
138
139 return $result;
140 }
141
142 /**
143 * Call the Jetstrike AI API to generate an explanation.
144 *
145 * @return array{explanation: string, impact: string, source: string}|null
146 */
147 private function call_ai_api(object $conflict, array $details): ?array {
148 $prompt = $this->build_conflict_prompt($conflict, $details);
149 $response = $this->send_to_api($prompt, 'conflict_explanation');
150
151 if ($response === null) {
152 return null;
153 }
154
155 $parsed = json_decode($response, true);
156
157 if (is_array($parsed) && isset($parsed['explanation'])) {
158 return [
159 'explanation' => sanitize_text_field($parsed['explanation']),
160 'impact' => sanitize_text_field($parsed['impact'] ?? ''),
161 'source' => 'ai',
162 ];
163 }
164
165 return [
166 'explanation' => sanitize_text_field($response),
167 'impact' => '',
168 'source' => 'ai',
169 ];
170 }
171
172 /**
173 * Send a prompt to the Jetstrike AI API.
174 *
175 * @param string $prompt The prompt to send.
176 * @param string $task_type Type of task for billing/routing.
177 * @return string|null Response text, or null on failure.
178 */
179 private function send_to_api(string $prompt, string $task_type): ?string {
180 $api_key = get_option('jetstrike_cd_ai_api_key', '');
181
182 if (empty($api_key)) {
183 $api_key = defined('JETSTRIKE_AI_API_KEY')
184 ? constant('JETSTRIKE_AI_API_KEY')
185 : '';
186 }
187
188 if (empty($api_key)) {
189 return null;
190 }
191
192 $response = wp_remote_post(self::API_ENDPOINT, [
193 'timeout' => 30,
194 'headers' => [
195 'Content-Type' => 'application/json',
196 'Authorization' => 'Bearer ' . $api_key,
197 ],
198 'body' => wp_json_encode([
199 'prompt' => $prompt,
200 'task_type' => $task_type,
201 'plugin_version' => JETSTRIKE_CD_VERSION,
202 'max_tokens' => $task_type === 'executive_summary' ? 800 : 400,
203 ]),
204 ]);
205
206 if (is_wp_error($response)) {
207 return null;
208 }
209
210 $code = (int) wp_remote_retrieve_response_code($response);
211 if ($code < 200 || $code >= 300) {
212 return null;
213 }
214
215 $body = json_decode(wp_remote_retrieve_body($response), true);
216
217 return $body['text'] ?? null;
218 }
219
220 /**
221 * Build the AI prompt for a single conflict explanation.
222 */
223 private function build_conflict_prompt(object $conflict, array $details): string {
224 $plugin_a = $this->get_plugin_display_name($conflict->plugin_a ?? '');
225 $plugin_b = $this->get_plugin_display_name($conflict->plugin_b ?? '');
226 $type = str_replace('_', ' ', $conflict->conflict_type ?? 'unknown');
227 $severity = $conflict->severity ?? 'medium';
228 $description = $conflict->description ?? '';
229
230 $detail_summary = '';
231 foreach (['hook', 'function', 'handle', 'global', 'resource_type'] as $key) {
232 if (! empty($details[$key])) {
233 $detail_summary .= ucfirst($key) . ': ' . $details[$key] . '. ';
234 }
235 }
236
237 $has_woo = class_exists('WooCommerce');
238
239 return "You are a WordPress and WooCommerce expert writing for a non-technical store owner.\n\n" .
240 "Explain this plugin conflict in 2-3 plain sentences. Then estimate the business impact in 1-2 sentences.\n\n" .
241 "Respond as JSON: {\"explanation\": \"...\", \"impact\": \"...\"}\n\n" .
242 "Conflict details:\n" .
243 "- Type: {$type}\n" .
244 "- Severity: {$severity}\n" .
245 "- Plugin A: {$plugin_a}\n" .
246 "- Plugin B: {$plugin_b}\n" .
247 "- Technical description: {$description}\n" .
248 "- Technical details: {$detail_summary}\n" .
249 "- WooCommerce active: " . ($has_woo ? 'Yes' : 'No') . "\n\n" .
250 "Rules:\n" .
251 "- Write for a store owner, not a developer. No code, no jargon.\n" .
252 "- Be specific about WHAT could go wrong (broken checkout, slow pages, lost orders).\n" .
253 "- Quantify impact where possible (e.g. 'could affect 5-15% of orders').\n" .
254 "- Keep it under 100 words total.";
255 }
256
257 /**
258 * Build the AI prompt for an executive summary.
259 */
260 private function build_summary_prompt(
261 string $site_name,
262 int $plugin_count,
263 array $health_data,
264 array $severity_counts,
265 array $conflict_types,
266 array $conflicts
267 ): string {
268 $score = $health_data['score'] ?? 0;
269 $grade = $health_data['grade'] ?? 'F';
270 $total = array_sum($severity_counts);
271
272 $type_summary = '';
273 foreach ($conflict_types as $type => $count) {
274 $type_summary .= str_replace('_', ' ', $type) . " ({$count}), ";
275 }
276 $type_summary = rtrim($type_summary, ', ');
277
278 $top_conflicts = '';
279 $critical_and_high = array_filter($conflicts, function ($c) {
280 return in_array($c->severity ?? '', ['critical', 'high'], true);
281 });
282
283 foreach (array_slice($critical_and_high, 0, 3) as $c) {
284 $pa = $this->get_plugin_display_name($c->plugin_a ?? '');
285 $pb = $this->get_plugin_display_name($c->plugin_b ?? '');
286 $top_conflicts .= "- {$c->severity}: {$pa} vs {$pb} ({$c->conflict_type}): {$c->description}\n";
287 }
288
289 $has_woo = class_exists('WooCommerce');
290
291 return "You are a senior WordPress consultant writing an executive summary for a client.\n\n" .
292 "Write a 3-4 paragraph executive summary of this site's plugin conflict audit.\n" .
293 "Write in first person as the consultant. Be professional but clear.\n\n" .
294 "Site: {$site_name}\n" .
295 "Active plugins: {$plugin_count}\n" .
296 "Health score: {$score}/100 (Grade: {$grade})\n" .
297 "WooCommerce active: " . ($has_woo ? 'Yes' : 'No') . "\n" .
298 "Total conflicts: {$total}\n" .
299 "- Critical: {$severity_counts['critical']}\n" .
300 "- High: {$severity_counts['high']}\n" .
301 "- Medium: {$severity_counts['medium']}\n" .
302 "- Low: {$severity_counts['low']}\n" .
303 "Conflict types found: {$type_summary}\n\n" .
304 "Top issues:\n{$top_conflicts}\n" .
305 "Rules:\n" .
306 "- Write for a business owner, not a developer.\n" .
307 "- Lead with the most important finding.\n" .
308 "- Include specific business impact (revenue risk, checkout failures, customer experience).\n" .
309 "- End with a prioritised recommendation.\n" .
310 "- Keep it under 250 words.";
311 }
312
313 /**
314 * Generate a template-based explanation when AI is unavailable.
315 *
316 * @return array{explanation: string, impact: string, source: string}
317 */
318 private function generate_fallback(object $conflict, array $details): array {
319 $plugin_a = $this->get_plugin_display_name($conflict->plugin_a ?? '');
320 $plugin_b = $this->get_plugin_display_name($conflict->plugin_b ?? '');
321 $severity = $conflict->severity ?? 'medium';
322
323 $explanations = [
324 'hook_conflict' => [
325 'explanation' => sprintf(
326 '%s and %s are both trying to modify the same part of your site at the same time. ' .
327 'Because they run at the same priority, one plugin silently overrides the other, ' .
328 'which can cause features to stop working unpredictably.',
329 $plugin_a,
330 $plugin_b
331 ),
332 'impact' => $severity === 'critical'
333 ? 'This could cause checkout failures or payment processing errors that directly affect your revenue.'
334 : 'This may cause intermittent issues that are difficult to diagnose, especially during high traffic.',
335 ],
336 'resource_collision' => [
337 'explanation' => sprintf(
338 '%s and %s both load a file with the same name. WordPress can only load one, ' .
339 'so the other plugin\'s version gets dropped. This means one of the two plugins ' .
340 'may not work correctly on pages where both are active.',
341 $plugin_a,
342 $plugin_b
343 ),
344 'impact' => 'You may see broken layouts, missing features, or JavaScript errors on your site. ' .
345 'Customers might see a broken page and leave without buying.',
346 ],
347 'function_redeclaration' => [
348 'explanation' => sprintf(
349 '%s and %s both define a function with the same name. When WordPress tries to load both, ' .
350 'it crashes with a fatal error — your entire site goes down with a white screen.',
351 $plugin_a,
352 $plugin_b
353 ),
354 'impact' => 'This is a site-breaking issue. When triggered, your store becomes completely ' .
355 'inaccessible to customers until you manually deactivate one of the plugins via FTP or database.',
356 ],
357 'class_collision' => [
358 'explanation' => sprintf(
359 '%s and %s both define a class with the same name. This causes a fatal error that ' .
360 'takes your entire site offline — customers see a blank white page instead of your store.',
361 $plugin_a,
362 $plugin_b
363 ),
364 'impact' => 'Complete site outage. Every minute your store is down, you lose potential sales ' .
365 'and damage customer trust.',
366 ],
367 'global_conflict' => [
368 'explanation' => sprintf(
369 '%s and %s both use a shared variable to store data, but they expect different values. ' .
370 'This means they corrupt each other\'s data — settings get overwritten, ' .
371 'calculations produce wrong results, or features break silently.',
372 $plugin_a,
373 $plugin_b
374 ),
375 'impact' => 'This can cause subtle, hard-to-diagnose issues like wrong prices, ' .
376 'missing products, or incorrect shipping calculations.',
377 ],
378 'performance_degradation' => [
379 'explanation' => sprintf(
380 'When %s and %s are both active, your site becomes significantly slower. ' .
381 'Pages that normally load in 1-2 seconds may take 4 or more seconds.',
382 $plugin_a,
383 $plugin_b
384 ),
385 'impact' => 'Slow page loads directly reduce sales. Studies show that every extra second of load time ' .
386 'reduces conversions by 7%%. If your store does $30,000/month, this could cost $2,000+ in lost sales.',
387 ],
388 'dependency_conflict' => [
389 'explanation' => sprintf(
390 '%s and %s both include their own copy of the same code library, but different versions. ' .
391 'WordPress loads one version and ignores the other, which means one plugin is using an ' .
392 'incompatible library version and may malfunction.',
393 $plugin_a,
394 $plugin_b
395 ),
396 'impact' => 'This often causes random errors in payment processing, email sending, or API connections — ' .
397 'problems that seem to come and go without explanation.',
398 ],
399 'js_global_conflict' => [
400 'explanation' => sprintf(
401 '%s and %s both create a JavaScript variable with the same name. One plugin\'s code ' .
402 'overwrites the other\'s, causing interactive features like sliders, popups, or ' .
403 'checkout forms to break.',
404 $plugin_a,
405 $plugin_b
406 ),
407 'impact' => 'Customers may see broken forms, unresponsive buttons, or errors during checkout ' .
408 'that prevent them from completing their purchase.',
409 ],
410 'db_option_collision' => [
411 'explanation' => sprintf(
412 '%s and %s both store their settings under the same name in the database. ' .
413 'Each time one plugin saves its settings, it overwrites the other plugin\'s settings.',
414 $plugin_a,
415 $plugin_b
416 ),
417 'impact' => 'Plugin settings keep resetting themselves. You configure one plugin, ' .
418 'and the next time you check, the settings have changed back.',
419 ],
420 'db_cpt_collision' => [
421 'explanation' => sprintf(
422 '%s and %s both register the same custom content type. WordPress can only have one ' .
423 'definition, so one plugin\'s content may appear in the wrong place or disappear entirely.',
424 $plugin_a,
425 $plugin_b
426 ),
427 'impact' => 'Content created by one plugin may become inaccessible or display incorrectly. ' .
428 'In WooCommerce stores, this could affect product listings or order management.',
429 ],
430 ];
431
432 $type = $conflict->conflict_type ?? 'unknown';
433
434 if (isset($explanations[$type])) {
435 return [
436 'explanation' => $explanations[$type]['explanation'],
437 'impact' => $explanations[$type]['impact'],
438 'source' => 'template',
439 ];
440 }
441
442 return [
443 'explanation' => sprintf(
444 'A %s conflict was detected between %s and %s. ' .
445 'These two plugins are interfering with each other in a way that could cause errors or unexpected behavior.',
446 str_replace('_', ' ', $type),
447 $plugin_a,
448 $plugin_b
449 ),
450 'impact' => $severity === 'critical'
451 ? 'This is a critical issue that could take your site offline or break your checkout.'
452 : 'This may cause intermittent issues that are difficult to diagnose.',
453 'source' => 'template',
454 ];
455 }
456
457 /**
458 * Generate a template-based executive summary when AI is unavailable.
459 */
460 private function fallback_summary(
461 string $site_name,
462 int $plugin_count,
463 array $health_data,
464 array $severity_counts
465 ): string {
466 $score = (int) ($health_data['score'] ?? 0);
467 $grade = $health_data['grade'] ?? 'F';
468 $total = array_sum($severity_counts);
469
470 $condition = 'good condition';
471 if ($score < 40) {
472 $condition = 'serious trouble';
473 } elseif ($score < 60) {
474 $condition = 'fair condition but needs attention';
475 } elseif ($score < 75) {
476 $condition = 'reasonable shape with some issues';
477 }
478
479 $summary = sprintf(
480 '%s is running %d active plugins and scored %d/100 (Grade %s) in our conflict audit. ' .
481 'Overall, the site is in %s.',
482 $site_name,
483 $plugin_count,
484 $score,
485 $grade,
486 $condition
487 );
488
489 if ($severity_counts['critical'] > 0) {
490 $summary .= sprintf(
491 "\n\nWe found %d critical conflict(s) that pose an immediate risk to your store's checkout and payment " .
492 'processing. These should be resolved this week to prevent revenue loss.',
493 $severity_counts['critical']
494 );
495 }
496
497 if ($severity_counts['high'] > 0) {
498 $summary .= sprintf(
499 "\n\n%d high-severity conflict(s) were detected that could cause intermittent errors " .
500 'or performance degradation. We recommend addressing these within the next two weeks.',
501 $severity_counts['high']
502 );
503 }
504
505 if ($total === 0) {
506 $summary .= "\n\nNo conflicts were detected. Your plugin stack is clean and well-maintained.";
507 } else {
508 $summary .= sprintf(
509 "\n\nIn total, %d conflict(s) were found across all severity levels. " .
510 'We recommend starting with the critical and high-severity issues, then addressing ' .
511 'medium and low issues during your next maintenance window.',
512 $total
513 );
514 }
515
516 return $summary;
517 }
518
519 /**
520 * Get a human-readable plugin name from a file path.
521 */
522 private function get_plugin_display_name(string $plugin_file): string {
523 if (empty($plugin_file)) {
524 return 'Unknown Plugin';
525 }
526
527 if (function_exists('get_plugin_data') && defined('WP_PLUGIN_DIR')) {
528 $full_path = WP_PLUGIN_DIR . '/' . $plugin_file;
529 if (file_exists($full_path)) {
530 $data = get_plugin_data($full_path, false, false);
531 if (! empty($data['Name'])) {
532 return $data['Name'];
533 }
534 }
535 }
536
537 $dir = dirname($plugin_file);
538 return $dir !== '.' ? ucwords(str_replace('-', ' ', $dir)) : $plugin_file;
539 }
540}
Modifiedincludes/API/RestController.php+9−0View fileUnifiedSplit
@@ -387,6 +387,7 @@ final class RestController {
387387 'max_pairs_per_batch',
388388 'performance_threshold',
389389 'excluded_plugins',
390 'autofix_beta_enabled',
390391 ];
391392
392393 foreach ($body as $key => $value) {
@@ -395,6 +396,14 @@ final class RestController {
395396 }
396397 }
397398
399 // Auto-Fix beta toggle is stored as a separate option
400 // because AutoResolver::is_beta_enabled() reads it directly.
401 if (isset($current['autofix_beta_enabled'])) {
402 $autofix_value = $current['autofix_beta_enabled'] ? 'yes' : 'no';
403 update_option('jetstrike_cd_autofix_beta_enabled', $autofix_value);
404 unset($current['autofix_beta_enabled']);
405 }
406
398407 update_option('jetstrike_cd_settings', $current);
399408
400409 // Reschedule cron if frequency changed.
Modifiedincludes/Activator.php+4−3View fileUnifiedSplit
@@ -73,9 +73,10 @@ final class Activator {
7373 'performance_threshold' => 3.0,
7474 'excluded_plugins' => [],
7575 ],
76 'jetstrike_cd_license_key' => '',
77 'jetstrike_cd_license_tier' => 'free',
78 'jetstrike_cd_activated_at' => current_time('mysql', true),
76 'jetstrike_cd_license_key' => '',
77 'jetstrike_cd_license_tier' => 'free',
78 'jetstrike_cd_autofix_beta_enabled' => 'no',
79 'jetstrike_cd_activated_at' => current_time('mysql', true),
7980 ];
8081
8182 foreach ($defaults as $key => $value) {
Modifiedincludes/Admin/AdminAjax.php+193−9View fileUnifiedSplit
@@ -12,6 +12,10 @@ namespace Jetstrike\ConflictDetector\Admin;
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;
18use Jetstrike\ConflictDetector\AI\ExplanationGenerator;
1519use Jetstrike\ConflictDetector\Subscription\FeatureFlags;
1620
1721final class AdminAjax {
@@ -33,8 +37,16 @@ final class AdminAjax {
3337 add_action('wp_ajax_jetstrike_cd_update_conflict', [$this, 'update_conflict_status']);
3438 add_action('wp_ajax_jetstrike_cd_auto_fix', [$this, 'auto_fix_conflict']);
3539 add_action('wp_ajax_jetstrike_cd_revert_fix', [$this, 'revert_fix']);
40 add_action('wp_ajax_jetstrike_cd_generate_report', [$this, 'generate_report']);
41 add_action('wp_ajax_jetstrike_cd_export_data', [$this, 'export_data']);
42 add_action('wp_ajax_jetstrike_cd_import_data', [$this, 'import_data']);
43 add_action('wp_ajax_jetstrike_cd_pre_update_check', [$this, 'pre_update_check']);
3644 add_action('wp_ajax_jetstrike_cd_activate_license', [$this, 'activate_license']);
3745 add_action('wp_ajax_jetstrike_cd_deactivate_license', [$this, 'deactivate_license']);
46 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']);
3850 }
3951
4052 /**
@@ -124,19 +136,24 @@ final class AdminAjax {
124136
125137 if ($scan->status === 'completed') {
126138 $conflicts = $this->repository->get_conflicts_for_scan($scan_id);
127 $data['conflicts'] = array_map(function ($c) {
139 $ai = new ExplanationGenerator();
140
141 $data['conflicts'] = array_map(function ($c) use ($ai) {
128142 $can_fix = AutoResolver::can_auto_resolve($c->conflict_type);
143 $explanation = $ai->explain_conflict($c);
129144
130145 return [
131 'id' => (int) $c->id,
132 'plugin_a' => $c->plugin_a,
133 'plugin_b' => $c->plugin_b,
134 'conflict_type' => $c->conflict_type,
135 'severity' => $c->severity,
136 'description' => $c->description,
137 'recommendation' => $c->recommendation,
138 'can_auto_fix' => $can_fix['can_resolve'],
146 'id' => (int) $c->id,
147 'plugin_a' => $c->plugin_a,
148 'plugin_b' => $c->plugin_b,
149 'conflict_type' => $c->conflict_type,
150 'severity' => $c->severity,
151 'description' => $c->description,
152 'recommendation' => $c->recommendation,
153 'can_auto_fix' => $can_fix['can_resolve'],
139154 'fix_description' => $can_fix['description'],
155 'ai_explanation' => $explanation['explanation'],
156 'ai_impact' => $explanation['impact'],
140157 ];
141158 }, $conflicts);
142159 }
@@ -250,6 +267,100 @@ final class AdminAjax {
250267 }
251268 }
252269
270 /**
271 * Generate a professional conflict report.
272 */
273 public function generate_report(): void {
274 $this->verify_request();
275
276 $scan_id = (int) ($_POST['scan_id'] ?? 0);
277 $generator = new ReportGenerator($this->repository);
278 $report = $generator->generate($scan_id > 0 ? $scan_id : null);
279
280 wp_send_json_success([
281 'html' => $report['html'],
282 'filename' => $report['filename'],
283 ]);
284 }
285
286 /**
287 * Export conflict data as JSON.
288 */
289 public function export_data(): void {
290 $this->verify_request();
291
292 if (! FeatureFlags::is_at_least('pro')) {
293 wp_send_json_error(['message' => __('Export requires a Pro or Agency plan.', 'jetstrike-cd')]);
294 }
295
296 $exporter = new ExportManager($this->repository);
297 $export = $exporter->export([
298 'include_scans' => ! empty($_POST['include_scans']),
299 'include_resolved' => ! empty($_POST['include_resolved']),
300 ]);
301
302 wp_send_json_success([
303 'json' => $export['json'],
304 'filename' => $export['filename'],
305 'stats' => $export['stats'],
306 ]);
307 }
308
309 /**
310 * Import conflict data from JSON.
311 */
312 public function import_data(): void {
313 $this->verify_request();
314
315 if (! FeatureFlags::is_at_least('pro')) {
316 wp_send_json_error(['message' => __('Import requires a Pro or Agency plan.', 'jetstrike-cd')]);
317 }
318
319 $json = wp_unslash($_POST['import_json'] ?? '');
320
321 if (empty($json)) {
322 wp_send_json_error(['message' => __('No import data provided.', 'jetstrike-cd')]);
323 }
324
325 $importer = new ExportManager($this->repository);
326 $result = $importer->import($json, ['merge' => true]);
327
328 if ($result['success']) {
329 wp_send_json_success($result);
330 } else {
331 wp_send_json_error($result);
332 }
333 }
334
335 /**
336 * Run a pre-update compatibility check.
337 */
338 public function pre_update_check(): void {
339 $this->verify_request();
340
341 if (! FeatureFlags::can('pre_update_scan')) {
342 wp_send_json_error([
343 'message' => __('Pre-update simulation requires a Pro or Agency plan.', 'jetstrike-cd'),
344 ]);
345 }
346
347 $plugin_file = sanitize_text_field($_POST['plugin_file'] ?? '');
348
349 if (empty($plugin_file)) {
350 wp_send_json_error(['message' => __('No plugin specified.', 'jetstrike-cd')]);
351 }
352
353 $analyzer = new PreUpdateAnalyzer($this->repository);
354
355 // Quick check first (instant, no download).
356 $quick = $analyzer->quick_check($plugin_file);
357
358 wp_send_json_success([
359 'quick_check' => $quick,
360 'plugin' => dirname($plugin_file),
361 ]);
362 }
363
253364 /**
254365 * Activate a license key.
255366 */
@@ -279,6 +390,79 @@ final class AdminAjax {
279390 wp_send_json_success(['deactivated' => true]);
280391 }
281392
393 /**
394 * Get AI-powered explanation for a conflict.
395 */
396 public function ai_explain_conflict(): void {
397 $this->verify_request();
398
399 $conflict_id = (int) ($_POST['conflict_id'] ?? 0);
400
401 if ($conflict_id < 1) {
402 wp_send_json_error(['message' => __('Invalid conflict ID.', 'jetstrike-cd')]);
403 }
404
405 $conflict = $this->repository->get_conflict($conflict_id);
406
407 if ($conflict === null) {
408 wp_send_json_error(['message' => __('Conflict not found.', 'jetstrike-cd')]);
409 }
410
411 $ai = new ExplanationGenerator();
412 $result = $ai->explain_conflict($conflict);
413
414 wp_send_json_success($result);
415 }
416
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
282466 /**
283467 * Verify AJAX request (nonce + capability).
284468 */
Addedincludes/Admin/CompatibilityMatrix.php+301−0View fileUnifiedSplit
@@ -0,0 +1,301 @@
1
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">—</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' ? '❌' : '⚠')
198 : '✓';
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/DatabaseHealthAnalyzer.php+460−0View fileUnifiedSplit
@@ -0,0 +1,460 @@
1
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
@@ -0,0 +1,352 @@
1
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
@@ -0,0 +1,505 @@
1
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}
Addedincludes/Analyzer/PreUpdateAnalyzer.php+544−0View fileUnifiedSplit
@@ -0,0 +1,544 @@
1
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}
Modifiedincludes/Deactivator.php+24−0View fileUnifiedSplit
@@ -34,6 +34,30 @@ final class Deactivator {
3434 delete_transient('jetstrike_cd_conflict_summary');
3535 delete_transient('jetstrike_cd_plugin_scores');
3636
37 // Remove Auto-Fix mu-plugin patches so they don't run
38 // while the plugin is deactivated.
39 $patch_dir = defined('WPMU_PLUGIN_DIR')
40 ? WPMU_PLUGIN_DIR . '/jetstrike-patches'
41 : WP_CONTENT_DIR . '/mu-plugins/jetstrike-patches';
42
43 if (is_dir($patch_dir)) {
44 $files = glob($patch_dir . '/*.php');
45 if (is_array($files)) {
46 foreach ($files as $file) {
47 wp_delete_file($file);
48 }
49 }
50 @rmdir($patch_dir);
51 }
52
53 $loader = defined('WPMU_PLUGIN_DIR')
54 ? WPMU_PLUGIN_DIR . '/jetstrike-patch-loader.php'
55 : WP_CONTENT_DIR . '/mu-plugins/jetstrike-patch-loader.php';
56
57 if (file_exists($loader)) {
58 wp_delete_file($loader);
59 }
60
3761 // Flush rewrite rules.
3862 flush_rewrite_rules();
3963 }
Addedincludes/Export/ExportManager.php+320−0View fileUnifiedSplit
@@ -0,0 +1,320 @@
1
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}
Modifiedincludes/Monitor/HealthMonitor.php+11−7View fileUnifiedSplit
@@ -146,13 +146,17 @@ final class HealthMonitor {
146146
147147 $score = max(0, min(100, $score));
148148
149 $grade = match (true) {
150 $score >= 90 => 'A',
151 $score >= 75 => 'B',
152 $score >= 60 => 'C',
153 $score >= 40 => 'D',
154 default => 'F',
155 };
149 if ($score >= 90) {
150 $grade = 'A';
151 } elseif ($score >= 75) {
152 $grade = 'B';
153 } elseif ($score >= 60) {
154 $grade = 'C';
155 } elseif ($score >= 40) {
156 $grade = 'D';
157 } else {
158 $grade = 'F';
159 }
156160
157161 return [
158162 'score' => $score,
Addedincludes/Multisite/NetworkScanner.php+220−0View fileUnifiedSplit
@@ -0,0 +1,220 @@
1
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+368−0View fileUnifiedSplit
@@ -0,0 +1,368 @@
1
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;
26use Jetstrike\ConflictDetector\AI\ExplanationGenerator;
27
28final class ReportGenerator {
29
30 /** @var Repository */
31 private Repository $repository;
32
33 public function __construct(Repository $repository) {
34 $this->repository = $repository;
35 }
36
37 /**
38 * Generate a full conflict report.
39 *
40 * @param int|null $scan_id Specific scan ID, or null for latest.
41 * @return array{html: string, filename: string, generated_at: string}
42 */
43 public function generate(int $scan_id = null): array {
44 $scan = $scan_id
45 ? $this->repository->get_scan($scan_id)
46 : $this->repository->get_latest_scan();
47
48 $conflicts = $scan
49 ? $this->repository->get_conflicts_for_scan((int) $scan->id)
50 : [];
51
52 $all_active = $this->repository->list_active_conflicts(1, 200);
53 $health_monitor = new HealthMonitor($this->repository);
54 $health = $health_monitor->get_health_data();
55
56 $site_url = get_site_url();
57 $site_name = get_bloginfo('name');
58 $generated_at = current_time('mysql');
59
60 $ai = new ExplanationGenerator();
61 $ai_explanations = $ai->explain_batch($all_active);
62 $ai_summary = $ai->generate_executive_summary(
63 $all_active,
64 $health,
65 $site_name,
66 count($this->get_plugin_list())
67 );
68
69 $html = $this->build_html([
70 'site_name' => $site_name,
71 'site_url' => $site_url,
72 'generated_at' => $generated_at,
73 'health' => $health,
74 'scan' => $scan,
75 'conflicts' => $conflicts,
76 'all_active' => $all_active,
77 'summary' => $this->build_summary($all_active),
78 'plugins' => $this->get_plugin_list(),
79 'ai_explanations' => $ai_explanations,
80 'ai_summary' => $ai_summary['summary'],
81 ]);
82
83 $filename = sprintf(
84 'jetstrike-report-%s-%s.html',
85 sanitize_title($site_name),
86 gmdate('Y-m-d')
87 );
88
89 return [
90 'html' => $html,
91 'filename' => $filename,
92 'generated_at' => $generated_at,
93 ];
94 }
95
96 /**
97 * Build the full HTML report.
98 */
99 private function build_html(array $data): string {
100 $grade_colors = [
101 'A' => '#22c55e', 'B' => '#84cc16', 'C' => '#eab308',
102 'D' => '#f97316', 'F' => '#ef4444',
103 ];
104 $grade_color = $grade_colors[$data['health']['grade'] ?? 'F'] ?? '#6b7280';
105
106 $html = '<!DOCTYPE html><html lang="en"><head>';
107 $html .= '<meta charset="UTF-8">';
108 $html .= '<meta name="viewport" content="width=device-width, initial-scale=1.0">';
109 $html .= '<title>Plugin Conflict Report — ' . esc_html($data['site_name']) . '</title>';
110 $html .= '<style>' . $this->get_report_css() . '</style>';
111 $html .= '</head><body>';
112
113 // Header.
114 $html .= '<div class="report-header">';
115 $html .= '<div class="report-logo">Jetstrike</div>';
116 $html .= '<div class="report-meta">';
117 $html .= '<h1>Plugin Conflict Report</h1>';
118 $html .= '<p>' . esc_html($data['site_name']) . ' — ' . esc_url($data['site_url']) . '</p>';
119 $html .= '<p>Generated: ' . esc_html($data['generated_at']) . '</p>';
120 $html .= '</div></div>';
121
122 // AI Executive Summary.
123 if (! empty($data['ai_summary'])) {
124 $html .= '<div class="report-section">';
125 $html .= '<div style="background: #f0f7ff; border: 1px solid #bfdbfe; border-radius: 8px; padding: 20px;">';
126 $html .= '<h2 style="font-size: 14px; text-transform: uppercase; letter-spacing: 0.5px; color: #1e40af; margin-bottom: 12px; border: 0; padding: 0;">Executive Summary</h2>';
127 $html .= '<div style="font-size: 14px; color: #334155; line-height: 1.7; white-space: pre-line;">';
128 $html .= esc_html($data['ai_summary']);
129 $html .= '</div></div></div>';
130 }
131
132 // Health Score.
133 $score = (int) ($data['health']['score'] ?? 0);
134 $grade = $data['health']['grade'] ?? 'F';
135
136 $html .= '<div class="report-section">';
137 $html .= '<div class="health-banner" style="border-left-color: ' . $grade_color . '">';
138 $html .= '<div class="health-grade" style="background: ' . $grade_color . '">' . esc_html($grade) . '</div>';
139 $html .= '<div class="health-info">';
140 $html .= '<h2>Site Health Score: ' . $score . '/100</h2>';
141 $html .= '<p>' . $this->grade_description($grade) . '</p>';
142 $html .= '</div></div></div>';
143
144 // Summary.
145 $summary = $data['summary'];
146 $html .= '<div class="report-section">';
147 $html .= '<h2>Conflict Summary</h2>';
148 $html .= '<div class="summary-grid">';
149
150 foreach (['critical', 'high', 'medium', 'low'] as $severity) {
151 $count = $summary['by_severity'][$severity] ?? 0;
152 $html .= '<div class="summary-card summary-card--' . $severity . '">';
153 $html .= '<div class="summary-count">' . $count . '</div>';
154 $html .= '<div class="summary-label">' . ucfirst($severity) . '</div>';
155 $html .= '</div>';
156 }
157
158 $html .= '</div></div>';
159
160 // Active plugins.
161 $plugins = $data['plugins'];
162 $html .= '<div class="report-section">';
163 $html .= '<h2>Active Plugins (' . count($plugins) . ')</h2>';
164 $html .= '<table class="report-table"><thead><tr><th>Plugin</th><th>Version</th><th>Conflicts</th></tr></thead><tbody>';
165
166 foreach ($plugins as $plugin) {
167 $conflict_count = 0;
168 foreach ($data['all_active'] as $c) {
169 if ($c->plugin_a === $plugin['file'] || $c->plugin_b === $plugin['file']) {
170 $conflict_count++;
171 }
172 }
173
174 $status_class = $conflict_count > 0 ? 'status--conflict' : 'status--clean';
175 $html .= '<tr>';
176 $html .= '<td><strong>' . esc_html($plugin['name']) . '</strong></td>';
177 $html .= '<td>' . esc_html($plugin['version']) . '</td>';
178 $html .= '<td class="' . $status_class . '">' . ($conflict_count > 0 ? $conflict_count . ' conflict(s)' : 'Clean') . '</td>';
179 $html .= '</tr>';
180 }
181
182 $html .= '</tbody></table></div>';
183
184 // Conflict details.
185 if (! empty($data['all_active'])) {
186 $html .= '<div class="report-section">';
187 $html .= '<h2>Active Conflicts (' . count($data['all_active']) . ')</h2>';
188
189 foreach ($data['all_active'] as $conflict) {
190 $can_fix = AutoResolver::can_auto_resolve($conflict->conflict_type);
191
192 $html .= '<div class="conflict-card conflict-card--' . esc_attr($conflict->severity) . '">';
193 $html .= '<div class="conflict-header">';
194 $html .= '<span class="severity-badge severity-badge--' . esc_attr($conflict->severity) . '">';
195 $html .= strtoupper(esc_html($conflict->severity)) . '</span>';
196 $html .= '<span class="conflict-type">' . esc_html(str_replace('_', ' ', $conflict->conflict_type)) . '</span>';
197
198 if ($can_fix['can_resolve']) {
199 $html .= '<span class="autofix-badge">Auto-Fix Available</span>';
200 }
201
202 $html .= '</div>';
203
204 // AI plain-English explanation (replaces technical description for non-technical readers).
205 $conflict_id = (int) ($conflict->id ?? 0);
206 $ai_data = $data['ai_explanations'][$conflict_id] ?? null;
207
208 if ($ai_data && ! empty($ai_data['explanation'])) {
209 $html .= '<p class="conflict-desc">' . esc_html($ai_data['explanation']) . '</p>';
210 if (! empty($ai_data['impact'])) {
211 $html .= '<p class="conflict-desc" style="color: #b91c1c; font-weight: 600; margin-top: 4px;">';
212 $html .= esc_html($ai_data['impact']) . '</p>';
213 }
214 $html .= '<p style="font-size: 12px; color: #94a3b8; margin-top: 4px;">';
215 $html .= '<em>Technical: ' . esc_html($conflict->description) . '</em></p>';
216 } else {
217 $html .= '<p class="conflict-desc">' . esc_html($conflict->description) . '</p>';
218 }
219
220 $html .= '<div class="conflict-plugins">';
221 $html .= '<code>' . esc_html(dirname($conflict->plugin_a)) . '</code>';
222
223 if (! empty($conflict->plugin_b)) {
224 $html .= ' vs <code>' . esc_html(dirname($conflict->plugin_b)) . '</code>';
225 }
226
227 $html .= '</div>';
228
229 if (! empty($conflict->recommendation)) {
230 $html .= '<div class="conflict-recommendation">';
231 $html .= '<strong>Recommendation:</strong> ' . esc_html($conflict->recommendation);
232 $html .= '</div>';
233 }
234
235 $html .= '</div>';
236 }
237
238 $html .= '</div>';
239 }
240
241 // Footer.
242 $html .= '<div class="report-footer">';
243 $html .= '<p>Generated by <strong>Jetstrike Conflict Detector</strong> v' . JETSTRIKE_CD_VERSION . '</p>';
244 $html .= '<p><a href="https://jetstrike.io">jetstrike.io</a></p>';
245 $html .= '</div>';
246
247 $html .= '</body></html>';
248
249 return $html;
250 }
251
252 /**
253 * Build conflict summary statistics.
254 */
255 private function build_summary(array $conflicts): array {
256 $summary = [
257 'by_severity' => ['critical' => 0, 'high' => 0, 'medium' => 0, 'low' => 0],
258 'by_type' => [],
259 'total' => count($conflicts),
260 ];
261
262 foreach ($conflicts as $conflict) {
263 $severity = $conflict->severity ?? 'medium';
264 $type = $conflict->conflict_type ?? 'unknown';
265
266 $summary['by_severity'][$severity] = ($summary['by_severity'][$severity] ?? 0) + 1;
267 $summary['by_type'][$type] = ($summary['by_type'][$type] ?? 0) + 1;
268 }
269
270 return $summary;
271 }
272
273 /**
274 * Get active plugin list.
275 */
276 private function get_plugin_list(): array {
277 $active = get_option('active_plugins', []);
278 $plugins = [];
279
280 foreach ($active as $file) {
281 if ($file === JETSTRIKE_CD_BASENAME) {
282 continue;
283 }
284
285 $data = get_plugin_data(WP_PLUGIN_DIR . '/' . $file, false, false);
286 $plugins[] = [
287 'file' => $file,
288 'name' => $data['Name'] ?: dirname($file),
289 'version' => $data['Version'] ?? '',
290 ];
291 }
292
293 return $plugins;
294 }
295
296 /**
297 * Get description for a health grade.
298 */
299 private function grade_description(string $grade): string {
300 $descriptions = [
301 'A' => 'Excellent — your site has no significant plugin conflicts.',
302 'B' => 'Good — minor issues detected that should be monitored.',
303 'C' => 'Fair — several conflicts found that may affect site stability.',
304 'D' => 'Poor — significant conflicts require attention.',
305 'F' => 'Critical — your site has serious conflicts that need immediate action.',
306 ];
307
308 return $descriptions[$grade] ?? 'Unknown health status.';
309 }
310
311 /**
312 * Self-contained CSS for the report.
313 */
314 private function get_report_css(): string {
315 return <<<'CSS'
316* { margin: 0; padding: 0; box-sizing: border-box; }
317body { 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; }
318.report-header { display: flex; align-items: center; gap: 24px; padding-bottom: 24px; border-bottom: 2px solid #e2e8f0; margin-bottom: 32px; }
319.report-logo { font-size: 28px; font-weight: 800; color: #2563eb; letter-spacing: -0.5px; }
320.report-meta h1 { font-size: 20px; font-weight: 600; }
321.report-meta p { color: #64748b; font-size: 14px; }
322.report-section { margin-bottom: 32px; }
323.report-section h2 { font-size: 18px; font-weight: 600; margin-bottom: 16px; padding-bottom: 8px; border-bottom: 1px solid #e2e8f0; }
324.health-banner { display: flex; align-items: center; gap: 20px; padding: 20px; background: #f8fafc; border-radius: 8px; border-left: 4px solid; }
325.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; }
326.health-info h2 { font-size: 18px; border: 0; padding: 0; margin: 0; }
327.health-info p { color: #64748b; margin-top: 4px; }
328.summary-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; }
329.summary-card { text-align: center; padding: 16px; border-radius: 8px; background: #f8fafc; }
330.summary-card--critical { border-left: 3px solid #ef4444; }
331.summary-card--high { border-left: 3px solid #f97316; }
332.summary-card--medium { border-left: 3px solid #eab308; }
333.summary-card--low { border-left: 3px solid #3b82f6; }
334.summary-count { font-size: 32px; font-weight: 700; }
335.summary-card--critical .summary-count { color: #ef4444; }
336.summary-card--high .summary-count { color: #f97316; }
337.summary-card--medium .summary-count { color: #eab308; }
338.summary-card--low .summary-count { color: #3b82f6; }
339.summary-label { font-size: 12px; text-transform: uppercase; letter-spacing: 1px; color: #64748b; }
340.report-table { width: 100%; border-collapse: collapse; }
341.report-table th, .report-table td { padding: 10px 12px; text-align: left; border-bottom: 1px solid #e2e8f0; font-size: 14px; }
342.report-table th { background: #f8fafc; font-weight: 600; font-size: 12px; text-transform: uppercase; letter-spacing: 0.5px; }
343.status--conflict { color: #ef4444; font-weight: 600; }
344.status--clean { color: #22c55e; }
345.conflict-card { padding: 16px; border-radius: 8px; margin-bottom: 12px; border: 1px solid #e2e8f0; border-left: 4px solid; }
346.conflict-card--critical { border-left-color: #ef4444; background: #fef2f2; }
347.conflict-card--high { border-left-color: #f97316; background: #fff7ed; }
348.conflict-card--medium { border-left-color: #eab308; background: #fefce8; }
349.conflict-card--low { border-left-color: #3b82f6; background: #eff6ff; }
350.conflict-header { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; flex-wrap: wrap; }
351.severity-badge { font-size: 11px; font-weight: 700; padding: 2px 8px; border-radius: 4px; color: #fff; }
352.severity-badge--critical { background: #ef4444; }
353.severity-badge--high { background: #f97316; }
354.severity-badge--medium { background: #eab308; }
355.severity-badge--low { background: #3b82f6; }
356.conflict-type { font-size: 13px; color: #64748b; text-transform: capitalize; }
357.autofix-badge { font-size: 11px; background: #2563eb; color: #fff; padding: 2px 8px; border-radius: 4px; margin-left: auto; }
358.conflict-desc { font-size: 14px; margin-bottom: 8px; }
359.conflict-plugins { margin-bottom: 8px; }
360.conflict-plugins code { background: #f1f5f9; padding: 2px 6px; border-radius: 3px; font-size: 13px; }
361.conflict-recommendation { font-size: 13px; color: #475569; padding: 8px 12px; background: #fff; border-radius: 4px; border: 1px solid #e2e8f0; }
362.report-footer { text-align: center; padding-top: 24px; border-top: 1px solid #e2e8f0; color: #94a3b8; font-size: 13px; }
363.report-footer a { color: #2563eb; text-decoration: none; }
364@media print { body { padding: 0; } .summary-grid { grid-template-columns: repeat(4, 1fr); } }
365@media (max-width: 600px) { .summary-grid { grid-template-columns: repeat(2, 1fr); } .health-banner { flex-direction: column; text-align: center; } }
366CSS;
367 }
368}
Modifiedincludes/Resolver/AutoResolver.php+155−14View fileUnifiedSplit
@@ -35,6 +35,28 @@ final class AutoResolver {
3535 /** Directory inside mu-plugins where patches live. */
3636 private const PATCH_DIR = 'jetstrike-patches';
3737
38 /** Option key controlling whether the Auto-Fix beta is enabled. */
39 private const BETA_OPT_IN_KEY = 'jetstrike_cd_autofix_beta_enabled';
40
41 /**
42 * Is the Auto-Fix Engine enabled for this site?
43 *
44 * Auto-Fix ships as opt-in BETA in v1.x because it writes mu-plugin files
45 * that can, if a patch is malformed, affect every request on the site.
46 * Sites must either:
47 * - Define JETSTRIKE_CD_AUTOFIX_ENABLED as true in wp-config.php, or
48 * - Toggle the beta on from Settings (stores option 'yes').
49 *
50 * @return bool True if Auto-Fix may run.
51 */
52 public static function is_beta_enabled(): bool {
53 if (defined('JETSTRIKE_CD_AUTOFIX_ENABLED')) {
54 return (bool) constant('JETSTRIKE_CD_AUTOFIX_ENABLED');
55 }
56
57 return get_option(self::BETA_OPT_IN_KEY, 'no') === 'yes';
58 }
59
3860 public function __construct(Repository $repository) {
3961 $this->repository = $repository;
4062 $this->hook_resolver = new HookPriorityResolver();
@@ -49,6 +71,18 @@ final class AutoResolver {
4971 * @return array{success: bool, method: string, message: string, patch_file: string|null}
5072 */
5173 public function resolve(int $conflict_id): array {
74 // Refuse to run unless the Auto-Fix beta has been explicitly enabled.
75 // This protects customers from accidentally writing mu-plugin files
76 // during the beta period.
77 if (! self::is_beta_enabled()) {
78 return [
79 'success' => false,
80 'method' => 'beta_disabled',
81 'message' => __('Auto-Fix is in beta and is disabled by default. Enable it from Settings → Auto-Fix (Beta) or define JETSTRIKE_CD_AUTOFIX_ENABLED in wp-config.php.', 'jetstrike-cd'),
82 'patch_file' => null,
83 ];
84 }
85
5286 $conflict = $this->repository->get_conflict($conflict_id);
5387
5488 if ($conflict === null) {
@@ -66,21 +100,50 @@ final class AutoResolver {
66100 }
67101
68102 // Dispatch to the appropriate resolver based on conflict type.
69 $result = match ($conflict->conflict_type) {
70 'hook_conflict' => $this->resolve_hook_conflict($conflict, $details),
71 'resource_collision' => $this->resolve_resource_collision($conflict, $details),
72 'function_redeclaration' => $this->resolve_function_conflict($conflict, $details),
73 'global_conflict' => $this->resolve_global_conflict($conflict, $details),
74 default => [
75 'success' => false,
76 'method' => 'unsupported',
77 'message' => sprintf('Auto-fix is not available for "%s" conflicts. Manual intervention required.', $conflict->conflict_type),
78 'patch_file' => null,
79 ],
80 };
103 switch ($conflict->conflict_type) {
104 case 'hook_conflict':
105 $result = $this->resolve_hook_conflict($conflict, $details);
106 break;
107 case 'resource_collision':
108 $result = $this->resolve_resource_collision($conflict, $details);
109 break;
110 case 'function_redeclaration':
111 $result = $this->resolve_function_conflict($conflict, $details);
112 break;
113 case 'global_conflict':
114 $result = $this->resolve_global_conflict($conflict, $details);
115 break;
116 default:
117 $result = [
118 'success' => false,
119 'method' => 'unsupported',
120 'message' => sprintf('Auto-fix is not available for "%s" conflicts. Manual intervention required.', $conflict->conflict_type),
121 'patch_file' => null,
122 ];
123 break;
124 }
125
126 // If the patch was written, verify the site still responds normally.
127 // If health verification fails, immediately roll back the patch so
128 // the customer is never left with a broken admin.
129 if ($result['success'] && ! empty($result['patch_file'])) {
130 $health = $this->verify_site_health();
131
132 if (! $health['ok']) {
133 $this->delete_patch_file((string) $result['patch_file']);
134
135 return [
136 'success' => false,
137 'method' => $result['method'],
138 'message' => sprintf(
139 /* translators: %s: failure reason from the health check */
140 __('Auto-Fix applied a patch but the post-apply health check failed (%s). The patch was automatically removed and the site was restored. Please resolve this conflict manually.', 'jetstrike-cd'),
141 $health['reason']
142 ),
143 'patch_file' => null,
144 ];
145 }
81146
82 // If resolved, update the conflict status.
83 if ($result['success']) {
84147 $this->repository->update_conflict($conflict_id, [
85148 'status' => 'resolved',
86149 'resolved_at' => current_time('mysql', true),
@@ -93,6 +156,84 @@ final class AutoResolver {
93156 return $result;
94157 }
95158
159 /**
160 * Verify the site is still healthy after a patch has been written.
161 *
162 * Issues a non-blocking loopback request to the home URL and a blocking
163 * request to the admin dashboard. If either returns a 5xx response or
164 * contains a fatal-error signature, health fails.
165 *
166 * @return array{ok: bool, reason: string}
167 */
168 private function verify_site_health(): array {
169 // Give PHP opcache a moment to pick up the new mu-plugin file.
170 if (function_exists('opcache_reset')) {
171 @opcache_reset();
172 }
173
174 $targets = [
175 home_url('/'),
176 admin_url('admin-ajax.php?action=heartbeat'),
177 ];
178
179 foreach ($targets as $url) {
180 $response = wp_remote_get($url, [
181 'timeout' => 10,
182 'redirection' => 2,
183 'sslverify' => false,
184 'blocking' => true,
185 'headers' => [
186 'X-Jetstrike-Healthcheck' => '1',
187 ],
188 ]);
189
190 if (is_wp_error($response)) {
191 return [
192 'ok' => false,
193 'reason' => sprintf('loopback request to %s failed: %s', $url, $response->get_error_message()),
194 ];
195 }
196
197 $code = (int) wp_remote_retrieve_response_code($response);
198 if ($code >= 500) {
199 return [
200 'ok' => false,
201 'reason' => sprintf('HTTP %d from %s', $code, $url),
202 ];
203 }
204
205 $body = (string) wp_remote_retrieve_body($response);
206 if ($body !== '' && preg_match('/(Fatal error|Parse error|Cannot redeclare|Call to undefined)/i', $body)) {
207 return [
208 'ok' => false,
209 'reason' => sprintf('PHP error detected in response from %s', $url),
210 ];
211 }
212 }
213
214 return ['ok' => true, 'reason' => ''];
215 }
216
217 /**
218 * Delete a patch file by filename (relative to the patch dir).
219 */
220 private function delete_patch_file(string $filename): void {
221 if ($filename === '') {
222 return;
223 }
224
225 $full_path = $this->get_patch_dir() . '/' . basename($filename);
226
227 if (file_exists($full_path)) {
228 wp_delete_file($full_path);
229 }
230
231 // Clear opcache for the removed file.
232 if (function_exists('opcache_invalidate')) {
233 @opcache_invalidate($full_path, true);
234 }
235 }
236
96237 /**
97238 * Undo a previously applied auto-fix.
98239 *
Modifiedincludes/Subscription/FeatureFlags.php+14−9View fileUnifiedSplit
@@ -124,11 +124,15 @@ final class FeatureFlags {
124124 public static function scan_history_limit(): int {
125125 $tier = self::get_tier();
126126
127 return match ($tier) {
128 self::TIER_AGENCY => 0,
129 self::TIER_PRO => 50,
130 default => 3,
131 };
127 if ($tier === self::TIER_AGENCY) {
128 return 0;
129 }
130
131 if ($tier === self::TIER_PRO) {
132 return 50;
133 }
134
135 return 3;
132136 }
133137
134138 /**
@@ -139,10 +143,11 @@ final class FeatureFlags {
139143 public static function weekly_scan_limit(): int {
140144 $tier = self::get_tier();
141145
142 return match ($tier) {
143 self::TIER_AGENCY, self::TIER_PRO => 0,
144 default => 1,
145 };
146 if ($tier === self::TIER_AGENCY || $tier === self::TIER_PRO) {
147 return 0;
148 }
149
150 return 1;
146151 }
147152
148153 /**
Addedintelligent-plugin-conflict-detector/.gitignore+2−0View fileUnifiedSplit
@@ -0,0 +1,2 @@
1vendor/
2.phpunit.result.cache
Addedintelligent-plugin-conflict-detector/admin/assets/css/admin.css+240−0View fileUnifiedSplit
@@ -0,0 +1,240 @@
1/* ==========================================================================
2 Intelligent Plugin Conflict Detector — Admin Styles
3 ========================================================================== */
4
5/* Layout ------------------------------------------------------------------ */
6.ipcd-wrap {
7 font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, sans-serif;
8}
9
10.ipcd-page-title {
11 display: flex;
12 align-items: center;
13 gap: 8px;
14 font-size: 1.6rem;
15 margin-bottom: 16px;
16}
17
18.ipcd-logo {
19 font-size: 1.8rem;
20 line-height: 1;
21}
22
23.ipcd-version {
24 font-size: 0.75rem;
25 font-weight: 400;
26 color: #888;
27 background: #f0f0f0;
28 border-radius: 12px;
29 padding: 2px 8px;
30}
31
32/* Tabs -------------------------------------------------------------------- */
33.ipcd-tabs {
34 display: flex;
35 gap: 4px;
36 border-bottom: 2px solid #ddd;
37 margin-bottom: 24px;
38}
39
40.ipcd-tab {
41 padding: 8px 16px;
42 text-decoration: none;
43 color: #555;
44 border-radius: 4px 4px 0 0;
45 border: 2px solid transparent;
46 border-bottom: none;
47 margin-bottom: -2px;
48 display: inline-flex;
49 align-items: center;
50 gap: 6px;
51 font-weight: 500;
52 transition: color 0.15s, background 0.15s;
53}
54
55.ipcd-tab:hover {
56 color: #2271b1;
57 background: #f6f7f7;
58}
59
60.ipcd-tab.active {
61 color: #2271b1;
62 background: #fff;
63 border-color: #ddd #ddd #fff;
64}
65
66.ipcd-tab-content {
67 display: none;
68}
69
70.ipcd-tab-content.active {
71 display: block;
72}
73
74/* Status bar -------------------------------------------------------------- */
75.ipcd-status-bar {
76 display: flex;
77 align-items: center;
78 gap: 24px;
79 background: #f6f7f7;
80 border: 1px solid #ddd;
81 border-radius: 6px;
82 padding: 12px 16px;
83 margin-bottom: 20px;
84 flex-wrap: wrap;
85}
86
87.ipcd-status-item {
88 display: flex;
89 align-items: center;
90 gap: 6px;
91 font-size: 0.875rem;
92}
93
94.ipcd-status-actions {
95 margin-left: auto;
96 display: flex;
97 gap: 8px;
98 flex-wrap: wrap;
99}
100
101/* Pills ------------------------------------------------------------------- */
102.ipcd-pill {
103 display: inline-block;
104 padding: 2px 10px;
105 border-radius: 12px;
106 font-size: 0.75rem;
107 font-weight: 600;
108 text-transform: uppercase;
109 letter-spacing: 0.03em;
110}
111
112.ipcd-pill--green { background: #d8f3dc; color: #1a7431; }
113.ipcd-pill--red { background: #fce4e4; color: #c0392b; }
114.ipcd-pill--yellow { background: #fff3cd; color: #856404; }
115
116/* Badges ------------------------------------------------------------------ */
117.ipcd-badge {
118 display: inline-flex;
119 align-items: center;
120 justify-content: center;
121 min-width: 18px;
122 height: 18px;
123 padding: 0 5px;
124 border-radius: 9px;
125 background: #ddd;
126 color: #555;
127 font-size: 0.7rem;
128 font-weight: 700;
129}
130
131.ipcd-badge--error {
132 background: #d63638;
133 color: #fff;
134}
135
136/* Cards ------------------------------------------------------------------- */
137.ipcd-card {
138 background: #fff;
139 border: 1px solid #ddd;
140 border-radius: 6px;
141 margin-bottom: 20px;
142 overflow: hidden;
143}
144
145.ipcd-card-header {
146 display: flex;
147 align-items: center;
148 justify-content: space-between;
149 padding: 14px 16px;
150 border-bottom: 1px solid #eee;
151 background: #fafafa;
152}
153
154.ipcd-card-header h2 {
155 margin: 0;
156 font-size: 1rem;
157}
158
159.ipcd-card > table {
160 margin: 0;
161}
162
163/* Tables ------------------------------------------------------------------ */
164.ipcd-table th,
165.ipcd-table td {
166 padding: 10px 12px ;
167 vertical-align: middle ;
168}
169
170.ipcd-actions-cell {
171 display: flex;
172 gap: 6px;
173 align-items: center;
174}
175
176/* Severity rows ----------------------------------------------------------- */
177.ipcd-severity-critical td:first-child {
178 border-left: 4px solid #d63638;
179}
180
181.ipcd-severity-warning td:first-child {
182 border-left: 4px solid #f0b849;
183}
184
185.ipcd-severity-info td:first-child {
186 border-left: 4px solid #2271b1;
187}
188
189.ipcd-resolved-row {
190 opacity: 0.6;
191}
192
193/* Context pre ------------------------------------------------------------- */
194.ipcd-context-pre {
195 background: #f6f7f7;
196 border: 1px solid #ddd;
197 border-radius: 4px;
198 padding: 8px 12px;
199 font-size: 0.8rem;
200 overflow-x: auto;
201 margin: 6px 0 0;
202}
203
204/* Empty state ------------------------------------------------------------- */
205.ipcd-empty-state {
206 display: flex;
207 flex-direction: column;
208 align-items: center;
209 justify-content: center;
210 padding: 48px 24px;
211 text-align: center;
212 color: #888;
213}
214
215.ipcd-empty-icon {
216 font-size: 3rem;
217 margin-bottom: 12px;
218}
219
220/* Notice area ------------------------------------------------------------- */
221#ipcd-notice-area .notice {
222 margin: 0 0 16px;
223}
224
225/* Spinner ----------------------------------------------------------------- */
226.ipcd-spinner {
227 display: inline-block;
228 width: 14px;
229 height: 14px;
230 border: 2px solid #ccc;
231 border-top-color: #2271b1;
232 border-radius: 50%;
233 animation: ipcd-spin 0.6s linear infinite;
234 vertical-align: middle;
235 margin-right: 4px;
236}
237
238@keyframes ipcd-spin {
239 to { transform: rotate(360deg); }
240}
Addedintelligent-plugin-conflict-detector/admin/assets/js/admin.js+261−0View fileUnifiedSplit
@@ -0,0 +1,261 @@
1/* global jQuery, ipcdData */
2( function ( $ ) {
3 'use strict';
4
5 // -------------------------------------------------------------------------
6 // Tab switching
7 // -------------------------------------------------------------------------
8 $( '.ipcd-tab' ).on( 'click', function ( e ) {
9 var href = $( this ).attr( 'href' );
10 if ( ! href || href.charAt( 0 ) !== '#' ) {
11 return; // External link (settings page) – let it through.
12 }
13 e.preventDefault();
14 $( '.ipcd-tab' ).removeClass( 'active' );
15 $( this ).addClass( 'active' );
16 $( '.ipcd-tab-content' ).removeClass( 'active' );
17 $( href ).addClass( 'active' );
18 } );
19
20 // -------------------------------------------------------------------------
21 // Helpers
22 // -------------------------------------------------------------------------
23
24 /**
25 * Show a notice in the notice area.
26 *
27 * @param {string} message HTML message.
28 * @param {string} type 'success' | 'error' | 'warning' | 'info'.
29 */
30 function showNotice( message, type ) {
31 type = type || 'info';
32 var $notice = $( '<div class="notice notice-' + type + ' is-dismissible ipcd-notice"><p>' + message + '</p></div>' );
33 var $area = $( '#ipcd-notice-area' );
34 $area.empty().append( $notice );
35 // Scroll to the notice.
36 $( 'html, body' ).animate( { scrollTop: $area.offset().top - 40 }, 300 );
37 // Auto-dismiss after 5 s.
38 setTimeout( function () { $notice.fadeOut( 300, function () { $( this ).remove(); } ); }, 5000 );
39 }
40
41 /**
42 * Standard AJAX call wrapper.
43 *
44 * @param {string} action WP AJAX action.
45 * @param {Object} data Additional POST data.
46 * @param {Function} onSuccess Success callback (response.data).
47 * @param {Function} onError Error callback (response.data.message or WP_Error).
48 */
49 function ipcdAjax( action, data, onSuccess, onError ) {
50 var payload = $.extend( {}, data, {
51 action: action,
52 nonce: ipcdData.nonce,
53 } );
54
55 $.post( ipcdData.ajaxUrl, payload )
56 .done( function ( response ) {
57 if ( response && response.success ) {
58 if ( typeof onSuccess === 'function' ) {
59 onSuccess( response.data );
60 }
61 } else {
62 var msg = ( response && response.data && response.data.message )
63 ? response.data.message
64 : ipcdData.strings.error;
65 if ( typeof onError === 'function' ) {
66 onError( msg );
67 } else {
68 showNotice( msg, 'error' );
69 }
70 }
71 } )
72 .fail( function () {
73 var msg = ipcdData.strings.error;
74 if ( typeof onError === 'function' ) {
75 onError( msg );
76 } else {
77 showNotice( msg, 'error' );
78 }
79 } );
80 }
81
82 // -------------------------------------------------------------------------
83 // Rollback
84 // -------------------------------------------------------------------------
85 $( document ).on( 'click', '.ipcd-rollback-btn', function () {
86 var snapshotId = $( this ).data( 'snapshot-id' );
87 if ( ! window.confirm( ipcdData.strings.confirmRollback ) ) {
88 return;
89 }
90
91 var $btn = $( this );
92 $btn.prop( 'disabled', true ).html( '<span class="ipcd-spinner"></span>' + ipcdData.strings.rollingBack );
93
94 ipcdAjax(
95 'ipcd_rollback',
96 { snapshot_id: snapshotId },
97 function ( data ) {
98 showNotice( data.message, 'success' );
99 $btn.prop( 'disabled', false ).text( '⏪ Rollback' );
100 },
101 function ( msg ) {
102 showNotice( msg, 'error' );
103 $btn.prop( 'disabled', false ).text( '⏪ Rollback' );
104 }
105 );
106 } );
107
108 // -------------------------------------------------------------------------
109 // Resolve conflict
110 // -------------------------------------------------------------------------
111 $( document ).on( 'click', '.ipcd-resolve-btn', function () {
112 var conflictId = $( this ).data( 'conflict-id' );
113 var $row = $( this ).closest( 'tr' );
114 var $btn = $( this );
115
116 $btn.prop( 'disabled', true );
117
118 ipcdAjax(
119 'ipcd_resolve_conflict',
120 { conflict_id: conflictId },
121 function ( data ) {
122 showNotice( data.message, 'success' );
123 $row.fadeOut( 300, function () { $( this ).remove(); } );
124 // Update badge count.
125 var $badge = $( '.ipcd-tab[href="#tab-conflicts"] .ipcd-badge--error' );
126 var count = parseInt( $badge.text(), 10 ) - 1;
127 if ( count > 0 ) {
128 $badge.text( count );
129 } else {
130 $badge.remove();
131 }
132 },
133 function ( msg ) {
134 showNotice( msg, 'error' );
135 $btn.prop( 'disabled', false );
136 }
137 );
138 } );
139
140 // -------------------------------------------------------------------------
141 // Run test now
142 // -------------------------------------------------------------------------
143 $( '#ipcd-run-test-btn' ).on( 'click', function () {
144 var $btn = $( this );
145 $btn.prop( 'disabled', true ).html( '<span class="ipcd-spinner"></span>' + ipcdData.strings.runningTest );
146
147 ipcdAjax(
148 'ipcd_run_test',
149 { plugin: '', event: 'manual' },
150 function ( data ) {
151 showNotice( data.message, 'success' );
152 $btn.prop( 'disabled', false ).text( 'Run Test Now' );
153 },
154 function ( msg ) {
155 showNotice( msg, 'error' );
156 $btn.prop( 'disabled', false ).text( 'Run Test Now' );
157 }
158 );
159 } );
160
161 // -------------------------------------------------------------------------
162 // Create snapshot
163 // -------------------------------------------------------------------------
164 $( '#ipcd-create-snapshot-btn' ).on( 'click', function () {
165 var label = window.prompt( 'Enter a label for this snapshot (optional):', 'manual_snapshot' );
166 if ( null === label ) {
167 return; // Cancelled.
168 }
169
170 var $btn = $( this );
171 $btn.prop( 'disabled', true );
172
173 ipcdAjax(
174 'ipcd_create_snapshot',
175 { label: label || 'manual_snapshot' },
176 function ( data ) {
177 showNotice( data.message + ' ID: ' + data.snapshot_id, 'success' );
178 $btn.prop( 'disabled', false );
179 // Reload to show the new snapshot.
180 setTimeout( function () { window.location.reload(); }, 1500 );
181 },
182 function ( msg ) {
183 showNotice( msg, 'error' );
184 $btn.prop( 'disabled', false );
185 }
186 );
187 } );
188
189 // -------------------------------------------------------------------------
190 // Delete snapshot
191 // -------------------------------------------------------------------------
192 $( document ).on( 'click', '.ipcd-delete-snapshot-btn', function () {
193 if ( ! window.confirm( 'Delete this snapshot? This cannot be undone.' ) ) {
194 return;
195 }
196
197 var snapshotId = $( this ).data( 'snapshot-id' );
198 var $row = $( this ).closest( 'tr' );
199
200 ipcdAjax(
201 'ipcd_delete_snapshot',
202 { snapshot_id: snapshotId },
203 function ( data ) {
204 showNotice( data.message, 'success' );
205 $row.fadeOut( 300, function () { $( this ).remove(); } );
206 }
207 );
208 } );
209
210 // -------------------------------------------------------------------------
211 // Clear all conflicts
212 // -------------------------------------------------------------------------
213 $( '#ipcd-clear-conflicts-btn, #ipcd-clear-all-conflicts-btn' ).on( 'click', function () {
214 if ( ! window.confirm( ipcdData.strings.confirmClear ) ) {
215 return;
216 }
217
218 ipcdAjax(
219 'ipcd_clear_conflicts',
220 {},
221 function ( data ) {
222 showNotice( data.message, 'success' );
223 setTimeout( function () { window.location.reload(); }, 1500 );
224 }
225 );
226 } );
227
228 // -------------------------------------------------------------------------
229 // Save settings
230 // -------------------------------------------------------------------------
231 $( '#ipcd-settings-form' ).on( 'submit', function ( e ) {
232 e.preventDefault();
233
234 var $btn = $( '#ipcd-save-settings-btn' );
235 var data = {};
236
237 $( this ).serializeArray().forEach( function ( item ) {
238 data[ item.name ] = item.value;
239 } );
240
241 // Include unchecked checkboxes as 0.
242 if ( ! data.email_notifications ) { data.email_notifications = 0; }
243 if ( ! data.auto_rollback ) { data.auto_rollback = 0; }
244
245 $btn.prop( 'disabled', true ).text( ipcdData.strings.saving );
246
247 ipcdAjax(
248 'ipcd_save_settings',
249 data,
250 function ( resp ) {
251 showNotice( resp.message, 'success' );
252 $btn.prop( 'disabled', false ).text( 'Save Settings' );
253 },
254 function ( msg ) {
255 showNotice( msg, 'error' );
256 $btn.prop( 'disabled', false ).text( 'Save Settings' );
257 }
258 );
259 } );
260
261} )( jQuery );
Addedintelligent-plugin-conflict-detector/admin/class-admin.php+347−0View fileUnifiedSplit
@@ -0,0 +1,347 @@
1
2/**
3 * Admin Class
4 *
5 * Registers admin menus, AJAX handlers and settings page for the IPCD plugin.
6 *
7 * @package IPCD
8 */
9
10if ( ! defined( 'ABSPATH' ) ) {
11 exit;
12}
13
14/**
15 * Class IPCD_Admin
16 */
17class IPCD_Admin {
18
19 /** @var IPCD_Conflict_Detector */
20 private $detector;
21
22 /** @var IPCD_Rollback_Manager */
23 private $rollback;
24
25 /** @var IPCD_Notification_Manager */
26 private $notifications;
27
28 /** @var IPCD_Background_Tester */
29 private $tester;
30
31 /** @var IPCD_Plugin_State_Manager */
32 private $state_manager;
33
34 /**
35 * Constructor – wire admin hooks.
36 *
37 * @param IPCD_Conflict_Detector $detector Conflict detector.
38 * @param IPCD_Rollback_Manager $rollback Rollback manager.
39 * @param IPCD_Notification_Manager $notifications Notification manager.
40 * @param IPCD_Background_Tester $tester Background tester.
41 * @param IPCD_Plugin_State_Manager $state_manager State manager.
42 */
43 public function __construct(
44 IPCD_Conflict_Detector $detector,
45 IPCD_Rollback_Manager $rollback,
46 IPCD_Notification_Manager $notifications,
47 IPCD_Background_Tester $tester,
48 IPCD_Plugin_State_Manager $state_manager
49 ) {
50 $this->detector = $detector;
51 $this->rollback = $rollback;
52 $this->notifications = $notifications;
53 $this->tester = $tester;
54 $this->state_manager = $state_manager;
55
56 add_action( 'admin_menu', array( $this, 'register_admin_menu' ) );
57 add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_assets' ) );
58
59 // AJAX handlers.
60 add_action( 'wp_ajax_ipcd_rollback', array( $this, 'ajax_rollback' ) );
61 add_action( 'wp_ajax_ipcd_resolve_conflict', array( $this, 'ajax_resolve_conflict' ) );
62 add_action( 'wp_ajax_ipcd_run_test', array( $this, 'ajax_run_test' ) );
63 add_action( 'wp_ajax_ipcd_save_settings', array( $this, 'ajax_save_settings' ) );
64 add_action( 'wp_ajax_ipcd_create_snapshot', array( $this, 'ajax_create_snapshot' ) );
65 add_action( 'wp_ajax_ipcd_delete_snapshot', array( $this, 'ajax_delete_snapshot' ) );
66 add_action( 'wp_ajax_ipcd_clear_conflicts', array( $this, 'ajax_clear_all_conflicts' ) );
67
68 // Lightweight health-ping endpoint (no auth required – used by the background tester).
69 add_action( 'wp_ajax_nopriv_ipcd_health_ping', array( $this, 'health_ping' ) );
70 add_action( 'wp_ajax_ipcd_health_ping', array( $this, 'health_ping' ) );
71
72 // Admin columns on Plugins page.
73 add_filter( 'plugin_action_links', array( $this, 'add_plugin_action_links' ), 10, 2 );
74 }
75
76 // -------------------------------------------------------------------------
77 // Menu & Assets
78 // -------------------------------------------------------------------------
79
80 /**
81 * Register the admin menu pages.
82 */
83 public function register_admin_menu(): void {
84 add_management_page(
85 __( 'Plugin Conflict Detector', 'ipcd' ),
86 __( 'Conflict Detector', 'ipcd' ),
87 'manage_options',
88 'ipcd-dashboard',
89 array( $this, 'render_dashboard' )
90 );
91
92 add_submenu_page(
93 null, // Hidden submenu (accessed via tab on dashboard page).
94 __( 'IPCD Settings', 'ipcd' ),
95 __( 'Settings', 'ipcd' ),
96 'manage_options',
97 'ipcd-settings',
98 array( $this, 'render_settings' )
99 );
100 }
101
102 /**
103 * Enqueue admin CSS and JS on IPCD pages.
104 *
105 * @param string $hook Current admin page hook.
106 */
107 public function enqueue_assets( string $hook ): void {
108 $ipcd_pages = array( 'tools_page_ipcd-dashboard', 'admin_page_ipcd-settings' );
109 if ( ! in_array( $hook, $ipcd_pages, true ) ) {
110 return;
111 }
112
113 wp_enqueue_style(
114 'ipcd-admin',
115 IPCD_PLUGIN_URL . 'admin/assets/css/admin.css',
116 array(),
117 IPCD_VERSION
118 );
119
120 wp_enqueue_script(
121 'ipcd-admin',
122 IPCD_PLUGIN_URL . 'admin/assets/js/admin.js',
123 array( 'jquery' ),
124 IPCD_VERSION,
125 true
126 );
127
128 wp_localize_script(
129 'ipcd-admin',
130 'ipcdData',
131 array(
132 'ajaxUrl' => admin_url( 'admin-ajax.php' ),
133 'nonce' => wp_create_nonce( 'ipcd_nonce' ),
134 'strings' => array(
135 'confirmRollback' => __( 'Are you sure you want to rollback to this snapshot? Your current plugin state will be saved first.', 'ipcd' ),
136 'confirmClear' => __( 'Are you sure you want to clear all conflict records?', 'ipcd' ),
137 'rollingBack' => __( 'Rolling back…', 'ipcd' ),
138 'runningTest' => __( 'Running test…', 'ipcd' ),
139 'saving' => __( 'Saving…', 'ipcd' ),
140 'done' => __( 'Done!', 'ipcd' ),
141 'error' => __( 'An error occurred. Please try again.', 'ipcd' ),
142 ),
143 )
144 );
145 }
146
147 // -------------------------------------------------------------------------
148 // View renderers
149 // -------------------------------------------------------------------------
150
151 /**
152 * Render the dashboard page.
153 */
154 public function render_dashboard(): void {
155 if ( ! current_user_can( 'manage_options' ) ) {
156 wp_die( esc_html__( 'You do not have permission to access this page.', 'ipcd' ) );
157 }
158 include IPCD_PLUGIN_DIR . 'admin/views/dashboard.php';
159 }
160
161 /**
162 * Render the settings page.
163 */
164 public function render_settings(): void {
165 if ( ! current_user_can( 'manage_options' ) ) {
166 wp_die( esc_html__( 'You do not have permission to access this page.', 'ipcd' ) );
167 }
168 include IPCD_PLUGIN_DIR . 'admin/views/settings.php';
169 }
170
171 // -------------------------------------------------------------------------
172 // AJAX handlers
173 // -------------------------------------------------------------------------
174
175 /**
176 * AJAX: Perform a rollback.
177 */
178 public function ajax_rollback(): void {
179 $this->verify_ajax_nonce();
180
181 $snapshot_id = sanitize_text_field( $_POST['snapshot_id'] ?? '' );
182 if ( ! $snapshot_id ) {
183 wp_send_json_error( array( 'message' => __( 'Missing snapshot ID.', 'ipcd' ) ) );
184 }
185
186 $result = $this->rollback->rollback( $snapshot_id );
187 if ( $result['success'] ) {
188 wp_send_json_success( $result );
189 } else {
190 wp_send_json_error( $result );
191 }
192 }
193
194 /**
195 * AJAX: Resolve a conflict.
196 */
197 public function ajax_resolve_conflict(): void {
198 $this->verify_ajax_nonce();
199
200 $conflict_id = sanitize_text_field( $_POST['conflict_id'] ?? '' );
201 if ( ! $conflict_id ) {
202 wp_send_json_error( array( 'message' => __( 'Missing conflict ID.', 'ipcd' ) ) );
203 }
204
205 $resolved = $this->detector->resolve_conflict( $conflict_id );
206 if ( $resolved ) {
207 wp_send_json_success( array( 'message' => __( 'Conflict marked as resolved.', 'ipcd' ) ) );
208 } else {
209 wp_send_json_error( array( 'message' => __( 'Conflict not found.', 'ipcd' ) ) );
210 }
211 }
212
213 /**
214 * AJAX: Manually trigger a background test run.
215 */
216 public function ajax_run_test(): void {
217 $this->verify_ajax_nonce();
218
219 $plugin = sanitize_text_field( $_POST['plugin'] ?? '' );
220 $event = sanitize_key( $_POST['event'] ?? 'manual' );
221
222 if ( ! $plugin ) {
223 // Run a full-site health probe with no specific plugin attributed.
224 $plugin = 'manual-check';
225 }
226
227 $this->tester->run_single_test( $plugin, $event );
228
229 wp_send_json_success( array( 'message' => __( 'Test completed. Refresh the page to see results.', 'ipcd' ) ) );
230 }
231
232 /**
233 * AJAX: Save settings.
234 */
235 public function ajax_save_settings(): void {
236 $this->verify_ajax_nonce();
237
238 $this->notifications->save_settings( $_POST );
239 wp_send_json_success( array( 'message' => __( 'Settings saved.', 'ipcd' ) ) );
240 }
241
242 /**
243 * AJAX: Create a manual snapshot.
244 */
245 public function ajax_create_snapshot(): void {
246 $this->verify_ajax_nonce();
247
248 $label = sanitize_text_field( $_POST['label'] ?? 'manual_snapshot' );
249 $snapshot_id = $this->state_manager->capture_snapshot( $label );
250 wp_send_json_success(
251 array(
252 'snapshot_id' => $snapshot_id,
253 'message' => __( 'Snapshot created successfully.', 'ipcd' ),
254 )
255 );
256 }
257
258 /**
259 * AJAX: Delete a snapshot.
260 */
261 public function ajax_delete_snapshot(): void {
262 $this->verify_ajax_nonce();
263
264 $snapshot_id = sanitize_text_field( $_POST['snapshot_id'] ?? '' );
265 if ( ! $snapshot_id ) {
266 wp_send_json_error( array( 'message' => __( 'Missing snapshot ID.', 'ipcd' ) ) );
267 }
268
269 $deleted = $this->state_manager->delete_snapshot( $snapshot_id );
270 if ( $deleted ) {
271 wp_send_json_success( array( 'message' => __( 'Snapshot deleted.', 'ipcd' ) ) );
272 } else {
273 wp_send_json_error( array( 'message' => __( 'Snapshot not found.', 'ipcd' ) ) );
274 }
275 }
276
277 /**
278 * AJAX: Clear all conflict records.
279 */
280 public function ajax_clear_all_conflicts(): void {
281 $this->verify_ajax_nonce();
282
283 $this->detector->clear_all_conflicts();
284 wp_send_json_success( array( 'message' => __( 'All conflicts cleared.', 'ipcd' ) ) );
285 }
286
287 /**
288 * Health ping endpoint – returns 200 OK with a JSON body.
289 */
290 public function health_ping(): void {
291 wp_send_json_success( array( 'status' => 'ok' ) );
292 }
293
294 // -------------------------------------------------------------------------
295 // Plugins list enhancement
296 // -------------------------------------------------------------------------
297
298 /**
299 * Add "View Conflicts" link to plugin action links if conflicts exist.
300 *
301 * @param array $actions Existing action links.
302 * @param string $plugin_file Plugin basename.
303 *
304 * @return array
305 */
306 public function add_plugin_action_links( array $actions, string $plugin_file ): array {
307 $conflicts = array_filter(
308 $this->detector->get_active_conflicts(),
309 static function ( $c ) use ( $plugin_file ) {
310 return $c['plugin'] === $plugin_file;
311 }
312 );
313
314 if ( ! empty( $conflicts ) ) {
315 $count = count( $conflicts );
316 $url = admin_url( 'tools.php?page=ipcd-dashboard' );
317 $actions['ipcd'] = sprintf(
318 '<a href="%s" style="color:#d63638;font-weight:600;">⚠ %s</a>',
319 esc_url( $url ),
320 sprintf(
321 /* translators: %d: number of conflicts */
322 _n( '%d Conflict', '%d Conflicts', $count, 'ipcd' ),
323 $count
324 )
325 );
326 }
327
328 return $actions;
329 }
330
331 // -------------------------------------------------------------------------
332 // Private helpers
333 // -------------------------------------------------------------------------
334
335 /**
336 * Verify the AJAX nonce and capability, die on failure.
337 */
338 private function verify_ajax_nonce(): void {
339 if (
340 ! check_ajax_referer( 'ipcd_nonce', 'nonce', false ) ||
341 ! current_user_can( 'manage_options' )
342 ) {
343 wp_send_json_error( array( 'message' => __( 'Permission denied.', 'ipcd' ) ), 403 );
344 wp_die();
345 }
346 }
347}
Addedintelligent-plugin-conflict-detector/admin/views/dashboard.php+267−0View fileUnifiedSplit
@@ -0,0 +1,267 @@
1
2/**
3 * Dashboard view
4 *
5 * @package IPCD
6 * @var IPCD_Conflict_Detector $detector
7 * @var IPCD_Rollback_Manager $rollback
8 * @var IPCD_Background_Tester $tester
9 * @var IPCD_Plugin_State_Manager $state_manager
10 */
11
12if ( ! defined( 'ABSPATH' ) ) {
13 exit;
14}
15
16// Make objects available inside the view.
17$detector = ipcd()->conflict_detector;
18$rollback = ipcd()->rollback_manager;
19$tester = ipcd()->background_tester;
20$state_manager = ipcd()->state_manager;
21
22$active_conflicts = $detector->get_active_conflicts();
23$all_conflicts = $detector->get_conflicts();
24$snapshots = $state_manager->get_snapshots();
25$rollback_history = $rollback->get_history();
26$next_run = $tester->get_next_run();
27$queue = $tester->get_queue();
28
29$severity_icons = array(
30 'critical' => '🔴',
31 'warning' => '🟡',
32 'info' => '🔵',
33);
34
35<div class="wrap ipcd-wrap">
36 <h1 class="ipcd-page-title">
37 <span class="ipcd-logo">🔍</span>
38 <?php esc_html_e( 'Intelligent Plugin Conflict Detector', 'ipcd' ); ?>
39 <span class="ipcd-version">v<?php echo esc_html( IPCD_VERSION ); ?></span>
40 </h1>
41
42 <nav class="ipcd-tabs">
43 <a href="#tab-conflicts" class="ipcd-tab active"><?php esc_html_e( 'Conflicts', 'ipcd' ); ?>
44 <?php if ( ! empty( $active_conflicts ) ) : ?>
45 <span class="ipcd-badge ipcd-badge--error"><?php echo esc_html( count( $active_conflicts ) ); ?></span>
46 <?php endif; ?>
47 </a>
48 <a href="#tab-snapshots" class="ipcd-tab"><?php esc_html_e( 'Snapshots', 'ipcd' ); ?>
49 <span class="ipcd-badge"><?php echo esc_html( count( $snapshots ) ); ?></span>
50 </a>
51 <a href="#tab-history" class="ipcd-tab"><?php esc_html_e( 'Rollback History', 'ipcd' ); ?></a>
52 <a href="<?php echo esc_url( admin_url( 'admin.php?page=ipcd-settings' ) ); ?>" class="ipcd-tab"><?php esc_html_e( 'Settings', 'ipcd' ); ?></a>
53 </nav>
54
55 <!-- STATUS BAR -->
56 <div class="ipcd-status-bar">
57 <div class="ipcd-status-item">
58 <strong><?php esc_html_e( 'Background Testing:', 'ipcd' ); ?></strong>
59 <span class="ipcd-pill ipcd-pill--green"><?php esc_html_e( 'Active', 'ipcd' ); ?></span>
60 </div>
61 <div class="ipcd-status-item">
62 <strong><?php esc_html_e( 'Next Scan:', 'ipcd' ); ?></strong>
63 <?php echo $next_run ? esc_html( human_time_diff( time(), $next_run ) . ' ' . __( 'from now', 'ipcd' ) ) : esc_html__( 'Not scheduled', 'ipcd' ); ?>
64 </div>
65 <div class="ipcd-status-item">
66 <strong><?php esc_html_e( 'Queue:', 'ipcd' ); ?></strong>
67 <?php
68 $queue_count = count( $queue );
69 /* translators: %d: number of pending tests */
70 echo esc_html( sprintf( _n( '%d pending test', '%d pending tests', $queue_count, 'ipcd' ), $queue_count ) );
71 ?>
72 </div>
73 <div class="ipcd-status-actions">
74 <button id="ipcd-run-test-btn" class="button button-secondary" data-plugin="" data-event="manual">
75 <?php esc_html_e( 'Run Test Now', 'ipcd' ); ?>
76 </button>
77 <button id="ipcd-create-snapshot-btn" class="button button-secondary">
78 <?php esc_html_e( 'Create Snapshot', 'ipcd' ); ?>
79 </button>
80 </div>
81 </div>
82
83 <div id="ipcd-notice-area"></div>
84
85 <!-- TAB: CONFLICTS -->
86 <div id="tab-conflicts" class="ipcd-tab-content active">
87 <div class="ipcd-card">
88 <div class="ipcd-card-header">
89 <h2><?php esc_html_e( 'Active Conflicts', 'ipcd' ); ?></h2>
90 <?php if ( ! empty( $active_conflicts ) ) : ?>
91 <button id="ipcd-clear-conflicts-btn" class="button button-link-delete">
92 <?php esc_html_e( 'Clear All', 'ipcd' ); ?>
93 </button>
94 <?php endif; ?>
95 </div>
96
97 <?php if ( empty( $active_conflicts ) ) : ?>
98 <div class="ipcd-empty-state">
99 <span class="ipcd-empty-icon">✅</span>
100 <p><?php esc_html_e( 'No active conflicts detected. Your site looks healthy!', 'ipcd' ); ?></p>
101 </div>
102 <?php else : ?>
103 <table class="wp-list-table widefat fixed striped ipcd-table">
104 <thead>
105 <tr>
106 <th><?php esc_html_e( 'Severity', 'ipcd' ); ?></th>
107 <th><?php esc_html_e( 'Plugin', 'ipcd' ); ?></th>
108 <th><?php esc_html_e( 'Type', 'ipcd' ); ?></th>
109 <th><?php esc_html_e( 'Message', 'ipcd' ); ?></th>
110 <th><?php esc_html_e( 'Detected', 'ipcd' ); ?></th>
111 <th><?php esc_html_e( 'Actions', 'ipcd' ); ?></th>
112 </tr>
113 </thead>
114 <tbody>
115 <?php foreach ( $active_conflicts as $conflict ) : ?>
116 <tr class="ipcd-conflict-row ipcd-severity-<?php echo esc_attr( $conflict['severity'] ); ?>" data-conflict-id="<?php echo esc_attr( $conflict['id'] ); ?>">
117 <td>
118 <?php echo esc_html( $severity_icons[ $conflict['severity'] ] ?? '⚪' ); ?>
119 <strong><?php echo esc_html( ucfirst( $conflict['severity'] ) ); ?></strong>
120 </td>
121 <td><code><?php echo esc_html( $conflict['plugin'] ); ?></code></td>
122 <td><code><?php echo esc_html( $conflict['type'] ); ?></code></td>
123 <td><?php echo esc_html( $conflict['message'] ); ?></td>
124 <td><?php echo esc_html( human_time_diff( $conflict['time'], time() ) . ' ago' ); ?></td>
125 <td>
126 <button class="button button-small ipcd-resolve-btn" data-conflict-id="<?php echo esc_attr( $conflict['id'] ); ?>">
127 <?php esc_html_e( 'Resolve', 'ipcd' ); ?>
128 </button>
129 </td>
130 </tr>
131 <?php if ( ! empty( $conflict['context'] ) ) : ?>
132 <tr class="ipcd-context-row">
133 <td colspan="6">
134 <details>
135 <summary><?php esc_html_e( 'Context', 'ipcd' ); ?></summary>
136 <pre class="ipcd-context-pre"><?php echo esc_html( wp_json_encode( $conflict['context'], JSON_PRETTY_PRINT ) ); ?></pre>
137 </details>
138 </td>
139 </tr>
140 <?php endif; ?>
141 <?php endforeach; ?>
142 </tbody>
143 </table>
144 <?php endif; ?>
145 </div>
146
147 <?php if ( count( $all_conflicts ) > count( $active_conflicts ) ) : ?>
148 <div class="ipcd-card">
149 <div class="ipcd-card-header">
150 <h2><?php esc_html_e( 'Resolved Conflicts', 'ipcd' ); ?></h2>
151 </div>
152 <table class="wp-list-table widefat fixed striped ipcd-table">
153 <thead>
154 <tr>
155 <th><?php esc_html_e( 'Plugin', 'ipcd' ); ?></th>
156 <th><?php esc_html_e( 'Type', 'ipcd' ); ?></th>
157 <th><?php esc_html_e( 'Message', 'ipcd' ); ?></th>
158 <th><?php esc_html_e( 'Detected', 'ipcd' ); ?></th>
159 </tr>
160 </thead>
161 <tbody>
162 <?php foreach ( $all_conflicts as $conflict ) : ?>
163 <?php if ( ! empty( $conflict['resolved'] ) ) : ?>
164 <tr class="ipcd-resolved-row">
165 <td><code><?php echo esc_html( $conflict['plugin'] ); ?></code></td>
166 <td><code><?php echo esc_html( $conflict['type'] ); ?></code></td>
167 <td><?php echo esc_html( $conflict['message'] ); ?></td>
168 <td><?php echo esc_html( human_time_diff( $conflict['time'], time() ) . ' ago' ); ?></td>
169 </tr>
170 <?php endif; ?>
171 <?php endforeach; ?>
172 </tbody>
173 </table>
174 </div>
175 <?php endif; ?>
176 </div>
177
178 <!-- TAB: SNAPSHOTS -->
179 <div id="tab-snapshots" class="ipcd-tab-content">
180 <div class="ipcd-card">
181 <div class="ipcd-card-header">
182 <h2><?php esc_html_e( 'Plugin State Snapshots', 'ipcd' ); ?></h2>
183 <p class="description"><?php esc_html_e( 'Snapshots capture the active plugin list at a point in time. Use them to roll back if a plugin causes issues.', 'ipcd' ); ?></p>
184 </div>
185
186 <?php if ( empty( $snapshots ) ) : ?>
187 <div class="ipcd-empty-state">
188 <span class="ipcd-empty-icon">📷</span>
189 <p><?php esc_html_e( 'No snapshots yet. Snapshots are created automatically when plugins are activated or updated, or you can create one manually.', 'ipcd' ); ?></p>
190 </div>
191 <?php else : ?>
192 <table class="wp-list-table widefat fixed striped ipcd-table">
193 <thead>
194 <tr>
195 <th><?php esc_html_e( 'Label', 'ipcd' ); ?></th>
196 <th><?php esc_html_e( 'Snapshot ID', 'ipcd' ); ?></th>
197 <th><?php esc_html_e( 'Created', 'ipcd' ); ?></th>
198 <th><?php esc_html_e( 'Actions', 'ipcd' ); ?></th>
199 </tr>
200 </thead>
201 <tbody>
202 <?php foreach ( $snapshots as $snapshot ) : ?>
203 <tr>
204 <td><?php echo esc_html( $snapshot['label'] ?: __( '(manual)', 'ipcd' ) ); ?></td>
205 <td><code><?php echo esc_html( $snapshot['snapshot_id'] ); ?></code></td>
206 <td><?php echo esc_html( $snapshot['created_at'] ); ?></td>
207 <td class="ipcd-actions-cell">
208 <button class="button button-primary ipcd-rollback-btn"
209 data-snapshot-id="<?php echo esc_attr( $snapshot['snapshot_id'] ); ?>">
210 ⏪ <?php esc_html_e( 'Rollback', 'ipcd' ); ?>
211 </button>
212 <button class="button button-link-delete ipcd-delete-snapshot-btn"
213 data-snapshot-id="<?php echo esc_attr( $snapshot['snapshot_id'] ); ?>">
214 <?php esc_html_e( 'Delete', 'ipcd' ); ?>
215 </button>
216 </td>
217 </tr>
218 <?php endforeach; ?>
219 </tbody>
220 </table>
221 <?php endif; ?>
222 </div>
223 </div>
224
225 <!-- TAB: ROLLBACK HISTORY -->
226 <div id="tab-history" class="ipcd-tab-content">
227 <div class="ipcd-card">
228 <div class="ipcd-card-header">
229 <h2><?php esc_html_e( 'Rollback History', 'ipcd' ); ?></h2>
230 </div>
231
232 <?php if ( empty( $rollback_history ) ) : ?>
233 <div class="ipcd-empty-state">
234 <span class="ipcd-empty-icon">📋</span>
235 <p><?php esc_html_e( 'No rollbacks have been performed yet.', 'ipcd' ); ?></p>
236 </div>
237 <?php else : ?>
238 <table class="wp-list-table widefat fixed striped ipcd-table">
239 <thead>
240 <tr>
241 <th><?php esc_html_e( 'Restored Snapshot', 'ipcd' ); ?></th>
242 <th><?php esc_html_e( 'Safety Snapshot', 'ipcd' ); ?></th>
243 <th><?php esc_html_e( 'Status', 'ipcd' ); ?></th>
244 <th><?php esc_html_e( 'Time', 'ipcd' ); ?></th>
245 </tr>
246 </thead>
247 <tbody>
248 <?php foreach ( $rollback_history as $entry ) : ?>
249 <tr>
250 <td><code><?php echo esc_html( $entry['snapshot_id'] ); ?></code></td>
251 <td><code><?php echo esc_html( $entry['pre_rollback_id'] ); ?></code></td>
252 <td>
253 <?php if ( 'success' === $entry['status'] ) : ?>
254 <span class="ipcd-pill ipcd-pill--green"><?php esc_html_e( 'Success', 'ipcd' ); ?></span>
255 <?php else : ?>
256 <span class="ipcd-pill ipcd-pill--red"><?php esc_html_e( 'Failed', 'ipcd' ); ?></span>
257 <?php endif; ?>
258 </td>
259 <td><?php echo esc_html( human_time_diff( $entry['time'], time() ) . ' ago' ); ?></td>
260 </tr>
261 <?php endforeach; ?>
262 </tbody>
263 </table>
264 <?php endif; ?>
265 </div>
266 </div>
267</div>
Addedintelligent-plugin-conflict-detector/admin/views/settings.php+116−0View fileUnifiedSplit
@@ -0,0 +1,116 @@
1
2/**
3 * Settings view
4 *
5 * @package IPCD
6 */
7
8if ( ! defined( 'ABSPATH' ) ) {
9 exit;
10}
11
12$settings = ipcd()->notification_manager->get_settings();
13
14<div class="wrap ipcd-wrap">
15 <h1 class="ipcd-page-title">
16 <span class="ipcd-logo">⚙️</span>
17 <?php esc_html_e( 'Conflict Detector — Settings', 'ipcd' ); ?>
18 </h1>
19
20 <div id="ipcd-notice-area"></div>
21
22 <div class="ipcd-card" style="max-width:700px;">
23 <form id="ipcd-settings-form">
24 <table class="form-table">
25 <tbody>
26 <tr>
27 <th scope="row">
28 <label for="ipcd_email_notifications">
29 <?php esc_html_e( 'Email Notifications', 'ipcd' ); ?>
30 </label>
31 </th>
32 <td>
33 <label>
34 <input type="checkbox"
35 id="ipcd_email_notifications"
36 name="email_notifications"
37 value="1"
38 <?php checked( $settings['email_notifications'] ); ?> />
39 <?php esc_html_e( 'Send email alerts when conflicts are detected', 'ipcd' ); ?>
40 </label>
41 </td>
42 </tr>
43 <tr>
44 <th scope="row">
45 <label for="ipcd_email_address">
46 <?php esc_html_e( 'Alert Email Address', 'ipcd' ); ?>
47 </label>
48 </th>
49 <td>
50 <input type="email"
51 id="ipcd_email_address"
52 name="email_address"
53 class="regular-text"
54 value="<?php echo esc_attr( $settings['email_address'] ); ?>" />
55 <p class="description"><?php esc_html_e( 'Defaults to the WordPress admin email.', 'ipcd' ); ?></p>
56 </td>
57 </tr>
58 <tr>
59 <th scope="row">
60 <label for="ipcd_auto_rollback">
61 <?php esc_html_e( 'Auto Rollback', 'ipcd' ); ?>
62 </label>
63 </th>
64 <td>
65 <label>
66 <input type="checkbox"
67 id="ipcd_auto_rollback"
68 name="auto_rollback"
69 value="1"
70 <?php checked( $settings['auto_rollback'] ); ?> />
71 <?php esc_html_e( 'Automatically roll back when a critical conflict is detected', 'ipcd' ); ?>
72 </label>
73 <p class="description">
74 <?php esc_html_e( 'Use with caution. This will restore the previous plugin state immediately after a critical conflict is found.', 'ipcd' ); ?>
75 </p>
76 </td>
77 </tr>
78 <tr>
79 <th scope="row">
80 <label for="ipcd_scan_interval">
81 <?php esc_html_e( 'Scan Interval (hours)', 'ipcd' ); ?>
82 </label>
83 </th>
84 <td>
85 <input type="number"
86 id="ipcd_scan_interval"
87 name="scan_interval_hours"
88 class="small-text"
89 min="1"
90 max="168"
91 value="<?php echo esc_attr( $settings['scan_interval_hours'] ); ?>" />
92 <p class="description"><?php esc_html_e( 'How often to run scheduled background tests. Minimum 1 hour.', 'ipcd' ); ?></p>
93 </td>
94 </tr>
95 </tbody>
96 </table>
97
98 <p class="submit">
99 <button id="ipcd-save-settings-btn" type="submit" class="button button-primary">
100 <?php esc_html_e( 'Save Settings', 'ipcd' ); ?>
101 </button>
102 <a href="<?php echo esc_url( admin_url( 'tools.php?page=ipcd-dashboard' ) ); ?>" class="button button-secondary">
103 <?php esc_html_e( '← Back to Dashboard', 'ipcd' ); ?>
104 </a>
105 </p>
106 </form>
107 </div>
108
109 <div class="ipcd-card" style="max-width:700px;margin-top:20px;">
110 <h2><?php esc_html_e( 'Danger Zone', 'ipcd' ); ?></h2>
111 <p><?php esc_html_e( 'These actions cannot be undone.', 'ipcd' ); ?></p>
112 <button id="ipcd-clear-conflicts-btn" class="button button-link-delete">
113 <?php esc_html_e( 'Clear All Conflict Records', 'ipcd' ); ?>
114 </button>
115 </div>
116</div>
Addedintelligent-plugin-conflict-detector/composer.json+29−0View fileUnifiedSplit
@@ -0,0 +1,29 @@
1{
2 "name": "ccantynz-alt/intelligent-plugin-conflict-detector",
3 "description": "WordPress plugin that intelligently detects plugin conflicts, tests in a safe environment, alerts users, and provides one-click rollback.",
4 "type": "wordpress-plugin",
5 "license": "GPL-2.0-or-later",
6 "require": {
7 "php": ">=7.4"
8 },
9 "require-dev": {
10 "phpunit/phpunit": "^9.6",
11 "brain/monkey": "^2.6",
12 "mockery/mockery": "^1.5"
13 },
14 "autoload-dev": {
15 "psr-4": {
16 "IPCD\\Tests\\": "tests/"
17 }
18 },
19 "scripts": {
20 "test": "phpunit"
21 },
22 "config": {
23 "optimize-autoloader": true,
24 "sort-packages": true,
25 "allow-plugins": {
26 "dealerdirect/phpcodesniffer-composer-installer": true
27 }
28 }
29}
Addedintelligent-plugin-conflict-detector/composer.lock+2070−0View fileUnifiedSplit
Large file (2,070 lines). Load full file
Addedintelligent-plugin-conflict-detector/includes/class-background-tester.php+187−0View fileUnifiedSplit
@@ -0,0 +1,187 @@
1
2/**
3 * Background Tester
4 *
5 * Schedules and executes background test runs that probe the site for
6 * conflicts introduced by a plugin activation or update. Tests run via
7 * WordPress Cron so they never block a real page request.
8 *
9 * @package IPCD
10 */
11
12if ( ! defined( 'ABSPATH' ) ) {
13 exit;
14}
15
16/**
17 * Class IPCD_Background_Tester
18 */
19class IPCD_Background_Tester {
20
21 /** Cron action name. */
22 const CRON_HOOK = 'ipcd_background_test';
23
24 /** Option key that stores the queue of pending tests. */
25 const QUEUE_OPTION = 'ipcd_test_queue';
26
27 /** @var IPCD_Conflict_Detector */
28 private $detector;
29
30 /** @var IPCD_Plugin_State_Manager */
31 private $state_manager;
32
33 /**
34 * Constructor.
35 *
36 * @param IPCD_Conflict_Detector $detector Conflict detector instance.
37 * @param IPCD_Plugin_State_Manager $state_manager State manager instance.
38 */
39 public function __construct(
40 IPCD_Conflict_Detector $detector,
41 IPCD_Plugin_State_Manager $state_manager
42 ) {
43 $this->detector = $detector;
44 $this->state_manager = $state_manager;
45
46 add_action( self::CRON_HOOK, array( $this, 'run_queued_tests' ) );
47 }
48
49 /**
50 * Register the custom cron schedule (every 6 hours).
51 */
52 public static function register_cron_schedule(): void {
53 add_filter(
54 'cron_schedules',
55 static function ( array $schedules ): array {
56 $schedules['ipcd_every_6_hours'] = array(
57 'interval' => 6 * HOUR_IN_SECONDS,
58 'display' => __( 'Every 6 Hours (IPCD)', 'ipcd' ),
59 );
60 return $schedules;
61 }
62 );
63 }
64
65 /**
66 * Add a test to the queue.
67 *
68 * @param string $plugin_basename Plugin that was changed.
69 * @param string $event 'activated' | 'updated'.
70 */
71 public function schedule_test( string $plugin_basename, string $event ): void {
72 $queue = $this->get_queue();
73 $queue[] = array(
74 'plugin' => $plugin_basename,
75 'event' => $event,
76 'queued_at' => time(),
77 );
78 update_option( self::QUEUE_OPTION, $queue );
79
80 // Trigger immediately via a one-off cron event.
81 if ( ! wp_next_scheduled( self::CRON_HOOK ) ) {
82 wp_schedule_single_event( time() + 5, self::CRON_HOOK );
83 }
84 }
85
86 /**
87 * Process all queued tests. Called by WP-Cron.
88 */
89 public function run_queued_tests(): void {
90 $queue = $this->get_queue();
91 if ( empty( $queue ) ) {
92 return;
93 }
94
95 // Clear the queue before processing so parallel runs don't double-test.
96 delete_option( self::QUEUE_OPTION );
97
98 foreach ( $queue as $item ) {
99 $this->run_single_test( $item['plugin'], $item['event'] );
100 }
101 }
102
103 /**
104 * Run tests for a single plugin change.
105 *
106 * Tests performed:
107 * 1. Probe the site home URL for HTTP 5xx.
108 * 2. Probe the WP admin URL for HTTP 5xx (uses nonce-free admin-ajax ping).
109 * 3. Scan the PHP error log for fatals introduced since the change.
110 *
111 * @param string $plugin_basename Plugin being tested.
112 * @param string $event 'activated' | 'updated'.
113 */
114 public function run_single_test( string $plugin_basename, string $event ): void {
115 $since = time();
116
117 /**
118 * Fires before a background test run starts.
119 *
120 * @param string $plugin_basename
121 * @param string $event
122 */
123 do_action( 'ipcd_before_test', $plugin_basename, $event );
124
125 $conflict_ids = array();
126
127 // 1. Probe home URL.
128 $home_conflict = $this->detector->probe_url( home_url( '/' ), $plugin_basename );
129 if ( $home_conflict ) {
130 $conflict_ids[] = $home_conflict;
131 }
132
133 // 2. Probe admin-ajax health check (lightweight, no auth needed).
134 $ajax_conflict = $this->detector->probe_url(
135 admin_url( 'admin-ajax.php?action=ipcd_health_ping' ),
136 $plugin_basename
137 );
138 if ( $ajax_conflict ) {
139 $conflict_ids[] = $ajax_conflict;
140 }
141
142 // 3. Scan PHP error log.
143 $log_conflicts = $this->detector->scan_error_log( $plugin_basename, $since );
144 $conflict_ids = array_merge( $conflict_ids, $log_conflicts );
145
146 /**
147 * Fires after a background test run completes.
148 *
149 * @param string $plugin_basename
150 * @param string $event
151 * @param array $conflict_ids IDs of any newly recorded conflicts.
152 */
153 do_action( 'ipcd_after_test', $plugin_basename, $event, $conflict_ids );
154
155 // If conflicts were found, notify the admin.
156 if ( ! empty( $conflict_ids ) ) {
157 do_action( 'ipcd_conflicts_detected', $plugin_basename, $event, $conflict_ids );
158 }
159 }
160
161 /**
162 * Return the current test queue.
163 *
164 * @return array
165 */
166 public function get_queue(): array {
167 $queue = get_option( self::QUEUE_OPTION, array() );
168 return is_array( $queue ) ? $queue : array();
169 }
170
171 /**
172 * Clear the test queue.
173 */
174 public function clear_queue(): void {
175 delete_option( self::QUEUE_OPTION );
176 }
177
178 /**
179 * Return the timestamp of the next scheduled cron run, or null.
180 *
181 * @return int|null
182 */
183 public function get_next_run(): ?int {
184 $next = wp_next_scheduled( self::CRON_HOOK );
185 return $next ?: null;
186 }
187}
Addedintelligent-plugin-conflict-detector/includes/class-conflict-detector.php+275−0View fileUnifiedSplit
@@ -0,0 +1,275 @@
1
2/**
3 * Conflict Detector
4 *
5 * Records, retrieves and clears plugin conflicts. A "conflict" is anything
6 * that causes an observable failure after a plugin change: a PHP fatal, a
7 * JavaScript console error on admin pages, or an HTTP 500 response from the
8 * site front-end.
9 *
10 * @package IPCD
11 */
12
13if ( ! defined( 'ABSPATH' ) ) {
14 exit;
15}
16
17/**
18 * Class IPCD_Conflict_Detector
19 */
20class IPCD_Conflict_Detector {
21
22 /** Option key used to store the active conflict log. */
23 const OPTION_KEY = 'ipcd_conflicts';
24
25 /** Maximum number of conflict records to keep. */
26 const MAX_RECORDS = 200;
27
28 /**
29 * Severity levels.
30 */
31 const SEVERITY_CRITICAL = 'critical';
32 const SEVERITY_WARNING = 'warning';
33 const SEVERITY_INFO = 'info';
34
35 /**
36 * Return all stored conflicts, newest first.
37 *
38 * @return array
39 */
40 public function get_conflicts(): array {
41 $conflicts = get_option( self::OPTION_KEY, array() );
42 return is_array( $conflicts ) ? $conflicts : array();
43 }
44
45 /**
46 * Return only unresolved (active) conflicts.
47 *
48 * @return array
49 */
50 public function get_active_conflicts(): array {
51 return array_filter(
52 $this->get_conflicts(),
53 static function ( $conflict ) {
54 return empty( $conflict['resolved'] );
55 }
56 );
57 }
58
59 /**
60 * Record a new conflict.
61 *
62 * @param string $plugin_basename The plugin that caused / is involved in the conflict.
63 * @param string $type Type of conflict (e.g. 'php_fatal', 'js_error', 'http_error').
64 * @param string $message Human-readable description.
65 * @param string $severity One of the SEVERITY_* constants.
66 * @param array $context Optional extra data (stack trace, URL, etc.).
67 *
68 * @return string Conflict ID.
69 */
70 public function record_conflict(
71 string $plugin_basename,
72 string $type,
73 string $message,
74 string $severity = self::SEVERITY_WARNING,
75 array $context = array()
76 ): string {
77 $conflicts = $this->get_conflicts();
78 $conflict_id = uniqid( 'ipcd_conflict_', true );
79
80 array_unshift(
81 $conflicts,
82 array(
83 'id' => $conflict_id,
84 'plugin' => sanitize_text_field( $plugin_basename ),
85 'type' => sanitize_key( $type ),
86 'message' => sanitize_text_field( $message ),
87 'severity' => $this->sanitize_severity( $severity ),
88 'context' => $context,
89 'resolved' => false,
90 'time' => time(),
91 )
92 );
93
94 // Trim to max records.
95 if ( count( $conflicts ) > self::MAX_RECORDS ) {
96 $conflicts = array_slice( $conflicts, 0, self::MAX_RECORDS );
97 }
98
99 update_option( self::OPTION_KEY, $conflicts );
100
101 /**
102 * Fired after a conflict has been recorded.
103 *
104 * @param array $conflict The full conflict record.
105 * @param string $conflict_id The generated conflict ID.
106 */
107 do_action( 'ipcd_conflict_recorded', $conflicts[0], $conflict_id );
108
109 return $conflict_id;
110 }
111
112 /**
113 * Mark a conflict as resolved.
114 *
115 * @param string $conflict_id Conflict ID to resolve.
116 *
117 * @return bool
118 */
119 public function resolve_conflict( string $conflict_id ): bool {
120 $conflicts = $this->get_conflicts();
121 $updated = false;
122
123 foreach ( $conflicts as &$conflict ) {
124 if ( isset( $conflict['id'] ) && $conflict['id'] === $conflict_id ) {
125 $conflict['resolved'] = true;
126 $conflict['resolved_at'] = time();
127 $updated = true;
128 break;
129 }
130 }
131 unset( $conflict );
132
133 if ( $updated ) {
134 update_option( self::OPTION_KEY, $conflicts );
135 }
136
137 return $updated;
138 }
139
140 /**
141 * Remove all stored conflicts for a specific plugin.
142 *
143 * @param string $plugin_basename Plugin basename.
144 */
145 public function clear_conflicts_for_plugin( string $plugin_basename ): void {
146 $conflicts = $this->get_conflicts();
147 $filtered = array_filter(
148 $conflicts,
149 static function ( $conflict ) use ( $plugin_basename ) {
150 return $conflict['plugin'] !== $plugin_basename;
151 }
152 );
153
154 update_option( self::OPTION_KEY, array_values( $filtered ) );
155 }
156
157 /**
158 * Delete all stored conflicts.
159 */
160 public function clear_all_conflicts(): void {
161 delete_option( self::OPTION_KEY );
162 }
163
164 /**
165 * Analyse the PHP error log for fatal errors introduced by a plugin change.
166 *
167 * @param string $plugin_basename Plugin to attribute errors to.
168 * @param int $since_timestamp Only look at log entries after this timestamp.
169 *
170 * @return array Array of new conflict IDs.
171 */
172 public function scan_error_log( string $plugin_basename, int $since_timestamp = 0 ): array {
173 $conflict_ids = array();
174
175 $log_file = ini_get( 'error_log' );
176 if ( ! $log_file || ! file_exists( $log_file ) || ! is_readable( $log_file ) ) {
177 return $conflict_ids;
178 }
179
180 $lines = file( $log_file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES );
181 if ( false === $lines ) {
182 return $conflict_ids;
183 }
184
185 // Only consider lines after the given timestamp.
186 foreach ( array_reverse( $lines ) as $line ) {
187 // Log lines typically start with "[13-Apr-2026 12:00:00 UTC]"
188 if ( preg_match( '/^\[(\d{2}-\w{3}-\d{4} \d{2}:\d{2}:\d{2} [A-Z]+)\]/', $line, $m ) ) {
189 $line_time = strtotime( $m[1] );
190 if ( $line_time && $line_time < $since_timestamp ) {
191 break;
192 }
193 }
194
195 // Detect PHP fatal / parse errors.
196 if ( preg_match( '/PHP (Fatal error|Parse error|Error):(.*)/i', $line, $matches ) ) {
197 $conflict_ids[] = $this->record_conflict(
198 $plugin_basename,
199 'php_fatal',
200 trim( $matches[2] ),
201 self::SEVERITY_CRITICAL,
202 array( 'raw_log_line' => $line )
203 );
204 }
205 }
206
207 return $conflict_ids;
208 }
209
210 /**
211 * Probe a URL and report HTTP errors.
212 *
213 * @param string $url URL to probe.
214 * @param string $plugin_basename Plugin to attribute the error to.
215 *
216 * @return string|null Conflict ID if an error was detected, null otherwise.
217 */
218 public function probe_url( string $url, string $plugin_basename ): ?string {
219 $response = wp_remote_get(
220 $url,
221 array(
222 'timeout' => 15,
223 'user-agent' => 'IPCD-Conflict-Probe/1.0',
224 'sslverify' => apply_filters( 'https_local_ssl_verify', false ),
225 )
226 );
227
228 if ( is_wp_error( $response ) ) {
229 return $this->record_conflict(
230 $plugin_basename,
231 'http_error',
232 $response->get_error_message(),
233 self::SEVERITY_CRITICAL,
234 array( 'url' => $url )
235 );
236 }
237
238 $status_code = wp_remote_retrieve_response_code( $response );
239 if ( $status_code >= 500 ) {
240 return $this->record_conflict(
241 $plugin_basename,
242 'http_error',
243 sprintf(
244 /* translators: 1: HTTP status code, 2: URL */
245 __( 'HTTP %1$d response detected at %2$s', 'ipcd' ),
246 $status_code,
247 $url
248 ),
249 self::SEVERITY_CRITICAL,
250 array(
251 'url' => $url,
252 'status_code' => $status_code,
253 )
254 );
255 }
256
257 return null;
258 }
259
260 // -------------------------------------------------------------------------
261 // Private helpers
262 // -------------------------------------------------------------------------
263
264 /**
265 * Ensure severity is one of the defined constants.
266 *
267 * @param string $severity Raw severity string.
268 *
269 * @return string
270 */
271 private function sanitize_severity( string $severity ): string {
272 $allowed = array( self::SEVERITY_CRITICAL, self::SEVERITY_WARNING, self::SEVERITY_INFO );
273 return in_array( $severity, $allowed, true ) ? $severity : self::SEVERITY_WARNING;
274 }
275}
Addedintelligent-plugin-conflict-detector/includes/class-notification-manager.php+221−0View fileUnifiedSplit
@@ -0,0 +1,221 @@
1
2/**
3 * Notification Manager
4 *
5 * Handles alerting the site admin when conflicts are detected. Supports
6 * both WordPress admin-notice banners and optional email alerts.
7 *
8 * @package IPCD
9 */
10
11if ( ! defined( 'ABSPATH' ) ) {
12 exit;
13}
14
15/**
16 * Class IPCD_Notification_Manager
17 */
18class IPCD_Notification_Manager {
19
20 /** Option key for pending admin notices. */
21 const NOTICES_OPTION = 'ipcd_pending_notices';
22
23 /** Option key for plugin settings. */
24 const SETTINGS_OPTION = 'ipcd_settings';
25
26 /**
27 * Constructor – wire hooks.
28 */
29 public function __construct() {
30 add_action( 'admin_notices', array( $this, 'display_admin_notices' ) );
31 add_action( 'ipcd_conflicts_detected', array( $this, 'on_conflicts_detected' ), 10, 3 );
32 }
33
34 /**
35 * Called when the background tester finds new conflicts.
36 *
37 * @param string $plugin_basename Plugin that caused the conflict.
38 * @param string $event 'activated' | 'updated'.
39 * @param array $conflict_ids IDs of new conflicts.
40 */
41 public function on_conflicts_detected(
42 string $plugin_basename,
43 string $event,
44 array $conflict_ids
45 ): void {
46 $plugin_name = $this->get_plugin_name( $plugin_basename );
47 $count = count( $conflict_ids );
48
49 $message = sprintf(
50 /* translators: 1: plugin name, 2: event type, 3: number of conflicts */
51 _n(
52 '<strong>IPCD:</strong> %1$d conflict detected after %3$s <em>%2$s</em>. <a href="%4$s">View details</a>.',
53 '<strong>IPCD:</strong> %1$d conflicts detected after %3$s <em>%2$s</em>. <a href="%4$s">View details</a>.',
54 $count,
55 'ipcd'
56 ),
57 $count,
58 esc_html( $plugin_name ),
59 esc_html( $event ),
60 esc_url( admin_url( 'tools.php?page=ipcd-dashboard' ) )
61 );
62
63 $this->queue_notice( $message, 'error' );
64
65 // Send email notification if enabled.
66 $settings = $this->get_settings();
67 if ( ! empty( $settings['email_notifications'] ) ) {
68 $this->send_email_alert( $plugin_name, $event, $count );
69 }
70 }
71
72 /**
73 * Display queued admin notices.
74 */
75 public function display_admin_notices(): void {
76 $notices = $this->get_queued_notices();
77 if ( empty( $notices ) ) {
78 return;
79 }
80
81 foreach ( $notices as $notice ) {
82 $type = in_array( $notice['type'], array( 'error', 'warning', 'success', 'info' ), true )
83 ? $notice['type'] : 'info';
84 $message = wp_kses(
85 $notice['message'],
86 array(
87 'strong' => array(),
88 'em' => array(),
89 'a' => array( 'href' => array() ),
90 )
91 );
92 printf(
93 '<div class="notice notice-%s is-dismissible ipcd-notice"><p>%s</p></div>',
94 esc_attr( $type ),
95 $message // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- already kses'd above
96 );
97 }
98
99 // Clear notices after display.
100 delete_option( self::NOTICES_OPTION );
101 }
102
103 /**
104 * Queue a notice to be displayed on the next admin page load.
105 *
106 * @param string $message Notice message (may contain allowed HTML).
107 * @param string $type 'error' | 'warning' | 'success' | 'info'.
108 */
109 public function queue_notice( string $message, string $type = 'info' ): void {
110 $notices = $this->get_queued_notices();
111 $notices[] = array(
112 'message' => $message,
113 'type' => $type,
114 );
115 update_option( self::NOTICES_OPTION, $notices );
116 }
117
118 /**
119 * Return queued notices.
120 *
121 * @return array
122 */
123 public function get_queued_notices(): array {
124 $notices = get_option( self::NOTICES_OPTION, array() );
125 return is_array( $notices ) ? $notices : array();
126 }
127
128 /**
129 * Return plugin settings.
130 *
131 * @return array
132 */
133 public function get_settings(): array {
134 $defaults = array(
135 'email_notifications' => false,
136 'email_address' => get_option( 'admin_email' ),
137 'auto_rollback' => false,
138 'scan_interval_hours' => 6,
139 );
140
141 $saved = get_option( self::SETTINGS_OPTION, array() );
142 return wp_parse_args( is_array( $saved ) ? $saved : array(), $defaults );
143 }
144
145 /**
146 * Save plugin settings.
147 *
148 * @param array $settings New settings values.
149 */
150 public function save_settings( array $settings ): void {
151 $sanitized = array(
152 'email_notifications' => ! empty( $settings['email_notifications'] ),
153 'email_address' => sanitize_email( $settings['email_address'] ?? '' ),
154 'auto_rollback' => ! empty( $settings['auto_rollback'] ),
155 'scan_interval_hours' => absint( $settings['scan_interval_hours'] ?? 6 ),
156 );
157 update_option( self::SETTINGS_OPTION, $sanitized );
158 }
159
160 // -------------------------------------------------------------------------
161 // Private helpers
162 // -------------------------------------------------------------------------
163
164 /**
165 * Send an email alert about detected conflicts.
166 *
167 * @param string $plugin_name Human-readable plugin name.
168 * @param string $event 'activated' | 'updated'.
169 * @param int $count Number of conflicts.
170 */
171 private function send_email_alert( string $plugin_name, string $event, int $count ): void {
172 $settings = $this->get_settings();
173 $recipient = $settings['email_address'] ?: get_option( 'admin_email' );
174 $site_name = get_bloginfo( 'name' );
175
176 $subject = sprintf(
177 /* translators: 1: site name, 2: plugin name */
178 __( '[%1$s] Plugin conflict detected — %2$s', 'ipcd' ),
179 $site_name,
180 $plugin_name
181 );
182
183 $body = sprintf(
184 /* translators: 1: number of conflicts, 2: plugin name, 3: event, 4: dashboard URL */
185 __(
186 "Hello,\n\n%1\$d conflict(s) were detected on %5\$s after %2\$s was %3\$s.\n\nPlease visit your IPCD dashboard to review the conflicts and roll back if necessary:\n%4\$s\n\nThis message was sent automatically by the Intelligent Plugin Conflict Detector.",
187 'ipcd'
188 ),
189 $count,
190 $plugin_name,
191 $event,
192 admin_url( 'tools.php?page=ipcd-dashboard' ),
193 $site_name
194 );
195
196 wp_mail( $recipient, $subject, $body );
197 }
198
199 /**
200 * Get a human-readable plugin name from its basename.
201 *
202 * @param string $plugin_basename Plugin basename (e.g. woocommerce/woocommerce.php).
203 *
204 * @return string
205 */
206 private function get_plugin_name( string $plugin_basename ): string {
207 if ( ! function_exists( 'get_plugin_data' ) ) {
208 require_once ABSPATH . 'wp-admin/includes/plugin.php';
209 }
210
211 $plugin_file = WP_PLUGIN_DIR . '/' . $plugin_basename;
212 if ( file_exists( $plugin_file ) ) {
213 $data = get_plugin_data( $plugin_file, false, false );
214 if ( ! empty( $data['Name'] ) ) {
215 return $data['Name'];
216 }
217 }
218
219 return $plugin_basename;
220 }
221}
Addedintelligent-plugin-conflict-detector/includes/class-plugin-state-manager.php+191−0View fileUnifiedSplit
@@ -0,0 +1,191 @@
1
2/**
3 * Plugin State Manager
4 *
5 * Captures and restores snapshots of the active-plugin list and relevant
6 * WordPress/WooCommerce option values so the Rollback Manager can restore
7 * any saved state.
8 *
9 * @package IPCD
10 */
11
12if ( ! defined( 'ABSPATH' ) ) {
13 exit;
14}
15
16/**
17 * Class IPCD_Plugin_State_Manager
18 */
19class IPCD_Plugin_State_Manager {
20
21 /** Database table name (without prefix). */
22 const TABLE_NAME = 'ipcd_snapshots';
23
24 /**
25 * Create the custom DB table on activation.
26 */
27 public static function create_tables(): void {
28 global $wpdb;
29
30 $table_name = $wpdb->prefix . self::TABLE_NAME;
31 $charset_collate = $wpdb->get_charset_collate();
32
33 $sql = "CREATE TABLE IF NOT EXISTS {$table_name} (
34 id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
35 snapshot_id VARCHAR(191) NOT NULL,
36 label VARCHAR(255) NOT NULL DEFAULT '',
37 active_plugins LONGTEXT NOT NULL,
38 options_data LONGTEXT NOT NULL,
39 created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
40 PRIMARY KEY (id),
41 UNIQUE KEY snapshot_id (snapshot_id)
42 ) {$charset_collate};";
43
44 require_once ABSPATH . 'wp-admin/includes/upgrade.php';
45 dbDelta( $sql );
46 }
47
48 /**
49 * Capture a snapshot of the current plugin/option state.
50 *
51 * @param string $label Human-readable label (e.g. "before_activate_woocommerce").
52 * @param array $extra_option_keys Additional option keys to snapshot beyond the defaults.
53 *
54 * @return string The unique snapshot ID.
55 */
56 public function capture_snapshot( string $label = '', array $extra_option_keys = array() ): string {
57 global $wpdb;
58
59 $snapshot_id = uniqid( 'ipcd_', true );
60 $active_plugins = get_option( 'active_plugins', array() );
61
62 $default_option_keys = array(
63 'siteurl',
64 'blogname',
65 'active_plugins',
66 'template',
67 'stylesheet',
68 'woocommerce_version',
69 'db_version',
70 );
71
72 $options_data = array();
73 foreach ( array_unique( array_merge( $default_option_keys, $extra_option_keys ) ) as $key ) {
74 $options_data[ $key ] = get_option( $key );
75 }
76
77 $table = $wpdb->prefix . self::TABLE_NAME;
78
79 $wpdb->insert(
80 $table,
81 array(
82 'snapshot_id' => $snapshot_id,
83 'label' => sanitize_text_field( $label ),
84 'active_plugins' => wp_json_encode( $active_plugins ),
85 'options_data' => wp_json_encode( $options_data ),
86 'created_at' => current_time( 'mysql' ),
87 ),
88 array( '%s', '%s', '%s', '%s', '%s' )
89 );
90
91 return $snapshot_id;
92 }
93
94 /**
95 * Return all stored snapshots, newest first.
96 *
97 * @return array
98 */
99 public function get_snapshots(): array {
100 global $wpdb;
101
102 $table = $wpdb->prefix . self::TABLE_NAME;
103 $rows = $wpdb->get_results( "SELECT * FROM {$table} ORDER BY created_at DESC", ARRAY_A );
104
105 return $rows ?: array();
106 }
107
108 /**
109 * Return a single snapshot by its ID.
110 *
111 * @param string $snapshot_id Snapshot ID.
112 *
113 * @return array|null
114 */
115 public function get_snapshot( string $snapshot_id ): ?array {
116 global $wpdb;
117
118 $table = $wpdb->prefix . self::TABLE_NAME;
119 $row = $wpdb->get_row(
120 $wpdb->prepare( "SELECT * FROM {$table} WHERE snapshot_id = %s", $snapshot_id ),
121 ARRAY_A
122 );
123
124 return $row ?: null;
125 }
126
127 /**
128 * Restore the active_plugins list from a snapshot.
129 * Does NOT update other options – only the plugin list.
130 *
131 * @param string $snapshot_id Snapshot ID to restore.
132 *
133 * @return bool True on success, false if snapshot not found.
134 */
135 public function restore_snapshot( string $snapshot_id ): bool {
136 $snapshot = $this->get_snapshot( $snapshot_id );
137 if ( ! $snapshot ) {
138 return false;
139 }
140
141 $active_plugins = json_decode( $snapshot['active_plugins'], true );
142 if ( ! is_array( $active_plugins ) ) {
143 return false;
144 }
145
146 // Only keep plugins that still exist on disk.
147 $valid_plugins = array_filter( $active_plugins, static function ( $plugin ) {
148 return file_exists( WP_PLUGIN_DIR . '/' . $plugin );
149 } );
150
151 update_option( 'active_plugins', array_values( $valid_plugins ) );
152
153 return true;
154 }
155
156 /**
157 * Delete a snapshot by ID.
158 *
159 * @param string $snapshot_id Snapshot ID to delete.
160 *
161 * @return bool
162 */
163 public function delete_snapshot( string $snapshot_id ): bool {
164 global $wpdb;
165
166 $table = $wpdb->prefix . self::TABLE_NAME;
167 $result = $wpdb->delete( $table, array( 'snapshot_id' => $snapshot_id ), array( '%s' ) );
168
169 return (bool) $result;
170 }
171
172 /**
173 * Prune snapshots older than a given number of days.
174 *
175 * @param int $days Number of days to keep.
176 *
177 * @return int Number of rows deleted.
178 */
179 public function prune_old_snapshots( int $days = 30 ): int {
180 global $wpdb;
181
182 $table = $wpdb->prefix . self::TABLE_NAME;
183
184 return (int) $wpdb->query(
185 $wpdb->prepare(
186 "DELETE FROM {$table} WHERE created_at < DATE_SUB(NOW(), INTERVAL %d DAY)",
187 $days
188 )
189 );
190 }
191}
Addedintelligent-plugin-conflict-detector/includes/class-rollback-manager.php+133−0View fileUnifiedSplit
@@ -0,0 +1,133 @@
1
2/**
3 * Rollback Manager
4 *
5 * Provides one-click rollback to any previously captured plugin state snapshot.
6 *
7 * @package IPCD
8 */
9
10if ( ! defined( 'ABSPATH' ) ) {
11 exit;
12}
13
14/**
15 * Class IPCD_Rollback_Manager
16 */
17class IPCD_Rollback_Manager {
18
19 /** Option key that stores rollback history. */
20 const HISTORY_OPTION = 'ipcd_rollback_history';
21
22 /** @var IPCD_Plugin_State_Manager */
23 private $state_manager;
24
25 /**
26 * Constructor.
27 *
28 * @param IPCD_Plugin_State_Manager $state_manager State manager instance.
29 */
30 public function __construct( IPCD_Plugin_State_Manager $state_manager ) {
31 $this->state_manager = $state_manager;
32 }
33
34 /**
35 * Perform a rollback to the given snapshot.
36 *
37 * The current state is automatically snapshotted first so the rollback
38 * itself can be undone if needed.
39 *
40 * @param string $snapshot_id ID of the snapshot to restore.
41 *
42 * @return array Result array with 'success' (bool) and 'message' (string).
43 */
44 public function rollback( string $snapshot_id ): array {
45 // Safety snapshot of current state before rolling back.
46 $pre_rollback_id = $this->state_manager->capture_snapshot(
47 'pre_rollback_' . $snapshot_id
48 );
49
50 $success = $this->state_manager->restore_snapshot( $snapshot_id );
51
52 if ( $success ) {
53 $this->add_history_entry(
54 $snapshot_id,
55 $pre_rollback_id,
56 'success'
57 );
58
59 /**
60 * Fires after a successful rollback.
61 *
62 * @param string $snapshot_id The restored snapshot.
63 * @param string $pre_rollback_id The safety snapshot taken before restoring.
64 */
65 do_action( 'ipcd_rollback_complete', $snapshot_id, $pre_rollback_id );
66
67 return array(
68 'success' => true,
69 'message' => __( 'Rollback completed successfully. Active plugins have been restored.', 'ipcd' ),
70 'pre_rollback_snapshot' => $pre_rollback_id,
71 );
72 }
73
74 $this->add_history_entry( $snapshot_id, $pre_rollback_id, 'failed' );
75
76 return array(
77 'success' => false,
78 'message' => __( 'Rollback failed: snapshot not found or could not be restored.', 'ipcd' ),
79 );
80 }
81
82 /**
83 * Return rollback history, newest first.
84 *
85 * @return array
86 */
87 public function get_history(): array {
88 $history = get_option( self::HISTORY_OPTION, array() );
89 return is_array( $history ) ? $history : array();
90 }
91
92 /**
93 * Clear all rollback history entries.
94 */
95 public function clear_history(): void {
96 delete_option( self::HISTORY_OPTION );
97 }
98
99 // -------------------------------------------------------------------------
100 // Private helpers
101 // -------------------------------------------------------------------------
102
103 /**
104 * Append an entry to the rollback history log.
105 *
106 * @param string $snapshot_id The snapshot that was restored.
107 * @param string $pre_rollback_id The safety snapshot taken before rollback.
108 * @param string $status 'success' | 'failed'.
109 */
110 private function add_history_entry(
111 string $snapshot_id,
112 string $pre_rollback_id,
113 string $status
114 ): void {
115 $history = $this->get_history();
116 array_unshift(
117 $history,
118 array(
119 'snapshot_id' => $snapshot_id,
120 'pre_rollback_id' => $pre_rollback_id,
121 'status' => $status,
122 'time' => time(),
123 )
124 );
125
126 // Keep at most 50 history entries.
127 if ( count( $history ) > 50 ) {
128 $history = array_slice( $history, 0, 50 );
129 }
130
131 update_option( self::HISTORY_OPTION, $history );
132 }
133}
Addedintelligent-plugin-conflict-detector/intelligent-plugin-conflict-detector.php+184−0View fileUnifiedSplit
@@ -0,0 +1,184 @@
1
2/**
3 * Plugin Name: Intelligent Plugin Conflict Detector
4 * Plugin URI: https://github.com/ccantynz-alt/Intelligent-Plugin-Conflict-Detector
5 * Description: Automatically detects plugin conflicts in a safe background environment, alerts store owners before problems occur, and provides one-click rollback.
6 * Version: 1.0.0
7 * Requires at least: 5.8
8 * Requires PHP: 7.4
9 * Author: Intelligent Plugin Conflict Detector Contributors
10 * License: GPL-2.0-or-later
11 * License URI: https://www.gnu.org/licenses/gpl-2.0.html
12 * Text Domain: ipcd
13 * Domain Path: /languages
14 *
15 * @package IPCD
16 */
17
18if ( ! defined( 'ABSPATH' ) ) {
19 exit;
20}
21
22// Plugin constants.
23define( 'IPCD_VERSION', '1.0.0' );
24define( 'IPCD_PLUGIN_FILE', __FILE__ );
25define( 'IPCD_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
26define( 'IPCD_PLUGIN_URL', plugin_dir_url( __FILE__ ) );
27define( 'IPCD_PLUGIN_BASENAME', plugin_basename( __FILE__ ) );
28
29// Required includes.
30require_once IPCD_PLUGIN_DIR . 'includes/class-plugin-state-manager.php';
31require_once IPCD_PLUGIN_DIR . 'includes/class-conflict-detector.php';
32require_once IPCD_PLUGIN_DIR . 'includes/class-background-tester.php';
33require_once IPCD_PLUGIN_DIR . 'includes/class-rollback-manager.php';
34require_once IPCD_PLUGIN_DIR . 'includes/class-notification-manager.php';
35
36if ( is_admin() ) {
37 require_once IPCD_PLUGIN_DIR . 'admin/class-admin.php';
38}
39
40/**
41 * Returns the main plugin instance (singleton).
42 *
43 * @return IPCD_Plugin
44 */
45function ipcd() {
46 return IPCD_Plugin::instance();
47}
48
49/**
50 * Main plugin class.
51 */
52final class IPCD_Plugin {
53
54 /** @var IPCD_Plugin|null */
55 private static $instance = null;
56
57 /** @var IPCD_Plugin_State_Manager */
58 public $state_manager;
59
60 /** @var IPCD_Conflict_Detector */
61 public $conflict_detector;
62
63 /** @var IPCD_Background_Tester */
64 public $background_tester;
65
66 /** @var IPCD_Rollback_Manager */
67 public $rollback_manager;
68
69 /** @var IPCD_Notification_Manager */
70 public $notification_manager;
71
72 /** @var IPCD_Admin|null */
73 public $admin;
74
75 /**
76 * Returns/creates singleton.
77 *
78 * @return IPCD_Plugin
79 */
80 public static function instance(): IPCD_Plugin {
81 if ( null === self::$instance ) {
82 self::$instance = new self();
83 }
84 return self::$instance;
85 }
86
87 /**
88 * Constructor – wire up hooks.
89 */
90 private function __construct() {
91 $this->state_manager = new IPCD_Plugin_State_Manager();
92 $this->conflict_detector = new IPCD_Conflict_Detector();
93 $this->background_tester = new IPCD_Background_Tester( $this->conflict_detector, $this->state_manager );
94 $this->rollback_manager = new IPCD_Rollback_Manager( $this->state_manager );
95 $this->notification_manager = new IPCD_Notification_Manager();
96
97 if ( is_admin() ) {
98 $this->admin = new IPCD_Admin(
99 $this->conflict_detector,
100 $this->rollback_manager,
101 $this->notification_manager,
102 $this->background_tester,
103 $this->state_manager
104 );
105 }
106
107 add_action( 'init', array( $this, 'load_textdomain' ) );
108 add_action( 'activated_plugin', array( $this, 'on_plugin_activated' ), 10, 2 );
109 add_action( 'deactivated_plugin', array( $this, 'on_plugin_deactivated' ), 10, 2 );
110 add_action( 'upgrader_process_complete', array( $this, 'on_upgrade_complete' ), 10, 2 );
111 }
112
113 /**
114 * Load plugin text domain.
115 */
116 public function load_textdomain(): void {
117 load_plugin_textdomain( 'ipcd', false, dirname( IPCD_PLUGIN_BASENAME ) . '/languages' );
118 }
119
120 /**
121 * Triggered when any plugin is activated – snapshot state and schedule a test run.
122 *
123 * @param string $plugin Plugin basename.
124 * @param bool $network_wide Whether activated network-wide.
125 */
126 public function on_plugin_activated( string $plugin, bool $network_wide ): void {
127 if ( $plugin === IPCD_PLUGIN_BASENAME ) {
128 return;
129 }
130 $this->state_manager->capture_snapshot( 'before_activate_' . sanitize_key( $plugin ) );
131 $this->background_tester->schedule_test( $plugin, 'activated' );
132 }
133
134 /**
135 * Triggered when any plugin is deactivated – clear stored conflict data for it.
136 *
137 * @param string $plugin Plugin basename.
138 * @param bool $network_wide Whether deactivated network-wide.
139 */
140 public function on_plugin_deactivated( string $plugin, bool $network_wide ): void {
141 $this->conflict_detector->clear_conflicts_for_plugin( $plugin );
142 }
143
144 /**
145 * Triggered after a plugin or WooCommerce update – capture snapshot and schedule test.
146 *
147 * @param \WP_Upgrader $upgrader Upgrader instance.
148 * @param array $options Upgrade options.
149 */
150 public function on_upgrade_complete( $upgrader, array $options ): void {
151 if ( isset( $options['type'] ) && 'plugin' === $options['type'] ) {
152 $plugins = $options['plugins'] ?? array();
153 foreach ( $plugins as $plugin ) {
154 $this->state_manager->capture_snapshot( 'before_update_' . sanitize_key( $plugin ) );
155 $this->background_tester->schedule_test( $plugin, 'updated' );
156 }
157 }
158 }
159}
160
161// Activation / deactivation / uninstall hooks.
162register_activation_hook( __FILE__, 'ipcd_activate' );
163register_deactivation_hook( __FILE__, 'ipcd_deactivate' );
164
165/**
166 * Plugin activation: create DB tables and schedule cron.
167 */
168function ipcd_activate(): void {
169 IPCD_Plugin_State_Manager::create_tables();
170 IPCD_Background_Tester::register_cron_schedule();
171 if ( ! wp_next_scheduled( 'ipcd_background_test' ) ) {
172 wp_schedule_event( time(), 'ipcd_every_6_hours', 'ipcd_background_test' );
173 }
174}
175
176/**
177 * Plugin deactivation: clear cron events.
178 */
179function ipcd_deactivate(): void {
180 wp_clear_scheduled_hook( 'ipcd_background_test' );
181}
182
183// Boot the plugin.
184ipcd();
Addedintelligent-plugin-conflict-detector/phpunit.xml+21−0View fileUnifiedSplit
@@ -0,0 +1,21 @@
1
2<phpunit
3 bootstrap="tests/bootstrap.php"
4 colors="true"
5 testdox="true"
6 stopOnFailure="false"
7 xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
8 xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/9.6/phpunit.xsd"
9>
10 <testsuites>
11 <testsuite name="IPCD Unit Tests">
12 <directory>tests/Unit</directory>
13 </testsuite>
14 </testsuites>
15 <coverage>
16 <include>
17 <directory suffix=".php">includes</directory>
18 <directory suffix=".php">admin</directory>
19 </include>
20 </coverage>
21</phpunit>
Addedintelligent-plugin-conflict-detector/tests/Unit/BackgroundTesterTest.php+126−0View fileUnifiedSplit
@@ -0,0 +1,126 @@
1
2/**
3 * Unit tests for IPCD_Background_Tester.
4 *
5 * @package IPCD\Tests\Unit
6 */
7
8namespace IPCD\Tests\Unit;
9
10use Brain\Monkey;
11use Brain\Monkey\Functions;
12use IPCD_Background_Tester;
13use IPCD_Conflict_Detector;
14use IPCD_Plugin_State_Manager;
15use Mockery;
16use PHPUnit\Framework\TestCase;
17
18/**
19 * Class BackgroundTesterTest
20 */
21class BackgroundTesterTest extends TestCase {
22
23 protected function setUp(): void {
24 parent::setUp();
25 Monkey\setUp();
26
27 Functions\when( 'get_option' )->justReturn( array() );
28 Functions\when( 'update_option' )->justReturn( true );
29 Functions\when( 'delete_option' )->justReturn( true );
30 Functions\when( 'add_action' )->justReturn( null );
31 Functions\when( 'add_filter' )->justReturn( null );
32 Functions\when( 'do_action' )->justReturn( null );
33 Functions\when( 'wp_next_scheduled' )->justReturn( false );
34 Functions\when( 'wp_schedule_single_event' )->justReturn( true );
35 Functions\when( '__' )->returnArg( 1 );
36 }
37
38 protected function tearDown(): void {
39 Mockery::close();
40 Monkey\tearDown();
41 parent::tearDown();
42 }
43
44 // -------------------------------------------------------------------------
45 // schedule_test
46 // -------------------------------------------------------------------------
47
48 public function test_schedule_test_adds_to_queue(): void {
49 $detector_mock = Mockery::mock( IPCD_Conflict_Detector::class );
50 $state_manager_mock = Mockery::mock( IPCD_Plugin_State_Manager::class );
51
52 $saved_queue = null;
53 Functions\when( 'update_option' )
54 ->alias( static function ( $key, $value ) use ( &$saved_queue ) {
55 if ( IPCD_Background_Tester::QUEUE_OPTION === $key ) {
56 $saved_queue = $value;
57 }
58 return true;
59 } );
60
61 $tester = new IPCD_Background_Tester( $detector_mock, $state_manager_mock );
62 $tester->schedule_test( 'test-plugin/test-plugin.php', 'activated' );
63
64 $this->assertIsArray( $saved_queue );
65 $this->assertCount( 1, $saved_queue );
66 $this->assertSame( 'test-plugin/test-plugin.php', $saved_queue[0]['plugin'] );
67 $this->assertSame( 'activated', $saved_queue[0]['event'] );
68 }
69
70 // -------------------------------------------------------------------------
71 // run_queued_tests – empty queue
72 // -------------------------------------------------------------------------
73
74 public function test_run_queued_tests_does_nothing_when_queue_is_empty(): void {
75 $detector_mock = Mockery::mock( IPCD_Conflict_Detector::class );
76 $state_manager_mock = Mockery::mock( IPCD_Plugin_State_Manager::class );
77
78 $tester = new IPCD_Background_Tester( $detector_mock, $state_manager_mock );
79
80 // Should not throw – and no AJAX calls / conflict detections happen.
81 $tester->run_queued_tests();
82 $this->assertTrue( true );
83 }
84
85 // -------------------------------------------------------------------------
86 // get_queue
87 // -------------------------------------------------------------------------
88
89 public function test_get_queue_returns_array(): void {
90 $detector_mock = Mockery::mock( IPCD_Conflict_Detector::class );
91 $state_manager_mock = Mockery::mock( IPCD_Plugin_State_Manager::class );
92
93 $tester = new IPCD_Background_Tester( $detector_mock, $state_manager_mock );
94 $queue = $tester->get_queue();
95
96 $this->assertIsArray( $queue );
97 }
98
99 // -------------------------------------------------------------------------
100 // get_next_run
101 // -------------------------------------------------------------------------
102
103 public function test_get_next_run_returns_null_when_not_scheduled(): void {
104 // wp_next_scheduled already stubbed to return false in setUp.
105 $detector_mock = Mockery::mock( IPCD_Conflict_Detector::class );
106 $state_manager_mock = Mockery::mock( IPCD_Plugin_State_Manager::class );
107
108 $tester = new IPCD_Background_Tester( $detector_mock, $state_manager_mock );
109 $next = $tester->get_next_run();
110
111 $this->assertNull( $next );
112 }
113
114 public function test_get_next_run_returns_timestamp_when_scheduled(): void {
115 // Override the setUp stub for this test.
116 Functions\when( 'wp_next_scheduled' )->justReturn( 1_700_000_000 );
117
118 $detector_mock = Mockery::mock( IPCD_Conflict_Detector::class );
119 $state_manager_mock = Mockery::mock( IPCD_Plugin_State_Manager::class );
120
121 $tester = new IPCD_Background_Tester( $detector_mock, $state_manager_mock );
122 $next = $tester->get_next_run();
123
124 $this->assertSame( 1_700_000_000, $next );
125 }
126}
Addedintelligent-plugin-conflict-detector/tests/Unit/ConflictDetectorTest.php+188−0View fileUnifiedSplit
@@ -0,0 +1,188 @@
1
2/**
3 * Unit tests for IPCD_Conflict_Detector.
4 *
5 * @package IPCD\Tests\Unit
6 */
7
8namespace IPCD\Tests\Unit;
9
10use Brain\Monkey;
11use Brain\Monkey\Functions;
12use IPCD_Conflict_Detector;
13use PHPUnit\Framework\TestCase;
14
15/**
16 * Class ConflictDetectorTest
17 */
18class ConflictDetectorTest extends TestCase {
19
20 protected function setUp(): void {
21 parent::setUp();
22 Monkey\setUp();
23 }
24
25 protected function tearDown(): void {
26 Monkey\tearDown();
27 parent::tearDown();
28 }
29
30 /**
31 * Stub the WordPress functions that every test needs.
32 * Called at the start of each test so we can also add test-specific stubs
33 * before or after without conflicts.
34 */
35 private function stub_wp_functions(): void {
36 Functions\when( 'get_option' )->justReturn( array() );
37 Functions\when( 'update_option' )->justReturn( true );
38 Functions\when( 'delete_option' )->justReturn( true );
39 Functions\when( 'do_action' )->justReturn( null );
40 Functions\when( '__' )->returnArg( 1 );
41 Functions\when( 'sanitize_text_field' )->returnArg( 1 );
42 Functions\when( 'sanitize_key' )->returnArg( 1 );
43 }
44
45 // -------------------------------------------------------------------------
46 // record_conflict
47 // -------------------------------------------------------------------------
48
49 public function test_record_conflict_returns_string_id(): void {
50 $this->stub_wp_functions();
51
52 $detector = new IPCD_Conflict_Detector();
53 $id = $detector->record_conflict(
54 'some-plugin/plugin.php',
55 'php_fatal',
56 'Call to undefined function foo()',
57 IPCD_Conflict_Detector::SEVERITY_CRITICAL
58 );
59
60 $this->assertIsString( $id );
61 $this->assertStringStartsWith( 'ipcd_conflict_', $id );
62 }
63
64 public function test_record_conflict_falls_back_to_warning_for_invalid_severity(): void {
65 $recorded = array();
66
67 Functions\when( 'get_option' )->justReturn( array() );
68 Functions\when( 'do_action' )->justReturn( null );
69 Functions\when( '__' )->returnArg( 1 );
70 Functions\when( 'sanitize_text_field' )->returnArg( 1 );
71 Functions\when( 'sanitize_key' )->returnArg( 1 );
72 Functions\when( 'update_option' )
73 ->alias( static function ( $key, $value ) use ( &$recorded ) {
74 $recorded = $value;
75 return true;
76 } );
77
78 $detector = new IPCD_Conflict_Detector();
79 $detector->record_conflict(
80 'bad-plugin/bad.php',
81 'http_error',
82 'HTTP 500',
83 'nonsense_severity'
84 );
85
86 $this->assertNotEmpty( $recorded );
87 $this->assertSame( 'warning', $recorded[0]['severity'] );
88 }
89
90 // -------------------------------------------------------------------------
91 // get_conflicts / get_active_conflicts
92 // -------------------------------------------------------------------------
93
94 public function test_get_conflicts_returns_array_when_option_is_empty(): void {
95 Functions\when( 'get_option' )->justReturn( array() );
96
97 $detector = new IPCD_Conflict_Detector();
98 $conflicts = $detector->get_conflicts();
99 $this->assertIsArray( $conflicts );
100 $this->assertCount( 0, $conflicts );
101 }
102
103 public function test_get_active_conflicts_excludes_resolved(): void {
104 $stored = array(
105 array( 'id' => 'c1', 'plugin' => 'foo/foo.php', 'type' => 'php_fatal', 'message' => 'Error', 'severity' => 'critical', 'context' => array(), 'resolved' => false, 'time' => time() ),
106 array( 'id' => 'c2', 'plugin' => 'bar/bar.php', 'type' => 'http_error', 'message' => 'HTTP 500', 'severity' => 'critical', 'context' => array(), 'resolved' => true, 'time' => time() ),
107 );
108
109 Functions\when( 'get_option' )->justReturn( $stored );
110
111 $detector = new IPCD_Conflict_Detector();
112 $active = $detector->get_active_conflicts();
113
114 $this->assertCount( 1, $active );
115 $this->assertSame( 'c1', array_values( $active )[0]['id'] );
116 }
117
118 // -------------------------------------------------------------------------
119 // resolve_conflict
120 // -------------------------------------------------------------------------
121
122 public function test_resolve_conflict_returns_true_for_known_id(): void {
123 $stored = array(
124 array( 'id' => 'abc123', 'plugin' => 'foo/foo.php', 'type' => 'php_fatal', 'message' => 'err', 'severity' => 'critical', 'context' => array(), 'resolved' => false, 'time' => time() ),
125 );
126
127 Functions\when( 'get_option' )->justReturn( $stored );
128 Functions\when( 'update_option' )->justReturn( true );
129
130 $detector = new IPCD_Conflict_Detector();
131 $result = $detector->resolve_conflict( 'abc123' );
132
133 $this->assertTrue( $result );
134 }
135
136 public function test_resolve_conflict_returns_false_for_unknown_id(): void {
137 Functions\when( 'get_option' )->justReturn( array() );
138
139 $detector = new IPCD_Conflict_Detector();
140 $result = $detector->resolve_conflict( 'nonexistent_id' );
141
142 $this->assertFalse( $result );
143 }
144
145 // -------------------------------------------------------------------------
146 // clear_conflicts_for_plugin
147 // -------------------------------------------------------------------------
148
149 public function test_clear_conflicts_for_plugin_removes_matching_entries(): void {
150 $stored = array(
151 array( 'id' => '1', 'plugin' => 'target/target.php', 'type' => 't', 'message' => 'm', 'severity' => 'info', 'context' => array(), 'resolved' => false, 'time' => time() ),
152 array( 'id' => '2', 'plugin' => 'other/other.php', 'type' => 't', 'message' => 'm', 'severity' => 'info', 'context' => array(), 'resolved' => false, 'time' => time() ),
153 );
154
155 $saved = null;
156 Functions\when( 'get_option' )->justReturn( $stored );
157 Functions\when( 'update_option' )
158 ->alias( static function ( $key, $value ) use ( &$saved ) {
159 $saved = $value;
160 return true;
161 } );
162
163 $detector = new IPCD_Conflict_Detector();
164 $detector->clear_conflicts_for_plugin( 'target/target.php' );
165
166 $this->assertIsArray( $saved );
167 $this->assertCount( 1, $saved );
168 $this->assertSame( 'other/other.php', $saved[0]['plugin'] );
169 }
170
171 // -------------------------------------------------------------------------
172 // clear_all_conflicts
173 // -------------------------------------------------------------------------
174
175 public function test_clear_all_conflicts_clears_conflicts(): void {
176 $deleted_key = null;
177 Functions\when( 'delete_option' )
178 ->alias( static function ( $key ) use ( &$deleted_key ) {
179 $deleted_key = $key;
180 return true;
181 } );
182
183 $detector = new IPCD_Conflict_Detector();
184 $detector->clear_all_conflicts();
185
186 $this->assertSame( IPCD_Conflict_Detector::OPTION_KEY, $deleted_key );
187 }
188}
Addedintelligent-plugin-conflict-detector/tests/Unit/NotificationManagerTest.php+115−0View fileUnifiedSplit
@@ -0,0 +1,115 @@
1
2/**
3 * Unit tests for IPCD_Notification_Manager.
4 *
5 * @package IPCD\Tests\Unit
6 */
7
8namespace IPCD\Tests\Unit;
9
10use Brain\Monkey;
11use Brain\Monkey\Functions;
12use IPCD_Notification_Manager;
13use PHPUnit\Framework\TestCase;
14
15/**
16 * Class NotificationManagerTest
17 */
18class NotificationManagerTest extends TestCase {
19
20protected function setUp(): void {
21parent::setUp();
22Monkey\setUp();
23
24Functions\when( 'add_action' )->justReturn( null );
25Functions\when( '__' )->returnArg( 1 );
26Functions\when( '_n' )->returnArg( 1 );
27Functions\when( 'wp_parse_args' )->alias( static function ( $args, $defaults ) {
28return array_merge( $defaults, (array) $args );
29} );
30Functions\when( 'sanitize_email' )->returnArg( 1 );
31Functions\when( 'absint' )->alias( 'abs' );
32Functions\when( 'get_bloginfo' )->justReturn( 'Test Site' );
33}
34
35protected function tearDown(): void {
36Monkey\tearDown();
37parent::tearDown();
38}
39
40// -------------------------------------------------------------------------
41// queue_notice / get_queued_notices
42// -------------------------------------------------------------------------
43
44public function test_queue_notice_stores_notice(): void {
45$stored = null;
46Functions\when( 'get_option' )->justReturn( array() );
47Functions\when( 'update_option' )
48->alias( static function ( $key, $value ) use ( &$stored ) {
49$stored = $value;
50return true;
51} );
52
53$manager = new IPCD_Notification_Manager();
54$manager->queue_notice( 'Test message', 'error' );
55
56$this->assertIsArray( $stored );
57$this->assertCount( 1, $stored );
58$this->assertSame( 'Test message', $stored[0]['message'] );
59$this->assertSame( 'error', $stored[0]['type'] );
60}
61
62public function test_get_queued_notices_returns_array(): void {
63Functions\when( 'get_option' )->justReturn( array() );
64
65$manager = new IPCD_Notification_Manager();
66$notices = $manager->get_queued_notices();
67$this->assertIsArray( $notices );
68}
69
70// -------------------------------------------------------------------------
71// get_settings / save_settings
72// -------------------------------------------------------------------------
73
74public function test_get_settings_returns_defaults_when_no_saved_settings(): void {
75Functions\when( 'get_option' )->alias( static function ( $key, $default = false ) {
76if ( 'admin_email' === $key ) {
77return 'admin@example.com';
78}
79return array();
80} );
81
82$manager = new IPCD_Notification_Manager();
83$settings = $manager->get_settings();
84
85$this->assertArrayHasKey( 'email_notifications', $settings );
86$this->assertArrayHasKey( 'email_address', $settings );
87$this->assertArrayHasKey( 'auto_rollback', $settings );
88$this->assertArrayHasKey( 'scan_interval_hours', $settings );
89$this->assertSame( false, $settings['email_notifications'] );
90$this->assertSame( 6, $settings['scan_interval_hours'] );
91}
92
93public function test_save_settings_persists_sanitized_values(): void {
94$saved_value = null;
95Functions\when( 'update_option' )
96->alias( static function ( $key, $value ) use ( &$saved_value ) {
97$saved_value = $value;
98return true;
99} );
100
101$manager = new IPCD_Notification_Manager();
102$manager->save_settings( array(
103'email_notifications' => '1',
104'email_address' => 'owner@shop.com',
105'auto_rollback' => '0',
106'scan_interval_hours' => '12',
107) );
108
109$this->assertIsArray( $saved_value );
110$this->assertTrue( $saved_value['email_notifications'] );
111$this->assertSame( 'owner@shop.com', $saved_value['email_address'] );
112$this->assertFalse( $saved_value['auto_rollback'] );
113$this->assertSame( 12, $saved_value['scan_interval_hours'] );
114}
115}
Addedintelligent-plugin-conflict-detector/tests/Unit/RollbackManagerTest.php+124−0View fileUnifiedSplit
@@ -0,0 +1,124 @@
1
2/**
3 * Unit tests for IPCD_Rollback_Manager.
4 *
5 * @package IPCD\Tests\Unit
6 */
7
8namespace IPCD\Tests\Unit;
9
10use Brain\Monkey;
11use Brain\Monkey\Functions;
12use IPCD_Plugin_State_Manager;
13use IPCD_Rollback_Manager;
14use Mockery;
15use PHPUnit\Framework\TestCase;
16
17/**
18 * Class RollbackManagerTest
19 */
20class RollbackManagerTest extends TestCase {
21
22 protected function setUp(): void {
23 parent::setUp();
24 Monkey\setUp();
25
26 Functions\when( 'get_option' )->justReturn( array() );
27 Functions\when( 'update_option' )->justReturn( true );
28 Functions\when( 'delete_option' )->justReturn( true );
29 Functions\when( 'do_action' )->justReturn( null );
30 Functions\when( '__' )->returnArg( 1 );
31 Functions\when( 'sanitize_text_field' )->returnArg( 1 );
32 Functions\when( 'current_time' )->justReturn( '2026-01-01 00:00:00' );
33 Functions\when( 'wp_json_encode' )->alias( 'json_encode' );
34 }
35
36 protected function tearDown(): void {
37 Mockery::close();
38 Monkey\tearDown();
39 parent::tearDown();
40 }
41
42 // -------------------------------------------------------------------------
43 // rollback – success path
44 // -------------------------------------------------------------------------
45
46 public function test_rollback_returns_success_when_snapshot_restored(): void {
47 $state_mock = Mockery::mock( IPCD_Plugin_State_Manager::class );
48
49 $state_mock->shouldReceive( 'capture_snapshot' )
50 ->once()
51 ->andReturn( 'pre_snapshot_123' );
52
53 $state_mock->shouldReceive( 'restore_snapshot' )
54 ->once()
55 ->with( 'target_snapshot' )
56 ->andReturn( true );
57
58 // update_option is called to store rollback history.
59 Functions\when( 'update_option' )->justReturn( true );
60 Functions\when( 'get_option' )->justReturn( array() );
61
62 $rollback = new IPCD_Rollback_Manager( $state_mock );
63 $result = $rollback->rollback( 'target_snapshot' );
64
65 $this->assertTrue( $result['success'] );
66 $this->assertArrayHasKey( 'pre_rollback_snapshot', $result );
67 }
68
69 // -------------------------------------------------------------------------
70 // rollback – failure path
71 // -------------------------------------------------------------------------
72
73 public function test_rollback_returns_failure_when_snapshot_not_found(): void {
74 $state_mock = Mockery::mock( IPCD_Plugin_State_Manager::class );
75
76 $state_mock->shouldReceive( 'capture_snapshot' )
77 ->once()
78 ->andReturn( 'pre_snapshot_456' );
79
80 $state_mock->shouldReceive( 'restore_snapshot' )
81 ->once()
82 ->andReturn( false );
83
84 Functions\when( 'update_option' )->justReturn( true );
85 Functions\when( 'get_option' )->justReturn( array() );
86
87 $rollback = new IPCD_Rollback_Manager( $state_mock );
88 $result = $rollback->rollback( 'missing_snapshot' );
89
90 $this->assertFalse( $result['success'] );
91 }
92
93 // -------------------------------------------------------------------------
94 // get_history
95 // -------------------------------------------------------------------------
96
97 public function test_get_history_returns_array(): void {
98 $state_mock = Mockery::mock( IPCD_Plugin_State_Manager::class );
99 $rollback = new IPCD_Rollback_Manager( $state_mock );
100
101 $history = $rollback->get_history();
102 $this->assertIsArray( $history );
103 }
104
105 // -------------------------------------------------------------------------
106 // clear_history
107 // -------------------------------------------------------------------------
108
109 public function test_clear_history_deletes_option(): void {
110 $state_mock = Mockery::mock( IPCD_Plugin_State_Manager::class );
111 $deleted_key = null;
112
113 Functions\when( 'delete_option' )
114 ->alias( static function ( $key ) use ( &$deleted_key ) {
115 $deleted_key = $key;
116 return true;
117 } );
118
119 $rollback = new IPCD_Rollback_Manager( $state_mock );
120 $rollback->clear_history();
121
122 $this->assertSame( IPCD_Rollback_Manager::HISTORY_OPTION, $deleted_key );
123 }
124}
Addedintelligent-plugin-conflict-detector/tests/bootstrap.php+55−0View fileUnifiedSplit
@@ -0,0 +1,55 @@
1
2/**
3 * PHPUnit bootstrap file for IPCD tests.
4 *
5 * Uses Brain\Monkey to mock WordPress functions so tests can run without
6 * a live WordPress installation.
7 *
8 * @package IPCD\Tests
9 */
10
11// Composer autoloader.
12$autoloader = __DIR__ . '/../vendor/autoload.php';
13if ( ! file_exists( $autoloader ) ) {
14 echo "Composer autoloader not found. Run `composer install` inside the plugin directory.\n";
15 exit( 1 );
16}
17require_once $autoloader;
18
19// Define ABSPATH so plugin files don't bail out.
20if ( ! defined( 'ABSPATH' ) ) {
21 define( 'ABSPATH', sys_get_temp_dir() . '/wordpress/' );
22}
23
24// Stub WP_PLUGIN_DIR.
25if ( ! defined( 'WP_PLUGIN_DIR' ) ) {
26 define( 'WP_PLUGIN_DIR', ABSPATH . 'wp-content/plugins' );
27}
28
29// Constants that the plugin defines.
30if ( ! defined( 'IPCD_VERSION' ) ) {
31 define( 'IPCD_VERSION', '1.0.0' );
32}
33if ( ! defined( 'IPCD_PLUGIN_DIR' ) ) {
34 define( 'IPCD_PLUGIN_DIR', dirname( __DIR__ ) . '/' );
35}
36if ( ! defined( 'IPCD_PLUGIN_URL' ) ) {
37 define( 'IPCD_PLUGIN_URL', 'https://example.com/wp-content/plugins/intelligent-plugin-conflict-detector/' );
38}
39if ( ! defined( 'IPCD_PLUGIN_FILE' ) ) {
40 define( 'IPCD_PLUGIN_FILE', dirname( __DIR__ ) . '/intelligent-plugin-conflict-detector.php' );
41}
42if ( ! defined( 'IPCD_PLUGIN_BASENAME' ) ) {
43 define( 'IPCD_PLUGIN_BASENAME', 'intelligent-plugin-conflict-detector/intelligent-plugin-conflict-detector.php' );
44}
45if ( ! defined( 'HOUR_IN_SECONDS' ) ) {
46 define( 'HOUR_IN_SECONDS', 3600 );
47}
48
49// Load the plugin classes under test (without loading the main plugin file
50// which has side-effects like hooking into WP and calling ipcd()).
51require_once IPCD_PLUGIN_DIR . 'includes/class-plugin-state-manager.php';
52require_once IPCD_PLUGIN_DIR . 'includes/class-conflict-detector.php';
53require_once IPCD_PLUGIN_DIR . 'includes/class-background-tester.php';
54require_once IPCD_PLUGIN_DIR . 'includes/class-rollback-manager.php';
55require_once IPCD_PLUGIN_DIR . 'includes/class-notification-manager.php';
Modifiedreadme.txt+75−46View fileUnifiedSplit
@@ -11,82 +11,111 @@ Domain Path: /languages
1111License: GPL-2.0-or-later
1212License URI: https://www.gnu.org/licenses/gpl-2.0.html
1313
14Intelligent plugin conflict detection for WordPress and WooCommerce. Automatically scans, detects, and reports plugin conflicts before they break your store.
14Stop deactivating plugins one by one. Jetstrike scans your site, pinpoints the conflicting pair, and shows you how to fix it.
1515
1616== Description ==
1717
18**Jetstrike Conflict Detector** is the most advanced plugin conflict detection tool for WordPress. It proactively scans your site for plugin conflicts using static code analysis, runtime sandbox testing, and WooCommerce-specific intelligence — so you catch problems before they crash your store.
18**Jetstrike Conflict Detector** finds the plugin that broke your site — automatically.
1919
20= Why Jetstrike? =
20Every WordPress owner knows the drill: after a plugin update something breaks, and the only way to find the culprit is to deactivate plugins one at a time until the problem goes away. For a WooCommerce store with 30+ plugins, that's hours of downtime and lost sales.
2121
22Every WordPress site owner has experienced the pain of plugin conflicts — the white screen of death after an update, broken checkout flows, or mysterious errors that take hours to diagnose. Existing tools require you to manually deactivate plugins one by one. Jetstrike does it all automatically.
22Jetstrike replaces that manual process with automated conflict detection. It analyses your plugins' code, tests combinations in an isolated sandbox, and uses a binary-search algorithm to narrow down conflicts in minutes instead of hours.
2323
24= Key Features =
24= What it detects =
2525
26**Static Code Analysis**
27* Function and class name collision detection
28* WordPress hook/filter conflict analysis
29* Global variable conflict detection
30* Script/style handle collision detection
31* Shortcode and REST API namespace conflicts
26* **Function and class collisions** — two plugins declaring the same function or class name
27* **Hook priority conflicts** — plugins hooking the same action with incompatible priorities
28* **Global variable conflicts** — plugins stepping on each other's globals
29* **Script and style collisions** — duplicate enqueued handles
30* **Fatal errors and PHP warnings** detected during sandbox testing
31* **Performance degradation** — plugins that slow down pages significantly
32* **WooCommerce-specific issues** — payment gateway conflicts, checkout field interference, HPOS compatibility, template overrides, cart calculation hooks
33* **JavaScript conflicts** — global namespace pollution, jQuery overrides, prototype pollution *(Pro)*
34* **Bundled library version conflicts** — when two plugins ship incompatible versions of Guzzle, Monolog, Stripe SDK, etc. *(Pro)*
35* **Database-level conflicts** — colliding option keys, cron hooks, custom post type slugs, custom table names *(Pro)*
3236
33**Runtime Sandbox Testing**
34* Isolated loopback testing — never affects your live site
35* Binary search algorithm isolates conflicts in O(N log N) tests
36* Detects fatal errors, HTTP 500s, and PHP warnings
37* Performance degradation detection
37= Scan modes =
3838
39**WooCommerce Intelligence (Pro)**
40* Payment gateway conflict detection
41* Checkout field manipulation conflicts
42* Cart calculation interference
43* Template override collisions
44* HPOS (High-Performance Order Storage) compatibility checks
39* **Quick Scan (free)** — pure static code analysis. No code is executed. Completes in under 30 seconds on a 30-plugin site.
40* **Full Scan (free, 1/week — Pro: unlimited)** — static analysis plus runtime sandbox testing using isolated loopback HTTP requests.
41* **Pre-Update Scan (Pro)** — simulates the effect of a pending plugin update *before* you apply it.
42* **Targeted Scan (Pro)** — test one specific plugin against every other plugin on the site.
4543
46**Background Monitoring (Pro)**
47* Automated scheduled scans via WP-Cron
48* Plugin update detection and re-scanning
49* New plugin activation scanning
50* Continuous site health scoring
44= Free vs Pro =
5145
52**Notifications (Pro/Agency)**
53* Email alerts for new conflicts
54* Slack webhook integration (Agency)
55* WordPress admin notice alerts
46Free includes: Quick Scan, limited Full Scan (1/week), 3 scan history, basic health scoring, WordPress Site Health integration.
5647
57**Full REST API (Agency)**
58* Programmatic scan control
59* Conflict management
60* Multi-site support
48Pro adds: unlimited Full Scans, automated background scanning (WP-Cron), Pre-Update Simulation, WooCommerce deep analysis, JavaScript / dependency / database analyzers, WP-CLI integration, email alerts, the Auto-Fix Engine *(beta)*, and advanced reporting.
6149
62= Site Health Integration =
50Agency adds: Slack notifications, REST API, multi-site network scanner, unlimited scan history, cross-site export/import, priority support.
6351
64Jetstrike integrates with WordPress Site Health, adding conflict detection to your site's built-in health checks.
52= Site Health integration =
53
54Jetstrike adds conflict-detection checks to WordPress's built-in Site Health screen, so conflicts surface in the same place site owners already look.
55
56= WP-CLI =
57
58Pro users get full CLI access for CI/CD pipelines:
59
60`wp jetstrike scan --type=full --format=json`
61`wp jetstrike conflicts --severity=critical --format=count`
62
63Exit-code-friendly output for gating deployments.
64
65= Privacy =
66
67Jetstrike runs entirely on your own server. No scan data is sent to us unless you explicitly opt in to the anonymous telemetry program (which only shares plugin slugs, conflict types, and severity — never URLs, user data, or code). Telemetry can be toggled off at any time from Settings.
6568
6669== Installation ==
6770
681. Upload the `jetstrike-conflict-detector` folder to `/wp-content/plugins/`
692. Activate the plugin through the 'Plugins' menu in WordPress
703. Navigate to **Conflict Detector** in your admin menu
714. Run your first Quick Scan
711. Upload the `jetstrike-conflict-detector` folder to `/wp-content/plugins/`, or install via Plugins → Add New.
722. Activate the plugin through the Plugins menu.
733. Open **Jetstrike Conflict Detector** from the WordPress admin menu.
744. Click **Run Quick Scan** for an immediate static analysis.
755. (Optional) Activate a Pro licence in **Jetstrike → Settings** to unlock full scans and background monitoring.
7276
7377== Frequently Asked Questions ==
7478
7579= Will scanning affect my live site? =
7680
77No. Quick Scans use static code analysis only — no code is executed. Full Scans use isolated loopback requests that don't affect your visitors.
81Quick Scan is completely safe — it only reads plugin source files and runs static analysis. No plugins are activated or deactivated.
82
83Full Scan uses isolated loopback HTTP requests to test plugin combinations. These requests are made in the background and don't affect real visitors, but on some managed hosts (e.g. when loopback requests are blocked) Full Scan may fall back to static analysis only. Jetstrike will warn you if loopback testing is unavailable.
84
85= Is the Auto-Fix Engine safe? =
86
87Auto-Fix ships as **opt-in BETA in v1.x**. It is disabled by default. When enabled, it writes compatibility patches to `wp-content/mu-plugins/jetstrike-patches/` that are always reversible with one click. After each patch is written, Jetstrike automatically runs a health check on your site; if the check detects a fatal error, the patch is removed and the site is restored before you even see the admin page. You can enable Auto-Fix from **Jetstrike → Settings → Auto-Fix (Beta)** once you've reviewed the warnings.
7888
7989= How is this different from Health Check & Troubleshooting? =
8090
81Health Check requires you to manually enable troubleshooting mode and deactivate plugins one by one. Jetstrike automates the entire process with intelligent binary search and runs scans in the background without any manual intervention.
91Health Check requires you to manually enable troubleshooting mode and deactivate plugins one by one. Jetstrike automates this with a binary-search algorithm (O(N log N) rather than O(N²) tests) and can run scans in the background without any interaction.
8292
8393= Does it work with WooCommerce? =
8494
85Yes! Jetstrike has WooCommerce-specific analysis that detects payment gateway conflicts, checkout field interference, cart calculation issues, template override collisions, and HPOS compatibility problems.
95Yes. WooCommerce is the primary use case. Jetstrike includes a dedicated WooCommerce analyser that catches payment gateway conflicts, checkout field interference, cart calculation hooks, template override collisions, HPOS compatibility issues, and Checkout Block vs. Classic Checkout conflicts. It also works on any WordPress site — WooCommerce is not required.
8696
8797= How many plugins can it scan? =
8898
89There's no limit. The binary search algorithm efficiently handles 30+ active plugins, reducing the number of tests needed from hundreds to dozens.
99There is no hard limit. The binary-search algorithm handles 30+ active plugins efficiently. Very large stacks (50+ plugins) may take a few minutes for a Full Scan, which is why background monitoring is recommended for Pro users.
100
101= Does Jetstrike send my data anywhere? =
102
103No — not unless you opt in. All scanning happens on your server. The optional telemetry program (opt-in only) shares nothing more than plugin directory names, conflict types, and severity levels, and can be turned off at any time.
104
105= Can I use it on a staging site first? =
106
107Absolutely — we recommend it. Jetstrike includes an Export/Import feature (Agency) that lets you scan a staging site, export the results, and import them to production for comparison.
108
109= Does it support multisite? =
110
111The free and Pro tiers support a single site. The Agency tier includes a Network Scanner for multisite installations that scans every site in the network and surfaces per-site health grades.
112
113== Screenshots ==
114
1151. Dashboard with health score, conflict breakdown, and scan controls.
1162. Scan results with severity-ranked conflict list and recommendations.
1173. Compatibility Matrix — visual heat map of every plugin-to-plugin interaction.
1184. Settings page with scan frequency, notifications, and licence activation.
90119
91120== Changelog ==
92121
@@ -112,4 +141,4 @@ There's no limit. The binary search algorithm efficiently handles 30+ active plu
112141== Upgrade Notice ==
113142
114143= 1.0.0 =
115Initial release of Jetstrike Conflict Detector.
144First public release of Jetstrike Conflict Detector.
Addedsite/sample-report.html+339−0View fileUnifiedSplit
@@ -0,0 +1,339 @@
1
2<html lang="en">
3<head>
4<meta charset="UTF-8">
5<meta name="viewport" content="width=device-width, initial-scale=1.0">
6<title>WooCommerce Conflict Audit — Sample Report</title>
7<style>
8* { margin: 0; padding: 0; box-sizing: border-box; }
9body { 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; }
10.report-header { display: flex; align-items: center; justify-content: space-between; padding-bottom: 24px; border-bottom: 2px solid #2563eb; margin-bottom: 32px; }
11.report-brand { font-size: 28px; font-weight: 800; color: #2563eb; letter-spacing: -0.5px; }
12.report-brand small { display: block; font-size: 12px; font-weight: 400; color: #64748b; letter-spacing: 1px; text-transform: uppercase; }
13.report-meta { text-align: right; }
14.report-meta h1 { font-size: 20px; font-weight: 600; }
15.report-meta p { color: #64748b; font-size: 13px; }
16.executive-summary { background: #f0f7ff; border: 1px solid #bfdbfe; border-radius: 8px; padding: 24px; margin-bottom: 32px; }
17.executive-summary h2 { font-size: 16px; font-weight: 700; color: #1e40af; margin-bottom: 12px; text-transform: uppercase; letter-spacing: 0.5px; }
18.executive-summary p { font-size: 15px; color: #334155; }
19.executive-summary .highlight { color: #dc2626; font-weight: 700; }
20.section { margin-bottom: 32px; }
21.section h2 { font-size: 18px; font-weight: 600; margin-bottom: 16px; padding-bottom: 8px; border-bottom: 1px solid #e2e8f0; }
22.health-banner { display: flex; align-items: center; gap: 20px; padding: 20px; background: #f8fafc; border-radius: 8px; border-left: 4px solid #f97316; }
23.health-grade { width: 64px; height: 64px; border-radius: 50%; background: #f97316; color: #fff; display: flex; align-items: center; justify-content: center; font-size: 30px; font-weight: 700; flex-shrink: 0; }
24.health-info h3 { font-size: 18px; font-weight: 600; }
25.health-info p { color: #64748b; margin-top: 4px; font-size: 14px; }
26.summary-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin-top: 16px; }
27.summary-card { text-align: center; padding: 16px; border-radius: 8px; background: #f8fafc; }
28.summary-card.critical { border-left: 3px solid #ef4444; }
29.summary-card.high { border-left: 3px solid #f97316; }
30.summary-card.medium { border-left: 3px solid #eab308; }
31.summary-card.low { border-left: 3px solid #3b82f6; }
32.summary-count { font-size: 36px; font-weight: 700; }
33.summary-card.critical .summary-count { color: #ef4444; }
34.summary-card.high .summary-count { color: #f97316; }
35.summary-card.medium .summary-count { color: #eab308; }
36.summary-card.low .summary-count { color: #3b82f6; }
37.summary-label { font-size: 11px; text-transform: uppercase; letter-spacing: 1px; color: #64748b; }
38table { width: 100%; border-collapse: collapse; margin-top: 8px; }
39th, td { padding: 10px 12px; text-align: left; border-bottom: 1px solid #e2e8f0; font-size: 14px; }
40th { background: #f8fafc; font-weight: 600; font-size: 12px; text-transform: uppercase; letter-spacing: 0.5px; color: #475569; }
41.status-conflict { color: #ef4444; font-weight: 600; }
42.status-clean { color: #22c55e; font-weight: 600; }
43.conflict-card { padding: 16px; border-radius: 8px; margin-bottom: 12px; border: 1px solid #e2e8f0; border-left: 4px solid; page-break-inside: avoid; }
44.conflict-card.critical { border-left-color: #ef4444; background: #fef2f2; }
45.conflict-card.high { border-left-color: #f97316; background: #fff7ed; }
46.conflict-card.medium { border-left-color: #eab308; background: #fefce8; }
47.conflict-card.low { border-left-color: #3b82f6; background: #eff6ff; }
48.conflict-header { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; flex-wrap: wrap; }
49.badge { font-size: 11px; font-weight: 700; padding: 2px 8px; border-radius: 4px; color: #fff; }
50.badge.critical { background: #ef4444; }
51.badge.high { background: #f97316; }
52.badge.medium { background: #eab308; }
53.badge.low { background: #3b82f6; }
54.badge.fixable { background: #2563eb; margin-left: auto; }
55.conflict-type { font-size: 13px; color: #64748b; text-transform: capitalize; }
56.conflict-desc { font-size: 14px; margin-bottom: 8px; }
57.conflict-plugins { margin-bottom: 8px; }
58.conflict-plugins code { background: #f1f5f9; padding: 2px 6px; border-radius: 3px; font-size: 13px; }
59.recommendation { font-size: 13px; color: #475569; padding: 10px 12px; background: #fff; border-radius: 4px; border: 1px solid #e2e8f0; margin-top: 8px; }
60.recommendation strong { color: #1e293b; }
61.next-steps { background: #f0fdf4; border: 1px solid #bbf7d0; border-radius: 8px; padding: 24px; }
62.next-steps h2 { color: #166534; border: 0; padding: 0; margin-bottom: 12px; }
63.next-steps ol { padding-left: 20px; }
64.next-steps li { margin-bottom: 8px; font-size: 14px; }
65.next-steps .urgent { background: #fef2f2; border: 1px solid #fecaca; border-radius: 4px; padding: 12px; margin-top: 16px; }
66.next-steps .urgent strong { color: #dc2626; }
67.cost-box { background: #fffbeb; border: 1px solid #fde68a; border-radius: 8px; padding: 20px; margin-top: 24px; }
68.cost-box h3 { font-size: 16px; color: #92400e; margin-bottom: 8px; }
69.cost-box p { font-size: 14px; color: #78350f; }
70.footer { text-align: center; padding-top: 24px; border-top: 1px solid #e2e8f0; color: #94a3b8; font-size: 13px; margin-top: 40px; }
71.footer a { color: #2563eb; text-decoration: none; }
72.watermark { background: #fef3c7; color: #92400e; text-align: center; padding: 8px; border-radius: 4px; font-size: 12px; font-weight: 600; margin-bottom: 24px; }
73@media print { body { padding: 20px; } .watermark { display: none; } }
74@media (max-width: 600px) { .summary-grid { grid-template-columns: repeat(2, 1fr); } .report-header { flex-direction: column; text-align: center; } .report-meta { text-align: center; } .health-banner { flex-direction: column; text-align: center; } }
75</style>
76</head>
77<body>
78
79<div class="watermark">SAMPLE REPORT — Replace with actual client data</div>
80
81<div class="report-header">
82 <div class="report-brand">
83 Jetstrike
84 <small>Conflict Detector</small>
85 </div>
86 <div class="report-meta">
87 <h1>WooCommerce Conflict Audit</h1>
88 <p>Prepared for: <strong>Acme Outdoor Gear</strong></p>
89 <p>Site: https://acmeoutdoorgear.com</p>
90 <p>Date: April 16, 2026</p>
91 <p>Auditor: [Your Name]</p>
92 </div>
93</div>
94
95<div class="executive-summary">
96 <h2>Executive Summary</h2>
97 <p>
98 Your store has <strong>27 active plugins</strong>. Our scan detected
99 <span class="highlight">11 plugin conflicts</span>, including
100 <span class="highlight">2 critical issues</span> that pose an immediate risk to
101 your checkout flow and could cause order failures during peak traffic.
102 We also found 3 high-severity conflicts and 6 medium/low issues that
103 may degrade performance or cause intermittent errors.
104 </p>
105 <p style="margin-top: 8px;">
106 <strong>Estimated revenue at risk:</strong> Based on your reported monthly revenue of ~$45,000
107 and the nature of the critical conflicts (checkout interference), an unresolved failure during
108 peak hours could cost <span class="highlight">$1,500–$4,500 per incident</span> in lost orders
109 and abandoned carts.
110 </p>
111</div>
112
113<div class="section">
114 <h2>Site Health Score</h2>
115 <div class="health-banner">
116 <div class="health-grade">D</div>
117 <div class="health-info">
118 <h3>Health Score: 38 / 100</h3>
119 <p>Poor — significant conflicts require immediate attention. Your checkout and payment flows are at risk.</p>
120 </div>
121 </div>
122 <div class="summary-grid">
123 <div class="summary-card critical">
124 <div class="summary-count">2</div>
125 <div class="summary-label">Critical</div>
126 </div>
127 <div class="summary-card high">
128 <div class="summary-count">3</div>
129 <div class="summary-label">High</div>
130 </div>
131 <div class="summary-card medium">
132 <div class="summary-count">4</div>
133 <div class="summary-label">Medium</div>
134 </div>
135 <div class="summary-card low">
136 <div class="summary-count">2</div>
137 <div class="summary-label">Low</div>
138 </div>
139 </div>
140</div>
141
142<div class="section">
143 <h2>Active Plugins (27)</h2>
144 <table>
145 <thead><tr><th>Plugin</th><th>Version</th><th>Status</th></tr></thead>
146 <tbody>
147 <tr><td><strong>WooCommerce</strong></td><td>9.5.1</td><td class="status-conflict">3 conflicts</td></tr>
148 <tr><td><strong>WooCommerce Stripe Gateway</strong></td><td>8.2.0</td><td class="status-conflict">2 conflicts</td></tr>
149 <tr><td><strong>WooPayments</strong></td><td>8.4.0</td><td class="status-conflict">1 conflict</td></tr>
150 <tr><td><strong>Elementor Pro</strong></td><td>3.21.0</td><td class="status-conflict">2 conflicts</td></tr>
151 <tr><td><strong>Yoast SEO</strong></td><td>23.8</td><td class="status-clean">Clean</td></tr>
152 <tr><td><strong>WPForms Lite</strong></td><td>1.9.1</td><td class="status-clean">Clean</td></tr>
153 <tr><td><strong>MailPoet</strong></td><td>4.58.0</td><td class="status-conflict">1 conflict</td></tr>
154 <tr><td><strong>WP Rocket</strong></td><td>3.17</td><td class="status-conflict">1 conflict</td></tr>
155 <tr><td><strong>Wordfence Security</strong></td><td>7.11.5</td><td class="status-clean">Clean</td></tr>
156 <tr><td><strong>CartFlows</strong></td><td>2.0.9</td><td class="status-conflict">2 conflicts</td></tr>
157 <tr><td><strong>Custom Order Status Manager</strong></td><td>1.4.2</td><td class="status-conflict">1 conflict</td></tr>
158 <tr><td colspan="3" style="color: #64748b; font-style: italic;">+ 16 more plugins (all clean)</td></tr>
159 </tbody>
160 </table>
161</div>
162
163<div class="section">
164 <h2>Conflict Details (11 found)</h2>
165
166 <!-- Critical 1 -->
167 <div class="conflict-card critical">
168 <div class="conflict-header">
169 <span class="badge critical">CRITICAL</span>
170 <span class="conflict-type">Payment Gateway Conflict</span>
171 <span class="badge fixable">Fixable</span>
172 </div>
173 <div class="conflict-desc">
174 Both WooCommerce Stripe Gateway and WooPayments register payment handlers on the
175 <code>woocommerce_payment_gateways</code> hook at priority 10. During checkout, Stripe's
176 intent creation can be overridden by WooPayments' handler, causing intermittent
177 "Payment processing failed" errors — especially under concurrent requests during sales events.
178 </div>
179 <div class="conflict-plugins">
180 <code>woocommerce-gateway-stripe</code> vs <code>woocommerce-payments</code>
181 </div>
182 <div class="recommendation">
183 <strong>Recommendation:</strong> Adjust the hook priority of WooPayments' gateway registration
184 to priority 20, or deactivate one of the two gateways if you're only using one payment processor.
185 If both are needed for different currencies, a priority-separation patch resolves this cleanly.
186 </div>
187 </div>
188
189 <!-- Critical 2 -->
190 <div class="conflict-card critical">
191 <div class="conflict-header">
192 <span class="badge critical">CRITICAL</span>
193 <span class="conflict-type">Checkout Field Conflict</span>
194 <span class="badge fixable">Fixable</span>
195 </div>
196 <div class="conflict-desc">
197 CartFlows overrides WooCommerce checkout fields via <code>woocommerce_checkout_fields</code>
198 at priority 10. Elementor Pro's checkout widget hooks the same filter at the same priority,
199 causing CartFlows' custom fields to be silently dropped on pages built with Elementor.
200 Customers on those pages see a broken checkout form.
201 </div>
202 <div class="conflict-plugins">
203 <code>cartflows</code> vs <code>elementor-pro</code>
204 </div>
205 <div class="recommendation">
206 <strong>Recommendation:</strong> Move CartFlows' checkout field filter to priority 20 so it
207 runs after Elementor Pro's modifications. This preserves both plugins' functionality.
208 A one-line mu-plugin patch resolves this permanently.
209 </div>
210 </div>
211
212 <!-- High 1 -->
213 <div class="conflict-card high">
214 <div class="conflict-header">
215 <span class="badge high">HIGH</span>
216 <span class="conflict-type">Script Collision</span>
217 <span class="badge fixable">Fixable</span>
218 </div>
219 <div class="conflict-desc">
220 Both Elementor Pro and CartFlows enqueue a script with the handle <code>flatpickr</code>.
221 Elementor loads v4.6.13 while CartFlows loads v4.6.9. On pages where both load, the older
222 version sometimes wins, causing Elementor's date picker to malfunction.
223 </div>
224 <div class="conflict-plugins">
225 <code>elementor-pro</code> vs <code>cartflows</code>
226 </div>
227 <div class="recommendation">
228 <strong>Recommendation:</strong> Dequeue CartFlows' older flatpickr and let Elementor's
229 newer version serve both plugins. Verified compatible.
230 </div>
231 </div>
232
233 <!-- High 2 -->
234 <div class="conflict-card high">
235 <div class="conflict-header">
236 <span class="badge high">HIGH</span>
237 <span class="conflict-type">Dependency Version Conflict</span>
238 </div>
239 <div class="conflict-desc">
240 WooCommerce Stripe Gateway bundles <code>stripe/stripe-php</code> v13.2.0 while
241 MailPoet bundles v10.21.0. Both are loaded without namespace prefixing. Depending on
242 plugin load order, API calls may use the wrong SDK version, causing intermittent
243 Stripe API errors ("Unexpected API version").
244 </div>
245 <div class="conflict-plugins">
246 <code>woocommerce-gateway-stripe</code> vs <code>mailpoet</code>
247 </div>
248 <div class="recommendation">
249 <strong>Recommendation:</strong> Contact MailPoet support to request they namespace-prefix
250 their Stripe SDK (this is a known issue). In the meantime, ensure WooCommerce Stripe Gateway
251 loads before MailPoet by adjusting plugin load order.
252 </div>
253 </div>
254
255 <!-- High 3 -->
256 <div class="conflict-card high">
257 <div class="conflict-header">
258 <span class="badge high">HIGH</span>
259 <span class="conflict-type">Performance Degradation</span>
260 </div>
261 <div class="conflict-desc">
262 When WP Rocket and Custom Order Status Manager are both active, the WooCommerce orders page
263 takes 4.7 seconds to load (baseline: 1.2 seconds). Custom Order Status Manager runs
264 unindexed queries on <code>wp_posts</code> to count orders per status, and WP Rocket's
265 database optimization cron compounds the issue by locking the table.
266 </div>
267 <div class="conflict-plugins">
268 <code>wp-rocket</code> vs <code>custom-order-status-manager</code>
269 </div>
270 <div class="recommendation">
271 <strong>Recommendation:</strong> Add an index on <code>post_status</code> in the orders table
272 and schedule WP Rocket's database optimization outside business hours. With HPOS enabled,
273 this issue resolves entirely.
274 </div>
275 </div>
276
277 <!-- Medium/Low summary -->
278 <div class="conflict-card medium">
279 <div class="conflict-header">
280 <span class="badge medium">MEDIUM</span>
281 <span class="conflict-type">4 additional medium-severity conflicts</span>
282 </div>
283 <div class="conflict-desc">
284 Including: duplicate jQuery enqueue (Elementor vs theme), overlapping cron schedule
285 (MailPoet vs WP Rocket cleanup), shared transient key collision (CartFlows vs WooCommerce
286 session cache), and a global <code>$options</code> variable conflict between two plugins.
287 Full details available upon request.
288 </div>
289 </div>
290
291 <div class="conflict-card low">
292 <div class="conflict-header">
293 <span class="badge low">LOW</span>
294 <span class="conflict-type">2 additional low-severity conflicts</span>
295 </div>
296 <div class="conflict-desc">
297 Minor REST API namespace overlap and a duplicate text-domain warning. No functional impact
298 but worth cleaning up for best practices.
299 </div>
300 </div>
301</div>
302
303<div class="section next-steps">
304 <h2>Recommended Next Steps</h2>
305 <ol>
306 <li><strong>Immediate (this week):</strong> Fix the 2 critical payment/checkout conflicts. These directly affect your ability to process orders.</li>
307 <li><strong>This month:</strong> Resolve the 3 high-severity issues — the Stripe SDK version conflict and script collision can both cause intermittent customer-facing errors.</li>
308 <li><strong>Ongoing:</strong> Set up monthly conflict monitoring. Every plugin update introduces the risk of new conflicts. Proactive scanning catches issues before customers do.</li>
309 </ol>
310
311 <div class="urgent">
312 <strong>Revenue impact estimate:</strong> The two critical checkout/payment conflicts affect
313 approximately 8–15% of checkout attempts. At your current order volume, resolving these
314 could recover an estimated $3,600–$6,700/month in otherwise-lost revenue from failed
315 payments and abandoned carts.
316 </div>
317</div>
318
319<div class="cost-box">
320 <h3>Fix Implementation Available</h3>
321 <p>
322 I can resolve all critical and high-severity conflicts this week with reversible, production-safe
323 patches. Each fix is tested in isolation before deployment and can be rolled back with one click.
324 </p>
325 <p style="margin-top: 8px;">
326 <strong>Conflict Resolution Package:</strong> $1,997 (covers all 5 critical + high issues, plus one month of monitoring to verify stability)
327 </p>
328 <p style="margin-top: 8px;">
329 <strong>Monthly Health Monitoring:</strong> $497/month — continuous scanning, proactive alerts before updates, and immediate resolution of any new conflicts.
330 </p>
331</div>
332
333<div class="footer">
334 <p>Generated by <strong>Jetstrike Conflict Detector</strong></p>
335 <p style="margin-top: 4px;">Confidential — prepared exclusively for Acme Outdoor Gear</p>
336</div>
337
338</body>
339</html>
Modifiedtemplates/admin/dashboard.php+49−0View fileUnifiedSplit
@@ -105,6 +105,22 @@ $grade_color = $grade_colors[$health['grade']] ?? '#6c757d';
105105 esc_html_e('Full Scan', 'jetstrike-cd');
106106 </button>
107107 </div>
108 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">
@@ -128,6 +144,39 @@ $grade_color = $grade_colors[$health['grade']] ?? '#6c757d';
128144 </div>
129145 </div>
130146
147 <!-- Toolbar: Report, Export, Matrix -->
148 <div class="jetstrike-cd-card jetstrike-cd-card--full">
149 <div class="jetstrike-cd-toolbar">
150 <button type="button" class="button" id="jetstrike-generate-report">
151 <span class="dashicons dashicons-media-document"></span>
152 <?php esc_html_e('Generate Report', 'jetstrike-cd'); ?>
153 </button>
154 <button type="button" class="button" id="jetstrike-export-data">
155 <span class="dashicons dashicons-download"></span>
156 <?php esc_html_e('Export Data', 'jetstrike-cd'); ?>
157 </button>
158 <button type="button" class="button" id="jetstrike-toggle-matrix">
159 <span class="dashicons dashicons-grid-view"></span>
160 <?php esc_html_e('Compatibility Matrix', 'jetstrike-cd'); ?>
161 </button>
162 <?php if ($tier === 'agency'): ?>
163 <button type="button" class="button" id="jetstrike-import-data">
164 <span class="dashicons dashicons-upload"></span>
165 <?php esc_html_e('Import Data', 'jetstrike-cd'); ?>
166 </button>
167 <input type="file" id="jetstrike-import-file" accept=".json" style="display: none;">
168 <?php endif; ?>
169 </div>
170
171 <!-- Compatibility Matrix (hidden by default, toggled by button) -->
172 <div id="jetstrike-matrix-container" style="display: none;">
173 <?php
174 $matrix = new \Jetstrike\ConflictDetector\Admin\CompatibilityMatrix($this->repository ?? $repository);
175 echo $matrix->render_html(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- HTML is escaped internally
176 ?>
177 </div>
178 </div>
179
131180 <!-- Active Conflicts Table -->
132181 <?php if ($total_conflicts > 0 && ! empty($active_conflicts['items'])): ?>
133182 <div class="jetstrike-cd-card jetstrike-cd-card--full">
Modifiedtemplates/admin/settings.php+22−0View fileUnifiedSplit
@@ -167,6 +167,28 @@ $frequencies = [
167167 </tr>
168168 </table>
169169
170 <h2> esc_html_e('Auto-Fix Engine (Beta)', 'jetstrike-cd'); </h2>
171 <table class="form-table">
172 <tr>
173 <th scope="row"><?php esc_html_e('Enable Auto-Fix', 'jetstrike-cd'); ?></th>
174 <td>
175 <label>
176 <input type="checkbox" name="autofix_beta_enabled" value="1"
177 <?php checked(get_option('jetstrike_cd_autofix_beta_enabled', 'no'), 'yes'); ?>
178 <?php echo $tier === 'free' ? 'disabled' : ''; ?>>
179 <?php esc_html_e('Enable the Auto-Fix Engine (beta)', 'jetstrike-cd'); ?>
180 </label>
181 <?php if ($tier === 'free'): ?>
182 <p class="description jetstrike-cd-pro-badge"><?php esc_html_e('Pro feature', 'jetstrike-cd'); ?></p>
183 <?php else: ?>
184 <p class="description" style="color: #b45309;">
185 <?php esc_html_e('Warning: Auto-Fix writes mu-plugin patch files to resolve conflicts. Each patch is individually reversible. A health check runs automatically after every fix — if your site becomes unreachable, the patch is removed immediately.', 'jetstrike-cd'); ?>
186 </p>
187 <?php endif; ?>
188 </td>
189 </tr>
190 </table>
191
170192 <h2><?php esc_html_e('Excluded Plugins', 'jetstrike-cd'); ?></h2>
171193 <table class="form-table">
172194 <tr>
Modifieduninstall.php+24−0View fileUnifiedSplit
@@ -35,3 +35,27 @@ foreach ($cron_hooks as $hook) {
3535 wp_unschedule_event($timestamp, $hook);
3636 }
3737}
38
39// Remove Auto-Fix mu-plugin patches.
40$patch_dir = defined('WPMU_PLUGIN_DIR')
41 ? WPMU_PLUGIN_DIR . '/jetstrike-patches'
42 : WP_CONTENT_DIR . '/mu-plugins/jetstrike-patches';
43
44if (is_dir($patch_dir)) {
45 $files = glob($patch_dir . '/*.php');
46 if (is_array($files)) {
47 foreach ($files as $file) {
48 wp_delete_file($file);
49 }
50 }
51 @rmdir($patch_dir);
52}
53
54// Remove the patch loader mu-plugin.
55$loader = defined('WPMU_PLUGIN_DIR')
56 ? WPMU_PLUGIN_DIR . '/jetstrike-patch-loader.php'
57 : WP_CONTENT_DIR . '/mu-plugins/jetstrike-patch-loader.php';
58
59if (file_exists($loader)) {
60 wp_delete_file($loader);
61}
3862
c comment · e edit title · m merge · a approve · r request changes · ? shortcuts