PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.1
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.1
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 1.1.5 1.1.6 1.1.7 1.1.8 1.2.0 All 28 releases
xspeed / includes / class-health.php

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

611 lines 27.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Health — shared diagnostic checks consumed by both Onboarding's
4 * environment surface and the Health module's dashboard panel.
5 *
6 * Each check returns:
7 * [
8 * 'id' => 'wp_version',
9 * 'tone' => 'ok' | 'warn' | 'fail' | 'info',
10 * 'label' => 'WordPress 6.6',
11 * 'detail' => 'Meets the 6.0+ minimum.',
12 * ]
13 *
14 * Pure reads — never writes to disk, never makes outbound HTTP calls.
15 * Safe to call from any request including the loading dashboard.
16 *
17 * @package XSpeed
18 */
19
20 declare(strict_types=1);
21
22 namespace XSpeed;
23
24 defined( 'ABSPATH' ) || exit;
25
26 final class Health {
27
28 public const OK = 'ok';
29 public const WARN = 'warn';
30 public const FAIL = 'fail';
31 public const INFO = 'info';
32
33 private const SERVER_LABELS = array(
34 'apache' => 'Apache',
35 'litespeed' => 'LiteSpeed',
36 'nginx' => 'nginx',
37 'iis' => 'IIS',
38 'unknown' => 'Unknown',
39 );
40
41 /**
42 * Full check list used by the Health module's dashboard panel.
43 *
44 * @return array<int,array{id:string,tone:string,label:string,detail:string}>
45 */
46 public static function checks(): array {
47 global $wp_version;
48
49 $server_type = Server::type();
50 $gzip_mode = Server::gzip_mode();
51 $conflicts = Server::conflicts();
52 $cache_dir = defined( 'XSPEED_CACHE_DIR' ) ? XSPEED_CACHE_DIR : ( WP_CONTENT_DIR . '/cache/xspeed' );
53
54 $out = array();
55
56 // WordPress version
57 $wp_ok = version_compare( (string) $wp_version, '6.0', '>=' );
58 $out[] = array(
59 'id' => 'wp_version',
60 'tone' => $wp_ok ? self::OK : self::FAIL,
61 'label' => sprintf( 'WordPress %s', (string) $wp_version ),
62 'detail' => $wp_ok ? 'Meets the 6.0+ minimum.' : 'Upgrade to WordPress 6.0 or higher.',
63 );
64
65 // PHP version
66 $php_ok = version_compare( PHP_VERSION, '7.4', '>=' );
67 $php_modern = version_compare( PHP_VERSION, '8.1', '>=' );
68 $out[] = array(
69 'id' => 'php_version',
70 'tone' => $php_modern ? self::OK : ( $php_ok ? self::WARN : self::FAIL ),
71 'label' => sprintf( 'PHP %s', PHP_VERSION ),
72 'detail' => $php_modern
73 ? 'Modern PHP — full speed.'
74 : ( $php_ok
75 ? 'Works, but 8.1+ is recommended for best performance.'
76 : 'Upgrade to PHP 7.4 or higher.' ),
77 );
78
79 // Server
80 $out[] = array(
81 'id' => 'server',
82 'tone' => self::INFO,
83 'label' => sprintf( 'Server: %s', self::SERVER_LABELS[ $server_type ] ?? 'Unknown' ),
84 'detail' => 'auto' === $gzip_mode
85 ? 'GZIP can be auto-configured via .htaccess.'
86 : 'GZIP requires a manual server-config snippet (shown in the GZIP module).',
87 );
88
89 // Cache directory writable
90 $dir_writable = wp_mkdir_p( $cache_dir ) && wp_is_writable( $cache_dir );
91 $out[] = array(
92 'id' => 'cache_dir',
93 'tone' => $dir_writable ? self::OK : self::FAIL,
94 'label' => 'Cache directory writable',
95 'detail' => $dir_writable
96 ? $cache_dir
97 : sprintf( 'Cannot write to %s. Adjust file permissions before enabling cache.', $cache_dir ),
98 );
99
100 // Drop-in installed (only when cache is enabled — otherwise N/A)
101 $cache_enabled = (bool) Settings::get()['cache_enabled'];
102 if ( $cache_enabled ) {
103 // Ask the ownership oracle, not the bytes. A loose "xspeed"
104 // substring matched any foreign drop-in that so much as mentions
105 // us in a compatibility note, and reported it as "owned by
106 // xSpeed" while another plugin served every hit — the exact
107 // loose-substring test the drop-in contract forbids.
108 $dropin_match = Cache::DROPIN_XSPEED === Cache::dropin_owner();
109 $out[] = array(
110 'id' => 'dropin',
111 'tone' => $dropin_match ? self::OK : self::FAIL,
112 'label' => 'advanced-cache.php drop-in',
113 'detail' => $dropin_match
114 ? 'Installed and owned by xSpeed.'
115 : 'Drop-in missing or owned by another plugin. Toggle Enable Cache off and on to reinstall.',
116 );
117 }
118
119 // WP_CACHE constant
120 $wp_cache_const = defined( 'WP_CACHE' ) && WP_CACHE;
121 if ( $cache_enabled ) {
122 // Why the constant is missing decides what we tell the user to
123 // do, so the two cases can't share one sentence. An unwritable
124 // wp-config.php (managed hosts make it read-only by design) is
125 // the common cause and the user must paste the line by hand.
126 // But it is NOT the only way to land here: a leftover
127 // `define( 'WP_CACHE', false );` from a previous cache plugin,
128 // a missing wp-config.php, or a WP_Filesystem that wants FTP
129 // credentials all fail set_wp_cache_constant() on a perfectly
130 // writable file. Asserting "not writable" unconditionally told
131 // those users something demonstrably false about their own
132 // server and sent them hand-editing a file the plugin could
133 // have fixed by toggling the cache off and on. (#19)
134 // The cost is real, but it is NOT "nothing is being cached": with
135 // the constant absent, Cache::maybe_start_cache() still serves on
136 // template_redirect and tags the response `HIT (php)`. What the
137 // constant buys is answering from the drop-in BEFORE WordPress
138 // boots — worth roughly an order of magnitude on TTFB, which is
139 // what makes this a WARN worth acting on. Claiming the cache was
140 // idle was simply false, and it is the sentence a worried user
141 // reads first. (#19, QA on #174)
142 //
143 // Ask the WRITER which branch to show, not a second oracle: Health
144 // used to run its own wp_is_writable() against its own path
145 // resolution, and the two disagreed with set_wp_cache_constant()
146 // in both directions — see Cache::can_write_wp_config().
147 $wp_config_writable = Cache::can_write_wp_config();
148 $check = array(
149 'id' => 'wp_cache_constant',
150 'tone' => $wp_cache_const ? self::OK : self::WARN,
151 'label' => 'WP_CACHE constant',
152 'detail' => $wp_cache_const
153 ? 'Defined and truthy in wp-config.php.'
154 : 'Not set — cached pages are being served the slow way. Without this constant WordPress boots fully before xSpeed can answer from cache, costing roughly 10ms per hit. ' . ( $wp_config_writable
155 ? 'wp-config.php is writable, so toggling Enable Cache off and on should set it for you; if it comes back, another plugin may have left define( \'WP_CACHE\', false ) behind — add the line below by hand instead.'
156 : 'wp-config.php is not writable here (managed hosts often make it read-only), so add the line below by hand, above the "That\'s all, stop editing!" comment.' ),
157 );
158 if ( ! $wp_cache_const ) {
159 // The line to paste, carried on the check itself so it
160 // survives on a persistent surface. Enabling the cache
161 // offered this only inside a toast that cleared after two
162 // seconds with no copy button, so a user who looked away
163 // had no way back to it anywhere in the dashboard — while
164 // the toggle read green and the site cached nothing.
165 // HealthCard already renders `snippet` through CopySnippet
166 // (the nginx check proves the wiring). (#19)
167 $check['snippet'] = "define( 'WP_CACHE', true );";
168 }
169 $out[] = $check;
170 }
171
172 // Static-rewrite probe. Active end-to-end check: writes a probe
173 // file under the static-cache dir, fetches it over HTTP, and
174 // confirms the web server (nginx OR Apache/LiteSpeed) served
175 // the raw file. Result is throttled to a 5-minute transient
176 // inside Cache::probe_static_rewrite so we never hit the
177 // network per-paint.
178 if ( $cache_enabled ) {
179 $server_type = Server::type();
180 // The live loopback probe only matters where a server-level static
181 // rewrite is actually used (nginx snippet / Apache .htaccess).
182 // LiteSpeed serves hits via the drop-in (see below), so skip the
183 // probe there entirely — no needless self-request. Health is the
184 // right place to pay for the probe when we DO run it (the admin
185 // bootstrap reads cache-only so it never blocks); the 5-minute
186 // transient still throttles repeat runs. (FBS-82142)
187 $probe = ( Server::LITESPEED === $server_type )
188 ? array( 'active' => false )
189 : Cache::probe_static_rewrite( true );
190 $is_active = (bool) ( $probe['active'] ?? false );
191 // An inconclusive probe (blocked loopback, TLS failure, timeout, a
192 // CDN/WAF answering instead of the origin) proves nothing about the
193 // rewrite. Reporting it as "not yet routing to the cache" told users
194 // with a correct nginx config that their config was broken, and
195 // pointed them at a snippet they had already pasted. (FBS-84012)
196 $inconclusive = (bool) ( $probe['inconclusive'] ?? false );
197 $probe_reason = (string) ( $probe['reason'] ?? '' );
198
199 $block_reason = Cache::static_rewrite_block_reason();
200
201 // An OBSERVED refusal, from the last cacheable render. The settings
202 // above say whether the rewrite is allowed; this says whether pages
203 // are actually reaching the tree. They disagree whenever a page is
204 // refused per-response — a nonce being the common one — and in that
205 // case the settings are right and irrelevant: nginx is configured
206 // correctly, and every hit still comes from PHP. (#372)
207 $skip = Cache::last_static_skip();
208 if ( '' === $block_reason && ! empty( $skip['reason'] ) ) {
209 $block_reason = 'skipped_' . (string) $skip['reason'];
210 }
211 $mobile_block = ( 'mobile_separate' === $block_reason )
212 ? ' Note: Separate Mobile Cache is on, which disables the device-blind static rewrite — if your site serves the same HTML to all devices, turn it off (Cache settings) for much faster cache hits.'
213 : '';
214
215 // A known refusal OUTRANKS the probe. probe_static_rewrite() writes
216 // its own file under the static-cache dir and fetches that — which
217 // succeeds whenever the server can serve a static file at all, even
218 // when static_rewrite_allowed() is false and no real page is being
219 // served that way. Checking $is_active first therefore reported
220 // "PHP bypassed" on a site whose pages were all returning
221 // HIT (php): the panel answered "why isn't static serving active?"
222 // with the opposite of the truth. When we already know why the
223 // rewrite is off, say that and ignore the probe. (FBS-83145)
224 $refused = ( '' !== $block_reason );
225 $is_active = $is_active && ! $refused;
226 // A refusal is a definite finding, so it also outranks
227 // "inconclusive" — otherwise a blocked rewrite whose probe merely
228 // failed to complete would be reported as INFO ("nothing to warn
229 // about") instead of the WARN the block deserves.
230 $inconclusive = $inconclusive && ! $refused;
231
232 if ( Server::NGINX === $server_type ) {
233 if ( $is_active ) {
234 $nginx_detail = 'nginx is serving cache hits directly — PHP bypassed (~5-15ms TTFB).';
235 } elseif ( 'mobile_separate' === $block_reason ) {
236 $nginx_detail = 'nginx detected, but the static rewrite is disabled because Separate Mobile Cache is on.' . $mobile_block;
237 } elseif ( 'skipped_nonce' === $block_reason ) {
238 $nginx_detail = self::nonce_skip_detail( $skip );
239 } elseif ( $inconclusive ) {
240 $nginx_detail = sprintf(
241 'Could not verify the static rewrite — the check itself did not complete, so this is not evidence that your config is wrong. If you have already pasted the snippet, it may well be working. Reason: %s',
242 $probe_reason
243 );
244 } else {
245 $nginx_detail = 'nginx detected but not yet routing to the cache. Paste the snippet below into your site\'s server { } block, then reload nginx.';
246 }
247
248 $out[] = array(
249 'id' => 'static_rewrite_nginx',
250 // Inconclusive is INFO, not WARN — we have no finding to warn about.
251 'tone' => $is_active ? self::OK : ( $inconclusive ? self::INFO : self::WARN ),
252 'label' => 'Static-file rewrite (nginx server config)',
253 'detail' => $nginx_detail,
254 // Always ship the snippet — even when active, so the
255 // admin has it handy for re-pasting after a server
256 // rebuild without having to find it elsewhere. Mirror
257 // the SAME unified block the "Server config" panel
258 // renders (cache + gzip + browser-cache directives),
259 // not the cache-only snippet — otherwise Health and
260 // the Cache panel disagree on what to paste.
261 'snippet' => Cache::full_nginx_server_block(),
262 );
263 } elseif ( Server::LITESPEED === $server_type ) {
264 // LiteSpeed intentionally does NOT use the .htaccess static
265 // rewrite: OpenLiteSpeed's .htaccess engine ignores
266 // mod_headers (so we can't stamp X-XSpeed-Cache: HIT) and has
267 // no per-rule access_log (so a static hit can't be counted).
268 // We route LiteSpeed hits through the PHP drop-in instead, so
269 // every hit is both visible (X-XSpeed-Cache: HIT) and counted
270 // in the hit-ratio — see Cache::static_rewrite_allowed(). This
271 // is the healthy, expected state on LiteSpeed, not a fallback.
272 $out[] = array(
273 'id' => 'static_rewrite_litespeed',
274 'tone' => self::OK,
275 'label' => 'Cache serving (LiteSpeed)',
276 'detail' => 'Cache hits are served by xSpeed\'s drop-in and tagged X-XSpeed-Cache: HIT — so every hit is visible and counted in your hit-ratio. (LiteSpeed\'s .htaccess can\'t add that header or log static hits, so xSpeed serves them itself for accurate reporting.)',
277 );
278 } elseif ( Server::APACHE === $server_type ) {
279 $installed = Cache::rewrite_installed();
280 if ( $is_active ) {
281 $tone = self::OK;
282 $detail = 'Block installed and serving cache hits directly — PHP bypassed.';
283 } elseif ( 'mobile_separate' === $block_reason ) {
284 $tone = self::WARN;
285 $detail = 'Static rewrite disabled because Separate Mobile Cache is on.' . $mobile_block;
286 } elseif ( 'no_mod_headers' === $block_reason ) {
287 // Not "missing" — deliberately not installed, because
288 // Apache can't stamp X-XSpeed-Cache without mod_headers
289 // and the hit would be invisible and uncountable. Caching
290 // still works via the drop-in; say so, and give the one
291 // step that actually changes the outcome.
292 $tone = self::INFO;
293 $detail = 'Cache hits are served by xSpeed\'s drop-in and tagged X-XSpeed-Cache: HIT (php), so every hit is visible and counted. The faster .htaccess fast path is off because Apache\'s mod_headers module is not loaded — without it a static hit could not be tagged or counted. Enable mod_headers (`a2enmod headers` on Debian/Ubuntu, then restart Apache) to shave roughly 20-30ms off each cache hit.';
294 } elseif ( 'skipped_nonce' === $block_reason ) {
295 // Before this, Apache fell through to "probe failed —
296 // check AllowOverride", sending the admin to audit a
297 // config that was never the problem.
298 $tone = self::WARN;
299 $detail = self::nonce_skip_detail( $skip );
300 } elseif ( ! $installed ) {
301 $tone = self::WARN;
302 $detail = 'Block missing from .htaccess. Toggle Enable Cache off and on to reinstall it.';
303 } elseif ( $inconclusive ) {
304 // Same distinction as the nginx branch: the probe never
305 // reached a verdict, so telling the user to go re-check
306 // AllowOverride blames a config that may be perfectly
307 // fine. Report the failure honestly instead. (FBS-84012)
308 $tone = self::INFO;
309 $detail = sprintf(
310 'Block is installed, but the check could not complete (%s), so this is not evidence that anything is misconfigured. Re-run it with `wp xspeed cache recheck-rewrite` once the site can reach itself over HTTP.',
311 $probe_reason
312 );
313 } else {
314 $tone = self::WARN;
315 $detail = sprintf( 'Block installed but probe failed (%s). Confirm the .htaccess block is at the TOP of the file, and that AllowOverride is enabled for your site so mod_rewrite reads it.', $probe_reason );
316 }
317 $out[] = array(
318 'id' => 'static_rewrite',
319 'tone' => $tone,
320 'label' => 'Static-file rewrite (.htaccess)',
321 'detail' => $detail,
322 );
323 }
324 }
325
326 // Cache expiry vs preloader schedule (deterministic rule, issue #31):
327 // pages that expire faster than the preloader re-warms them leave the
328 // cache cold for most real traffic — the classic "24.8% hit ratio with
329 // everything on" misconfiguration. Pure logic in
330 // expiry_preload_check() so it's unit-testable.
331 if ( $cache_enabled ) {
332 $cache_opts = Settings_Manager::get( 'cache' );
333 $pre_opts = Settings_Manager::get( 'preloader' );
334 $schedule = (string) ( $pre_opts['schedule'] ?? 'manual' );
335 $mismatch = self::expiry_preload_check(
336 (int) ( $cache_opts['cache_expiry'] ?? \XSpeed\Modules\Cache\CacheModule::DEFAULT_EXPIRY_HOURS ),
337 $schedule,
338 ! empty( $pre_opts['enabled'] ),
339 self::schedule_interval_hours( $schedule )
340 );
341 if ( null !== $mismatch ) {
342 $out[] = $mismatch;
343 }
344 }
345
346 // Permalinks
347 $permalinks_ok = (bool) get_option( 'permalink_structure' );
348 $out[] = array(
349 'id' => 'permalinks',
350 'tone' => $permalinks_ok ? self::OK : self::WARN,
351 'label' => 'Permalinks',
352 'detail' => $permalinks_ok
353 ? 'Pretty permalinks active.'
354 : 'Set permalinks to anything other than "Plain" — page caching needs URL paths to key on.',
355 );
356
357 // Cache-poisoning Set-Cookie detection (issue #33): a plugin emitting
358 // Set-Cookie on anonymous pageviews forces CDN/edge BYPASS for all
359 // HTML (Cloudflare never caches a response carrying Set-Cookie). Probe
360 // is transient-throttled inside Cookie_Inspector, same pattern as the
361 // static-rewrite probe above — Health is the right place to pay for it.
362 // Cached-only: Health runs inside the dashboard REST request and the
363 // MCP get_health tool, so this must never block on an HTTP call.
364 // A cold verdict schedules a background refresh and reports nothing
365 // this paint. See Cookie_Inspector::probe_cached().
366 $cookie_probe = Cookie_Inspector::probe_cached();
367 if ( $cookie_probe['checked'] ) {
368 $offenders = $cookie_probe['cookies'];
369 if ( empty( $offenders ) ) {
370 $out[] = array(
371 'id' => 'set_cookie_poisoning',
372 'tone' => self::OK,
373 'label' => 'No cache-poisoning cookies',
374 'detail' => 'Anonymous pages are served without Set-Cookie, so CDN/edge caches can store them.',
375 );
376 } else {
377 $named = array();
378 foreach ( $offenders as $c ) {
379 $named[] = null !== $c['plugin']
380 ? sprintf( '%s is setting %s', $c['plugin'], $c['name'] )
381 : sprintf( 'an unidentified plugin is setting %s', $c['name'] );
382 }
383 $out[] = array(
384 'id' => 'set_cookie_poisoning',
385 'tone' => self::WARN,
386 'label' => 'Set-Cookie on cacheable pages',
387 'detail' => sprintf(
388 '%s — this prevents CDN edge caching (Cloudflare returns BYPASS for any response with Set-Cookie). Configure the plugin to set its cookie via JavaScript instead, or exclude it from anonymous pageviews.',
389 implode( '; ', $named )
390 ),
391 );
392 }
393 }
394
395 // Conflicting plugins
396 $out[] = array(
397 'id' => 'conflicts',
398 'tone' => empty( $conflicts ) ? self::OK : self::WARN,
399 'label' => 'Caching plugin conflicts',
400 // Not "Active:" — the list now includes a drop-in left behind by a
401 // plugin that is not running, which is exactly the case that made
402 // this row disagree with what the enable actually does.
403 'detail' => empty( $conflicts )
404 ? 'No other caching plugins detected.'
405 : sprintf( 'Found: %s. Another page cache must be off, and its advanced-cache.php gone, before xSpeed can enable its own.', implode( ', ', $conflicts ) ),
406 );
407
408 /*
409 * A migration whose source is STILL RUNNING.
410 *
411 * Distinct from the generic `conflicts` check above, which only says
412 * "another caching plugin is active". This one knows the user imported
413 * from it and chose (or was refused) to leave it on, so it can name the
414 * plugin and the decision.
415 *
416 * The point is persistence: the import screen's warning disappears the
417 * moment the user navigates away, and the risk does not. Two page
418 * caches fighting over the drop-in is exactly what breaks caching for
419 * both, so the warning has to outlive the screen it was raised on.
420 * (#189 AC4)
421 */
422 $pending = class_exists( '\\XSpeed\\Migration' ) ? Migration::pending_source() : null;
423 if ( null !== $pending ) {
424 $out[] = array(
425 'id' => 'migration_source_active',
426 'tone' => self::WARN,
427 'label' => sprintf( '%s is still active after import', $pending['label'] ),
428 'detail' => sprintf(
429 'You imported settings from %s but left it running. Two page caches fight over the cache drop-in and can break caching for both — deactivate %s on the Plugins screen once you have checked the imported settings.',
430 $pending['label'],
431 $pending['label']
432 ),
433 );
434 }
435
436 return $out;
437 }
438
439 /**
440 * Hours between recurring preloader crawls, per schedule option.
441 * `twicedaily` is a WordPress core schedule — omitting it meant a site
442 * using it got no check at all, not even a pass.
443 */
444 public const PRELOAD_INTERVALS = array(
445 'hourly' => 1,
446 'twicedaily' => 12,
447 'daily' => 24,
448 'weekly' => 168,
449 );
450
451 /**
452 * Interval in hours for a cron schedule slug, or null when it isn't a
453 * recurring schedule (`manual`) or can't be resolved.
454 *
455 * Falls back to `wp_get_schedules()` so custom crons registered by a
456 * theme or another plugin are covered too, rather than silently
457 * skipping the check.
458 */
459 /**
460 * Explain a static-tree refusal caused by nonces.
461 *
462 * Says four things, because leaving any of them out is what made this
463 * invisible: the config is FINE (so nobody re-pastes a snippet that was
464 * never the problem), hits are coming from PHP instead, which nonce keys
465 * caused it, and that the refusal is deliberate rather than a bug to work
466 * around. The keys are the actionable part — they name the plugin, and it
467 * is usually a widget the page does not use. (#372)
468 *
469 * @param array{reason?:string,url?:string,keys?:string[]} $skip Recorded refusal.
470 */
471 private static function nonce_skip_detail( array $skip ): string {
472 $detail = 'Your nginx config is correct, but pages are not reaching the static cache, so hits are served by PHP (typically ~1s instead of ~5-15ms). '
473 . 'They contain nonces, and a static file is served with no PHP — nothing could ever refresh them, so every anonymous form on the page would break once they expire. Keeping these pages on PHP is deliberate.';
474
475 $keys = array_filter( array_map( 'strval', (array) ( $skip['keys'] ?? array() ) ) );
476 if ( ! empty( $keys ) ) {
477 $detail .= ' Nonces found: ' . implode( ', ', $keys ) . '.';
478 $detail .= ' These come from plugin widgets — disabling the ones this site does not use lets its pages be served statically again.';
479 }
480
481 $url = (string) ( $skip['url'] ?? '' );
482 if ( '' !== $url ) {
483 $detail .= sprintf( ' Last seen on %s.', $url );
484 }
485
486 return $detail;
487 }
488
489 public static function schedule_interval_hours( string $schedule ): ?int {
490 if ( isset( self::PRELOAD_INTERVALS[ $schedule ] ) ) {
491 return self::PRELOAD_INTERVALS[ $schedule ];
492 }
493 if ( '' === $schedule || 'manual' === $schedule || ! function_exists( 'wp_get_schedules' ) ) {
494 return null;
495 }
496 $schedules = wp_get_schedules();
497 if ( ! isset( $schedules[ $schedule ]['interval'] ) ) {
498 return null;
499 }
500 $hours = (int) round( (int) $schedules[ $schedule ]['interval'] / HOUR_IN_SECONDS );
501 return $hours > 0 ? $hours : 1;
502 }
503
504 /**
505 * Deterministic rule: warn when cache_expiry is shorter than the
506 * preloader's recurring interval (pages go cold between crawls).
507 *
508 * Pure — no WP calls — so it can be unit-tested directly.
509 *
510 * @param int $expiry_hours Cache expiry in hours.
511 * @param string $schedule Preloader schedule (manual|hourly|twicedaily|daily|weekly|custom).
512 * @param bool $preloader_enabled Whether the preloader module is on.
513 * @param int|null $interval_hours Pre-resolved interval, for schedules
514 * outside PRELOAD_INTERVALS. Keeps this
515 * function pure — the caller does the
516 * wp_get_schedules() lookup.
517 * @return array{id:string,tone:string,label:string,detail:string}|null Check
518 * row, or null when the rule doesn't apply (preloader off/manual).
519 */
520 public static function expiry_preload_check( int $expiry_hours, string $schedule, bool $preloader_enabled, ?int $interval_hours = null ): ?array {
521 if ( ! $preloader_enabled ) {
522 return null;
523 }
524 $interval = $interval_hours ?? ( self::PRELOAD_INTERVALS[ $schedule ] ?? null );
525 if ( null === $interval || $interval < 1 ) {
526 return null;
527 }
528 if ( $expiry_hours < $interval ) {
529 return array(
530 'id' => 'expiry_preload_mismatch',
531 'tone' => self::WARN,
532 'label' => 'Cache expiry shorter than the preload schedule',
533 'detail' => sprintf(
534 'Pages expire after %dh but the preloader only re-warms them every %dh (%s), so most visits hit a cold cache. Raise Cache Expiry to at least %dh (Cache settings), or preload more often (Preloader settings).',
535 $expiry_hours,
536 $interval,
537 $schedule,
538 $interval
539 ),
540 );
541 }
542 return array(
543 'id' => 'expiry_preload_mismatch',
544 'tone' => self::OK,
545 'label' => 'Cache expiry covers the preload schedule',
546 'detail' => sprintf( 'Expiry %dh ≥ preload interval %dh (%s) — preloaded pages stay warm between crawls.', $expiry_hours, $interval, $schedule ),
547 );
548 }
549
550 /**
551 * Lightweight environment payload for the onboarding wizard. Keeps
552 * the legacy shape Onboarding::env_payload returned so the Welcome
553 * step's HealthRow rendering doesn't change.
554 */
555 public static function env_payload(): array {
556 global $wp_version;
557 $cache_dir = defined( 'XSPEED_CACHE_DIR' ) ? XSPEED_CACHE_DIR : ( WP_CONTENT_DIR . '/cache/xspeed' );
558 return array(
559 'wp' => array(
560 'version' => (string) $wp_version,
561 'ok' => version_compare( (string) $wp_version, '6.0', '>=' ),
562 ),
563 'php' => array(
564 'version' => PHP_VERSION,
565 'ok' => version_compare( PHP_VERSION, '7.4', '>=' ),
566 'modern' => version_compare( PHP_VERSION, '8.1', '>=' ),
567 ),
568 'server' => array(
569 'type' => Server::type(),
570 'gzip_mode' => Server::gzip_mode(),
571 ),
572 'cache_dir' => array(
573 'path' => $cache_dir,
574 'writable' => wp_mkdir_p( $cache_dir ) && wp_is_writable( $cache_dir ),
575 ),
576 'wp_config' => array(
577 'writable' => self::wp_config_writable(),
578 ),
579 'permalinks_ok' => (bool) get_option( 'permalink_structure' ),
580 'conflicts' => Server::conflicts(),
581 /*
582 * The reason the enable would be refused right now, or null.
583 *
584 * `conflicts` is a list of plugins, and the wizard used it to
585 * decide whether to open with page caching ticked. The two are
586 * not the same question: an orphaned or doubly-defined WP_CACHE
587 * refuses the enable with no plugin to name, so the wizard
588 * offered a pre-ticked switch it already knew would fail. This
589 * is the gate's own answer, so the box and the outcome agree.
590 */
591 'page_cache_blocked' => Cache::acquisition_blocker(),
592 );
593 }
594
595 /**
596 * Cheap writability probe for the onboarding env payload only.
597 *
598 * Deliberately NOT the oracle behind the WP_CACHE check — that asks
599 * Cache::can_write_wp_config(), which runs the same WP_Filesystem test
600 * the writer runs, so advice can never contradict behaviour. This one
601 * stays a plain filesystem read because env_payload() is documented as
602 * making no outbound calls, and WP_Filesystem() can try to open an
603 * FTP/SSH connection. It shares the writer's path resolution so the two
604 * at least agree on WHICH file they are describing. (#19, QA on #174)
605 */
606 private static function wp_config_writable(): bool {
607 $path = Cache::wp_config_path();
608 return '' !== $path && wp_is_writable( $path );
609 }
610 }
611