PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.7
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.7
1.3.7 1.3.6 1.3.5 1.3.4 1.3.3 1.3.2 1.3.1 1.3.0 1.2.4 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 All 33 releases
xspeed / includes / class-pro-audit.php

class-pro-audit.php in xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN 1.3.7, at includes/class-pro-audit.php

680 lines 27.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Pro_Audit — scans the current Free configuration + cache stats and
4 * surfaces Pro features that would specifically help THIS site.
5 *
6 * Powers the dashboard's "Run Pro audit" button. The point isn't to
7 * list every Pro feature; it's to make each suggestion personal
8 * ("Cache hit ratio is 38% → Pro Recommendations would tell you why")
9 * so the user converts because Pro solves a problem they actually
10 * see, not because we shouted "BUY NOW."
11 *
12 * Pure read-only. Returns an ordered list of suggestions:
13 *
14 * [ id, severity ('high'|'med'|'low'), reason, fact ]
15 *
16 * - id → matches a key in PRO_FEATURES (the React catalog),
17 * so the panel renders title/body without duplicating
18 * copy here.
19 * - severity controls sort order + visual tone.
20 * - reason → one-sentence explanation specific to this site's
21 * state. Already-baked numbers/percentages so the
22 * React side just prints it.
23 * - fact → optional shorter inline stat (e.g. "38%") for the
24 * result card's chip.
25 *
26 * Adding a rule: drop another `if (…) $out[] = …` block in run().
27 * Rules are independent — keep them small + concrete + factual.
28 *
29 * An add-on adds one from outside via `xspeed_pro_audit_suggestions`
30 * instead — see contributed() for the shape it has to return and why
31 * that path is not gated by already_active().
32 *
33 * If the suggestion maps to a single Pro module, add it to
34 * BACKING_MODULE and guard the rule with `! self::already_active($id)`.
35 * A rule that fires on site state alone keeps nagging a customer who
36 * already bought and enabled the feature — and any consumer rendering
37 * the audit as a call-to-action (the Hub's "Enable" button) then shows
38 * a control that can never clear. (#187)
39 *
40 * @package XSpeed
41 */
42
43 declare(strict_types=1);
44
45 namespace XSpeed;
46
47 defined( 'ABSPATH' ) || exit;
48
49 final class Pro_Audit {
50
51 public const SEVERITY_HIGH = 'high';
52 public const SEVERITY_MED = 'med';
53 public const SEVERITY_LOW = 'low';
54
55 /**
56 * Bounds on what `xspeed_pro_audit_suggestions` may add.
57 *
58 * The audit is rendered in a dashboard card and returned verbatim by the
59 * MCP `get_pro_audit` tool, so an add-on that appends 500 rows or a
60 * paragraph of prose degrades both. The caps are generous against any
61 * honest use — no native rule comes close — and exist so a buggy
62 * contributor can't make the payload the problem.
63 */
64 private const MAX_CONTRIBUTED = 10;
65 private const MAX_REASON_LEN = 400;
66 private const MAX_FACT_LEN = 32;
67
68 /**
69 * Snapshot of state every rule needs. Computed once per audit run
70 * so we don't read the same option 8 times.
71 *
72 * @param array|null $totals_override Test injection — Brain Monkey
73 * can't mock static class methods,
74 * so tests synthesize the 24h
75 * counter shape here directly.
76 * Production callers leave null.
77 *
78 * @return array<string,mixed>
79 */
80 private static function snapshot( ?array $totals_override = null ): array {
81 $opts = static function ( string $slug ): array {
82 return (array) get_option( 'xspeed_module_' . $slug, array() );
83 };
84 if ( null !== $totals_override ) {
85 $totals = $totals_override;
86 } elseif ( class_exists( '\\XSpeed\\Hit_Counter' ) ) {
87 $totals = Hit_Counter::totals_24h();
88 } else {
89 $totals = array( 'hits' => 0, 'misses' => 0, 'excluded' => 0, 'ratio' => 0.0 );
90 }
91 $cloudflare = $opts( 'cloudflare' );
92 return array(
93 'cache' => $opts( 'cache' ),
94 'minify' => $opts( 'minify' ),
95 'lazy' => $opts( 'lazy' ),
96 'gzip' => $opts( 'gzip' ),
97 'browser_cache' => $opts( 'browser-cache' ),
98 'cloudflare' => $cloudflare,
99 'cdn' => $opts( 'cdn' ),
100 'database' => $opts( 'database' ),
101 'preloader' => $opts( 'preloader' ),
102 'heartbeat' => $opts( 'heartbeat' ),
103 'cache_enabled' => class_exists( '\\XSpeed\\Settings' )
104 ? ! empty( Settings::get()['cache_enabled'] )
105 : false,
106 // An edge cache (Cloudflare) in front means the origin hit ratio is
107 // only the origin layer — hits served at the edge never reach PHP —
108 // so a low number is an attribution artefact, not a cache problem.
109 // Rule 2 must not fire an upsell off it. (#118)
110 'edge_cache' => ! empty( $cloudflare['enabled'] ),
111 'totals_24h' => array(
112 'hits' => (int) ( $totals['hits'] ?? 0 ),
113 'misses' => (int) ( $totals['misses'] ?? 0 ),
114 // 404s + bots, kept out of the ratio denominator. (#118)
115 'excluded' => (int) ( $totals['excluded'] ?? 0 ),
116 'total' => (int) ( $totals['hits'] ?? 0 ) + (int) ( $totals['misses'] ?? 0 ),
117 'ratio' => (float) ( $totals['ratio'] ?? 0.0 ),
118 ),
119 );
120 }
121
122 /**
123 * Which Pro module backs each suggestion id. A suggestion whose module
124 * is installed AND switched on is not an upsell any more — the site
125 * already has the thing we'd be selling. (#187)
126 *
127 * Ids without an entry (analytics fallback, white-label, …) are always
128 * eligible; absence here means "no single module answers this".
129 */
130 private const BACKING_MODULE = array(
131 'webp-avif' => 'images',
132 'rum' => 'rum',
133 'critical-css' => 'critical-css',
134 );
135
136 /*
137 * `cloudflare-apo` is deliberately NOT here.
138 *
139 * APO's on/off state lives at Cloudflare — `Cloudflare_Apo::status()`
140 * is a live GET against their API, and our own options carry only the
141 * values we PUSH to it (cache_level, browser_ttl). Nothing local
142 * records whether it is on. The audit runs on every dashboard load and
143 * on Free installs with no credentials, so it must not make a network
144 * call to find out.
145 *
146 * The first cut mapped it to an `enabled` key that the module never
147 * writes, which read as "always eligible" — the right OUTCOME by
148 * accident, via a check that could never fire. Stating the limitation
149 * is better than a guard that looks like it works. If a cached
150 * APO-state option is added later, give it a probe below and restore
151 * the mapping. (#187 review)
152 */
153
154 /**
155 * Ids whose module records its on/off state somewhere other than a
156 * plain `enabled` flag.
157 *
158 * The first cut of this guard read `enabled` for all of them. Only
159 * `rum` and `critical-css` have that key — `images` is switched on PER
160 * FORMAT (`webp` / `avif`), so a site with conversion fully on was
161 * still told to buy image conversion, and the Hub kept drawing an
162 * Enable button that could never clear. (#187 review)
163 *
164 * A closure per id, receiving that module's EFFECTIVE options — schema
165 * defaults merged over the stored row, which is what the module actually
166 * runs on. Reading the raw row instead missed every setting still at its
167 * default, and Images defaults `webp` to true. (#187 QA round 2)
168 *
169 * @return array<string, callable(array):bool>
170 */
171 private static function activity_probes(): array {
172 return array(
173 // Conversion is per format — either one means new uploads are
174 // being converted, which is the thing the suggestion sells.
175 'webp-avif' => static function ( array $o ): bool {
176 return ! empty( $o['webp'] ) || ! empty( $o['avif'] );
177 },
178 );
179 }
180
181 /**
182 * Is the Pro module backing this suggestion already active?
183 *
184 * Resolved by module SLUG, never by referencing a Pro class: the audit
185 * runs on Free installs where those classes do not exist, and Free never
186 * names Pro. Settings_Manager returns an empty array for a slug that is
187 * not registered, so Free resolves to "not active" and keeps suggesting.
188 */
189 private static function already_active( string $id ): bool {
190 $slug = self::BACKING_MODULE[ $id ] ?? '';
191 if ( '' === $slug ) {
192 return false;
193 }
194
195 /*
196 * Suppression is only honest while Pro is installed AND licensed.
197 *
198 * Deactivating Pro leaves its settings rows behind, and nothing
199 * clears them — Pro has no uninstall routine for them, so deleting
200 * the plugin does not help either. Reading those rows directly meant
201 * a Free-only site with the same history was silently never shown
202 * the RUM and Critical CSS suggestions again. An expired licence is
203 * the same shape with a sharper edge: the feature is gated OFF and
204 * genuinely not running, which is exactly the moment a renewal
205 * prompt is most useful. (#187 review)
206 *
207 * `xspeed_pro_state` is the signal Pro already reports to Free for
208 * the gated UI; it needs no Pro class reference here.
209 */
210 if ( 'active' !== self::pro_state() ) {
211 return false;
212 }
213
214 /*
215 * Read the module's EFFECTIVE settings, not its stored row.
216 *
217 * A raw get_option() sees only what someone has explicitly saved. A
218 * module's schema defaults are what it actually runs on until then —
219 * Pro's Images module defaults `webp` to true, so a site that bought
220 * Pro and never opened the Images panel is converting images while
221 * its option row has no `webp` key at all. The probe read false and
222 * the audit told that customer to buy image conversion: the exact
223 * complaint in #187, reproduced on a different feature. Opening the
224 * panel and pressing Save with no changes made the suggestion vanish,
225 * which is the tell that the check was reading the wrong thing rather
226 * than a genuine "off". (#187 QA round 2)
227 *
228 * Settings_Manager::get() merges schema defaults over the stored row.
229 * It returns array() for a module that is not registered, so on Free —
230 * where the Pro module does not exist — this still resolves to "not
231 * active" and every suggestion keeps firing. Safe to call here because
232 * the pro_state() gate above means Pro is loaded by this point.
233 */
234 $opts = Settings_Manager::get( $slug );
235 $probes = self::activity_probes();
236 if ( isset( $probes[ $id ] ) ) {
237 return (bool) $probes[ $id ]( $opts );
238 }
239
240 return ! empty( $opts['enabled'] );
241 }
242
243 /**
244 * Suggestions contributed by an add-on, normalised to the native shape.
245 *
246 * The rules in run() only know what the Free engine can see: options and
247 * cache counters. An add-on that owns a feature knows things about it that
248 * no option records — that a generator has never once succeeded, that a
249 * conversion is producing files bigger than the ones it replaces — and
250 * before this filter it had nowhere to say so. The finding stayed inside
251 * that add-on's own panel, and `get_pro_audit`, which is what an agent
252 * reads when it asks "what is wrong with this site", never heard about it.
253 *
254 * DELIBERATELY NOT GATED BY already_active(). That guard exists to stop the
255 * audit nagging someone to buy a feature they already own (#187), which is
256 * an upsell concern: an upsell for a feature that is already on can never
257 * be acted on. A contribution is the opposite kind of message — it comes
258 * FROM the feature, and the feature has to be switched on to have anything
259 * to report. Inheriting the suppression would silence exactly the case
260 * worth hearing: a feature that is on and misbehaving. Do not "tidy up" by
261 * routing this through already_active(). (xspeed-pro#86)
262 *
263 * Contributors may only ADD. The filter is seeded with an empty array and
264 * the native list is passed as read-only context, so nothing a contributor
265 * returns can delete or rewrite a native suggestion. Contributions are
266 * appended after the native rules and then go through the same dedupe and
267 * severity sort, so a contribution takes over a native id only by being
268 * strictly more severe — never by merely arriving later.
269 *
270 * @param array<int,array<string,mixed>> $native Suggestions the native
271 * rules produced, as context.
272 * @return array<int,array{id:string,severity:string,reason:string,fact?:string}>
273 */
274 private static function contributed( array $native ): array {
275 /**
276 * Filter: xspeed_pro_audit_suggestions
277 *
278 * Extra suggestions to append to the audit. Seeded with an empty
279 * array — append your own entries and return the array; the native
280 * suggestions are passed separately as read-only context, so nothing
281 * returned here can remove or rewrite one.
282 *
283 * Each entry: id (string, required — a feature slug; matches a key in
284 * the React PRO_FEATURES catalog when one exists, otherwise the id
285 * itself is shown as the title), severity ('high'|'med'|'low',
286 * defaults to 'low'), reason (string, required — one sentence,
287 * already-baked numbers, no markup), fact (string, optional — a short
288 * inline stat for the card's chip). Anything else is dropped.
289 *
290 * @param array $suggestions Contributed suggestions (empty on entry).
291 * @param array $native The native suggestions, as context.
292 */
293 try {
294 $raw = apply_filters( 'xspeed_pro_audit_suggestions', array(), $native );
295 } catch ( \Throwable $e ) {
296 // A contributor that throws costs the audit its contributions,
297 // not the audit. Every native finding is already computed by the
298 // time this runs, and the audit is what the dashboard card and
299 // the MCP `get_pro_audit` tool both read — a seam that lets a
300 // broken add-on take those down is a liability to the thing it
301 // extends.
302 //
303 // The whole round is lost, not just the thrower's entry: the
304 // throw unwinds through apply_filters(), so a well-behaved
305 // contributor that ran earlier has no partial result left to
306 // salvage. Nothing to do about that from out here, but it is
307 // the reason this says "contributions" and not "its
308 // contribution".
309 //
310 // core's apply_filters() pops $wp_current_filter AFTER the
311 // callbacks return, so a throw leaves our hook name on the
312 // stack: current_filter() would keep answering
313 // 'xspeed_pro_audit_suggestions' for the rest of the request,
314 // and core's own lazy-loading branches on that. Pop it back,
315 // and only if it is still ours to pop. (WP_Hook's nesting_level
316 // leaks the same way and cannot be reached from here; it is
317 // scoped to this one hook, which we are done with.)
318 if ( isset( $GLOBALS['wp_current_filter'] )
319 && is_array( $GLOBALS['wp_current_filter'] )
320 && end( $GLOBALS['wp_current_filter'] ) === 'xspeed_pro_audit_suggestions' ) {
321 array_pop( $GLOBALS['wp_current_filter'] );
322 }
323 if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
324 // Swallowing a fatal without a word makes a broken add-on
325 // indistinguishable from one with nothing to report.
326 // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
327 error_log( '[xspeed] xspeed_pro_audit_suggestions contributor threw: ' . $e->getMessage() );
328 }
329 return array();
330 }
331 if ( ! is_array( $raw ) ) {
332 if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
333 // Usually a contributor that forgot to return the list. It
334 // costs every contribution, so it should not be silent either.
335 // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
336 error_log( '[xspeed] xspeed_pro_audit_suggestions returned ' . gettype( $raw ) . ', not an array; contributions dropped' );
337 }
338 return array();
339 }
340
341 $out = array();
342 foreach ( $raw as $row ) {
343 if ( count( $out ) >= self::MAX_CONTRIBUTED ) {
344 break;
345 }
346 $clean = self::normalize_suggestion( $row );
347 if ( null !== $clean ) {
348 $out[] = $clean;
349 }
350 }
351 return $out;
352 }
353
354 /**
355 * Coerce one contributed entry into the exact shape run() emits, or drop it.
356 *
357 * Everything downstream — the dedupe, the severity sort, the React card,
358 * the MCP tool's response — is written against `{id, severity, reason,
359 * fact?}` and nothing re-checks it. An unknown severity alone is enough to
360 * break the panel, which indexes a style map by it. So this is a whitelist,
361 * not a merge: unrecognised keys are dropped rather than passed through, and
362 * an entry that can't supply an id and a reason is dropped whole. A missing
363 * or invalid severity is not fatal, but it settles at 'low' — a contributor
364 * who won't say how bad it is doesn't get to outrank anyone.
365 *
366 * @param mixed $row Whatever the filter returned in this slot.
367 * @return array{id:string,severity:string,reason:string,fact?:string}|null
368 */
369 private static function normalize_suggestion( $row ): ?array {
370 if ( ! is_array( $row ) ) {
371 return null;
372 }
373
374 $id = sanitize_key( self::as_text( $row['id'] ?? null ) );
375 if ( '' === $id ) {
376 return null;
377 }
378
379 $reason = self::as_text( $row['reason'] ?? null );
380 $reason = trim( (string) sanitize_text_field( $reason ) );
381 if ( '' === $reason ) {
382 return null;
383 }
384
385 $severity = self::as_text( $row['severity'] ?? null );
386 if ( ! in_array( $severity, array( self::SEVERITY_HIGH, self::SEVERITY_MED, self::SEVERITY_LOW ), true ) ) {
387 $severity = self::SEVERITY_LOW;
388 }
389
390 $clean = array(
391 'id' => $id,
392 'severity' => $severity,
393 'reason' => self::clamp( $reason, self::MAX_REASON_LEN ),
394 );
395
396 $fact = trim( (string) sanitize_text_field( self::as_text( $row['fact'] ?? null ) ) );
397 if ( '' !== $fact ) {
398 $clean['fact'] = self::clamp( $fact, self::MAX_FACT_LEN );
399 }
400
401 return $clean;
402 }
403
404 /**
405 * A string, or '' for anything that isn't honestly one.
406 *
407 * Booleans are excluded on purpose: `(string) true` is "1", which would
408 * sail through an is_scalar() check and land a suggestion whose reason
409 * reads "1".
410 *
411 * @param mixed $value Raw value from a contributed entry.
412 */
413 private static function as_text( $value ): string {
414 if ( is_string( $value ) ) {
415 return $value;
416 }
417 if ( is_int( $value ) || is_float( $value ) ) {
418 return (string) $value;
419 }
420 return '';
421 }
422
423 /**
424 * Trim to a hard character budget, ellipsis included in the budget.
425 *
426 * @param string $text Text to bound.
427 * @param int $limit Maximum length of the result.
428 */
429 private static function clamp( string $text, int $limit ): string {
430 if ( function_exists( 'mb_strlen' ) && function_exists( 'mb_substr' ) ) {
431 if ( mb_strlen( $text ) <= $limit ) {
432 return $text;
433 }
434 return rtrim( mb_substr( $text, 0, $limit - 1 ) ) . '…';
435 }
436 return self::clamp_without_mbstring( $text, $limit );
437 }
438
439 /**
440 * clamp() on a site with no mbstring.
441 *
442 * strlen()/substr() count BYTES, and using them here got the budget wrong
443 * in both directions at once. Too tight: 400 bytes of accented Latin is
444 * under 200 characters, so a reason well inside its allowance came back
445 * truncated. And unsafe: a byte cut can land inside a character, and
446 * wp_json_encode() answers false for the WHOLE response rather than
447 * mangling one word — the client loses every finding in the audit.
448 *
449 * PCRE counts characters in `/u` mode without mbstring, so the budget
450 * stays a character budget. Both patterns are bounded by `$limit`, so
451 * neither walks a long string or builds an array of it.
452 *
453 * @param string $text Text to bound.
454 * @param int $limit Maximum length of the result.
455 */
456 private static function clamp_without_mbstring( string $text, int $limit ): string {
457 $limit = max( 1, $limit );
458
459 // preg_match() returns false — not 0 — when the subject is not valid
460 // UTF-8, which is how the byte fallback below is reached.
461 $within = preg_match( '/^.{0,' . $limit . '}$/us', $text );
462 if ( 1 === $within ) {
463 return $text;
464 }
465 if ( 0 === $within && 1 === preg_match( '/^.{0,' . ( $limit - 1 ) . '}/us', $text, $m ) ) {
466 return rtrim( $m[0] ) . '…';
467 }
468
469 // Not valid UTF-8 to begin with — a contributor sent bytes we cannot
470 // count. Bytes are all there is, but the result still has to encode.
471 if ( strlen( $text ) <= $limit ) {
472 return $text;
473 }
474 return rtrim( self::whole_characters( substr( $text, 0, $limit - 1 ) ) ) . '…';
475 }
476
477 /**
478 * Drop a trailing partial UTF-8 character.
479 *
480 * A byte cut can land inside a multibyte character and leave a dangling
481 * fragment. That makes the string invalid UTF-8, and wp_json_encode()
482 * answers false for the WHOLE response — the client loses every finding,
483 * not one accented word. At most three bytes come off.
484 *
485 * @param string $bytes Byte-cut text.
486 */
487 private static function whole_characters( string $bytes ): string {
488 // `//u` is an empty pattern with the UTF-8 modifier: it matches
489 // anything, and fails outright when the subject is not valid UTF-8.
490 while ( '' !== $bytes && 1 !== preg_match( '//u', $bytes ) ) {
491 $bytes = substr( $bytes, 0, -1 );
492 }
493 return $bytes;
494 }
495
496 /**
497 * Pro's own report of its licence state: 'not_installed' | 'unlicensed'
498 * | 'active'. Mirrors Admin::pro_state(), which is private to that
499 * class; only 'active' means Pro's features are actually running.
500 */
501 private static function pro_state(): string {
502 $present = class_exists( '\\XSpeed\\Tier_Registry' ) && Tier_Registry::pro_active();
503 $default = $present ? 'active' : 'not_installed';
504
505 /** This filter is documented in includes/class-admin.php */
506 $state = (string) apply_filters( 'xspeed_pro_state', $default );
507
508 return in_array( $state, array( 'not_installed', 'unlicensed', 'active' ), true ) ? $state : $default;
509 }
510
511 /**
512 * @param array|null $totals_override See snapshot(). Production
513 * callers pass nothing.
514 *
515 * @return array<int,array{id:string,severity:string,reason:string,fact?:string}>
516 */
517 public static function run( ?array $totals_override = null ): array {
518 $s = self::snapshot( $totals_override );
519 $out = array();
520
521 // Rule 1 — Cloudflare connected but APO not in use.
522 // High signal: user already pays the Cloudflare overhead, APO
523 // is the highest-leverage Pro feature they can flip on next.
524 //
525 // Deliberately NOT guarded by already_active(): APO's real state
526 // lives at Cloudflare, not in an option here, and finding out means
527 // a live API call — which this audit runs on every dashboard load,
528 // including Free installs with no credentials. The guard used to be
529 // called here anyway; it could never fire (no BACKING_MODULE entry),
530 // so it produced the right outcome by accident while reading as
531 // though the case were handled. See the note beside BACKING_MODULE.
532 // (#187 QA round 2)
533 if ( ! empty( $s['cloudflare']['enabled'] ) ) {
534 $out[] = array(
535 'id' => 'cloudflare-apo',
536 'severity' => self::SEVERITY_HIGH,
537 'reason' => 'Cloudflare is already connected. Pro adds Automatic Platform Optimization, which edge-caches your HTML — typically cuts TTFB in half.',
538 'fact' => 'Cloudflare on',
539 );
540 }
541
542 // Rule 2 — Low cache hit ratio with meaningful traffic.
543 // "Meaningful" = > 50 hits over 24h; below that the ratio is
544 // statistical noise and we'd suggest based on bad data. The ratio is
545 // now computed over real traffic only (404s + bots excluded, #118), and
546 // we skip it entirely when an edge cache fronts the origin — behind
547 // Cloudflare a low origin ratio means hits are served at the edge, not
548 // that the cache is failing, so firing a "your cache is bad" upsell off
549 // it is selling against a measurement artefact.
550 if ( empty( $s['edge_cache'] )
551 && $s['totals_24h']['total'] >= 50
552 && $s['totals_24h']['ratio'] < 0.5 ) {
553 $pct = (int) round( $s['totals_24h']['ratio'] * 100 );
554 $out[] = array(
555 'id' => 'recommendations',
556 'severity' => self::SEVERITY_HIGH,
557 'reason' => sprintf(
558 'Cache hit ratio is %d%% over the last 24h. Pro Recommendations identifies which URLs miss the cache and why, with one-click fixes.',
559 $pct
560 ),
561 'fact' => $pct . '% hit',
562 );
563 }
564
565 // Rule 3 — Lazy-load enabled but no auto WebP/AVIF.
566 // User cares about images (lazy on) → next gain is format.
567 if ( ! empty( $s['lazy']['lazy_images'] ) && ! self::already_active( 'webp-avif' ) ) {
568 $out[] = array(
569 'id' => 'webp-avif',
570 'severity' => self::SEVERITY_MED,
571 'reason' => 'Images are lazy-loaded. Pro auto-converts new JPEG/PNG uploads to WebP and AVIF — typically 25-35% smaller at the same visual quality.',
572 );
573 }
574
575 // Rule 4 — High traffic without RUM data.
576 // Real-user metrics matter more than synthetic Lighthouse when
577 // the site has actual visitors.
578 if ( $s['totals_24h']['total'] >= 100 && ! self::already_active( 'rum' ) ) {
579 $views = number_format( $s['totals_24h']['total'] );
580 $out[] = array(
581 'id' => 'rum',
582 'severity' => self::SEVERITY_MED,
583 'reason' => sprintf(
584 'You served %s requests in 24h. Pro RUM samples actual LCP, CLS and INP from those visitors — Lighthouse only simulates one device, one connection.',
585 $views
586 ),
587 'fact' => $views . ' / 24h',
588 );
589 }
590
591 // Rule 5 — HTML minify on but JS minify off (theme-safe stance).
592 // Suggest Critical CSS as the next gain that doesn't touch JS.
593 if ( ! empty( $s['minify']['minify_html'] ) && empty( $s['minify']['minify_js'] )
594 && ! self::already_active( 'critical-css' ) ) {
595 $out[] = array(
596 'id' => 'critical-css',
597 'severity' => self::SEVERITY_MED,
598 'reason' => 'JS minify is off (good — high theme-conflict risk). Pro Critical CSS delivers similar first-paint gains without touching JavaScript.',
599 );
600 }
601
602 // Rule 6 — Database cleanup on manual schedule.
603 // Only fire when the user has actually configured the Database
604 // module (has saved options). Empty option = user hasn't
605 // touched it; don't suggest scheduling something they might
606 // never use.
607 if ( ! empty( $s['database'] ) && 'manual' === ( $s['database']['schedule'] ?? 'manual' ) ) {
608 $out[] = array(
609 'id' => 'recommendations',
610 'severity' => self::SEVERITY_LOW,
611 'reason' => 'Database cleanup is set to manual. Pro Recommendations engine auto-schedules cleanups based on smart triggers (after publish, before backup).',
612 );
613 }
614
615 // Rule 7 — Agency / professional usage signal.
616 // >= 5 enabled modules suggests serious use → white-label is
617 // what they'd actually want next.
618 $enabled = 0;
619 foreach ( array( 'minify', 'gzip', 'lazy', 'browser_cache', 'cloudflare', 'cdn', 'preloader' ) as $k ) {
620 if ( ! empty( $s[ $k ]['enabled'] ) ) {
621 $enabled++;
622 }
623 }
624 if ( $s['cache_enabled'] ) {
625 $enabled++;
626 }
627 if ( $enabled >= 5 ) {
628 $out[] = array(
629 'id' => 'white-label',
630 'severity' => self::SEVERITY_LOW,
631 'reason' => sprintf(
632 'You\'ve configured %d modules — looks like agency work. Pro White-Label rebrands the dashboard chrome for client handoff.',
633 $enabled
634 ),
635 'fact' => $enabled . ' modules',
636 );
637 }
638
639 // Add-on contributions. Collected after the native rules and before
640 // the fallback: a site whose only real finding comes from an add-on
641 // should get that finding, not the generic filler underneath it.
642 $out = array_merge( $out, self::contributed( $out ) );
643
644 // Fallback — never return an empty audit. Analytics is the
645 // safe always-relevant suggestion (every site has cache
646 // activity to chart).
647 if ( empty( $out ) ) {
648 $out[] = array(
649 'id' => 'analytics',
650 'severity' => self::SEVERITY_LOW,
651 'reason' => 'See which pages benefit most from caching, where your slow URLs are, and your hit-ratio over time.',
652 );
653 }
654
655 // Dedupe by id, keeping the highest-severity rule per feature.
656 // Rules independently suggest the same feature for different
657 // reasons; pick the strongest reason to show.
658 $by_id = array();
659 $order = array( self::SEVERITY_HIGH => 0, self::SEVERITY_MED => 1, self::SEVERITY_LOW => 2 );
660 foreach ( $out as $row ) {
661 $id = $row['id'];
662 if ( ! isset( $by_id[ $id ] ) ) {
663 $by_id[ $id ] = $row;
664 continue;
665 }
666 $existing_rank = $order[ $by_id[ $id ]['severity'] ] ?? 9;
667 $new_rank = $order[ $row['severity'] ] ?? 9;
668 if ( $new_rank < $existing_rank ) {
669 $by_id[ $id ] = $row;
670 }
671 }
672
673 $out = array_values( $by_id );
674 usort( $out, static function ( $a, $b ) use ( $order ) {
675 return ( $order[ $a['severity'] ] ?? 9 ) <=> ( $order[ $b['severity'] ] ?? 9 );
676 } );
677 return $out;
678 }
679 }
680