PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.0
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.0
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 1.2.1 All 27 releases
xspeed / includes / class-optimize-runner.php

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

815 lines 31.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Optimize runner — the five phases, wired to the real site.
4 *
5 * @package XSpeed
6 */
7
8 namespace XSpeed;
9
10 defined( 'ABSPATH' ) || exit;
11
12 /**
13 * Measure, diagnose, apply, verify, re-measure — and report honestly.
14 *
15 * This is the thin layer that gives Optimize_Plan / Optimizer / Optimize_Verifier
16 * a real site to work on. Everything interesting lives in those three; what is
17 * here is the wiring and, more importantly, the shape of what comes back.
18 *
19 * ## Why `unfixable` exists
20 *
21 * The temptation with a tool like this is to return "done" and a green tick.
22 * That is false on a large class of sites, and the falseness is the expensive
23 * kind — the AI repeats it to the user, who believes their site is now fast.
24 *
25 * A real site tested during this feature's design carried a 97% hit ratio, a
26 * 124ms TTFB, and every optimization already enabled — and still scored 50,
27 * because of 15 images hotlinked from another domain, a 945KB video and 2,093
28 * DOM elements. There was nothing left for a caching plugin to do, and saying
29 * "optimized!" would have been a lie of omission.
30 *
31 * So the report names what it could not fix and why. A run that changes nothing
32 * is a legitimate outcome, reported as such.
33 *
34 * ## Why the score is measured rather than remembered
35 *
36 * The same honesty problem applies to the number itself. This read the last
37 * stored audit, which on a site measured a fortnight ago meant applying six
38 * changes and then reporting a two-week-old 77 as the outcome — a stale figure
39 * presented in the position a reader takes for a result.
40 *
41 * `measure_score` decides what to spend on avoiding that:
42 *
43 * - `auto` (default) — measure when the stored score is stale, and always
44 * after changes land, subject to the cooldown in Optimize_Diagnosis.
45 * - `never` — the pre-1.2.0 behaviour, for callers that must not spend a
46 * measurement.
47 * - `always` — measure even for a dry run.
48 *
49 * Every score now carries `age_seconds`, and the summary sentence names it, so
50 * a number that could not be refreshed is still readable as old rather than
51 * passing for fresh.
52 *
53 * ## One pass, and the seam for more
54 *
55 * This runs ONE pass: every step the tier allows, then stop. That is the whole
56 * of Free's behaviour and it is deliberate — a pass has a bounded cost, and
57 * chasing a target score does not.
58 *
59 * Repeating the cycle until a site reaches, say, 90 means measuring after each
60 * round and planning the next against whatever metric is actually weak, which
61 * is several measurements per site and only pays off across a fleet. That loop
62 * is Pro's; `xspeed_optimize_report` is where it attaches, so it never needs to
63 * fork this file. `rounds` in the report says how many passes produced it, and
64 * is 1 for everything Free does on its own.
65 *
66 * @since 1.2.0
67 */
68 final class Optimize_Runner {
69
70 /**
71 * How many pages OUR OWN suggestions offer for post-run checking.
72 *
73 * Applied before `xspeed_optimize_verify_urls` runs, so a site can add
74 * its own risky template without ours crowding it out. Three is the point
75 * where a person still opens all of them.
76 */
77 private const VERIFY_URL_LIMIT = 3;
78
79 /**
80 * Run the whole thing.
81 *
82 * @param array{aggressiveness?:string,dry_run?:bool,budget_seconds?:int,url?:string,measure_score?:string,round?:int} $args Options.
83 * @return array<string,mixed>|\WP_Error
84 */
85 public static function run( array $args = array() ) {
86 $aggressiveness = (string) ( $args['aggressiveness'] ?? Optimize_Plan::TIER_STANDARD );
87 $dry_run = (bool) ( $args['dry_run'] ?? false );
88 $budget = (int) ( $args['budget_seconds'] ?? 120 );
89 $url = (string) ( $args['url'] ?? home_url( '/' ) );
90 $measure = (string) ( $args['measure_score'] ?? 'auto' );
91
92 // Which round this is. A listener on xspeed_optimize_report re-enters
93 // run() for round 2 onward and passes this, so the filter context
94 // reports the true round rather than always claiming to be the first.
95 $round = max( 1, (int) ( $args['round'] ?? 1 ) );
96
97 // Arguments Free does not act on, forwarded to the seam so a listener
98 // can. `target_score` / `max_rounds` are the multi-round tuner's, and
99 // they arrive here from the MCP tool and the CLI like any other
100 // argument — Free's job is to carry them, not to understand them.
101 //
102 // Clamped on the way through, not passed on raw. Free does not act on
103 // these, but it is the only place that sees them before a listener
104 // does, and a tuner handed `target_score = 9000` would chase a score
105 // that cannot exist — burning rounds and measurements on an
106 // unreachable goal. A Lighthouse score is 0-100 and a round count
107 // below 1 is not a request. (#306 QA, minor 2)
108 $bounds = array(
109 'target_score' => array( 0, 100 ),
110 'max_rounds' => array( 1, 10 ),
111 );
112 $passthrough = array();
113 foreach ( $bounds as $key => list( $min, $max ) ) {
114 if ( isset( $args[ $key ] ) && is_numeric( $args[ $key ] ) ) {
115 $passthrough[ $key ] = max( $min, min( $max, (int) $args[ $key ] ) );
116 }
117 }
118
119 // Built once, passed to every seam call, so the two report paths cannot
120 // hand a listener different context for the same run.
121 $context = array_merge(
122 array(
123 'aggressiveness' => $aggressiveness,
124 'measure_score' => $measure,
125 'budget_seconds' => $budget,
126 'url' => $url,
127 'round' => $round,
128 ),
129 $passthrough
130 );
131
132 if ( ! in_array( $measure, array( 'auto', 'never', 'always' ), true ) ) {
133 return new \WP_Error(
134 'xspeed_optimize_measure_score',
135 __( 'measure_score must be auto, never or always.', 'xspeed' ),
136 array( 'status' => 400 )
137 );
138 }
139
140 if ( ! in_array( $aggressiveness, array( Optimize_Plan::TIER_SAFE, Optimize_Plan::TIER_STANDARD, Optimize_Plan::TIER_AGGRESSIVE ), true ) ) {
141 return new \WP_Error(
142 'xspeed_optimize_aggressiveness',
143 __( 'Aggressiveness must be safe, standard or aggressive.', 'xspeed' ),
144 array( 'status' => 400 )
145 );
146 }
147
148 // --- 1. Diagnose ------------------------------------------------
149 $current = self::current_settings();
150 $plan = Optimize_Plan::build( $current, $aggressiveness );
151
152 if ( $dry_run ) {
153 // A dry run changes nothing, so it has nothing to prove with a
154 // fresh number — and starting a PSI run for a preview is the
155 // surprise spend the cooldown exists to prevent. `always` is still
156 // honoured: someone explicitly asking to measure gets a
157 // measurement.
158 $preview = Optimize_Diagnosis::build( $current, self::score_for( 'always' === $measure ? 'always' : 'never' ) );
159 $out = array(
160 'dry_run' => true,
161 'message' => self::summary( $preview, 0, count( $plan['steps'] ) ),
162 'score' => $preview['score'],
163 'plan' => array_map(
164 static function ( $s ) {
165 return array(
166 'id' => $s['id'],
167 'change' => $s['label'],
168 'tier' => $s['tier'],
169 );
170 },
171 $plan['steps']
172 ),
173 'skipped' => $plan['skipped'],
174 'next_steps' => $preview['agent_fixable'],
175 'unfixable' => $preview['human_fixable'],
176 );
177
178 // A preview asked to reach a target owes the same explanation a
179 // real run gives. The tool description tells an assistant to read
180 // `stopped_because` and relay the reason, so leaving it off a
181 // preview meant the assistant either said nothing about the target
182 // or invented a reason. The real-run path sets this below; a dry
183 // run returns before reaching it. (QA #306, issue 3)
184 if ( isset( $context['target_score'] ) ) {
185 $out['stopped_because'] = __( 'A target score was requested, but this was a preview — no changes were applied and the target was not chased.', 'xspeed' );
186 }
187
188 return $out;
189 }
190
191 // Nothing to do is a real answer, and a common one on a site that has
192 // already been tuned. Returning early avoids spending two benchmarks
193 // to prove we changed nothing.
194 if ( array() === $plan['steps'] ) {
195 // The case that most needs a diagnosis attached. "Nothing to do"
196 // on a site scoring 50 is not an answer — it is the start of the
197 // conversation about what is actually wrong and who can fix it.
198 // The score carries this answer entirely. "Nothing to do" is only
199 // useful next to a number that is true NOW — beside a stale one it
200 // is indistinguishable from "we looked a fortnight ago".
201 $diagnosis = Optimize_Diagnosis::build( $current, self::score_for( $measure ) );
202
203 // This path fires the seam too. It is the case a multi-round tuner
204 // most needs to see: nothing left at THIS tier, with a score that
205 // says whether the target was reached. Returning early without
206 // filtering would hide the "already tuned and still short" state —
207 // the one where a listener has to decide between escalating the
208 // tier and stopping honestly.
209 return self::filter_report(
210 array(
211 'before' => null,
212 'applied' => array(),
213 'skipped' => $plan['skipped'],
214 'reverted' => array(),
215 'after' => null,
216 'score' => $diagnosis['score'],
217 'next_steps' => $diagnosis['agent_fixable'],
218 'unfixable' => $diagnosis['human_fixable'],
219 'verified' => true,
220 'message' => self::summary( $diagnosis, 0 ),
221 'rounds' => $round,
222 ),
223 $context
224 );
225 }
226
227 // --- 2. Measure -------------------------------------------------
228 $before = self::measure();
229 $baseline = Optimize_Verifier::sample( $url );
230 if ( is_wp_error( $baseline ) ) {
231 // No baseline means no way to tell a broken page from a working
232 // one. Refusing to start is the only safe option — running blind
233 // is exactly what this feature exists to stop.
234 return new \WP_Error(
235 'xspeed_optimize_no_baseline',
236 sprintf(
237 /* translators: %s: the underlying error */
238 __( 'Could not load the site to take a baseline, so no changes were made: %s', 'xspeed' ),
239 $baseline->get_error_message()
240 ),
241 array( 'status' => 502 )
242 );
243 }
244
245 // --- 3-4. Apply + verify ---------------------------------------
246 $result = Optimizer::run(
247 $plan['steps'],
248 $baseline,
249 array(
250 'apply' => static function ( array $step ): ?string {
251 return self::write( (string) $step['module'], (array) $step['values'] );
252 },
253 'revert' => static function ( array $step, array $previous ): void {
254 self::write( (string) $step['module'], $previous );
255 },
256 'purge' => static function (): void {
257 Cache::purge_all( 'optimize run' );
258 },
259 'sample' => static function () use ( $url ) {
260 $s = Optimize_Verifier::sample( $url );
261 return is_wp_error( $s ) ? null : $s;
262 },
263 // The per-step timing probe: one uncached render, its
264 // wall-clock in ms. Uses the same fetch as the integrity
265 // sample so both measure the same thing; the Optimizer
266 // medians PERF_SAMPLES of these per state and reverts a
267 // step that regresses past the noise floor. (#310)
268 'time' => static function () use ( $url ) {
269 $s = Optimize_Verifier::sample( $url );
270 if ( is_wp_error( $s ) || ! isset( $s['elapsed_ms'] ) ) {
271 return null;
272 }
273 return (float) $s['elapsed_ms'];
274 },
275 ),
276 $budget
277 );
278
279 // --- 5. Re-measure ----------------------------------------------
280 $after = self::measure();
281
282 // Re-read settings: what is still off AFTER this run is what an
283 // aggressive run could try next, and offering a step we just applied
284 // would be nonsense.
285 // Changes landed, so the stored score now describes a site that no
286 // longer exists. This is the one path where staleness is guaranteed
287 // rather than likely, so the age check is skipped instead of asking
288 // whether six hours have passed.
289 //
290 // The COOLDOWN still applies, though — which is what the previous
291 // form got wrong. It passed `'always'`, and `'always'` means $force,
292 // which skips the cooldown as well: so every run that applied
293 // anything spent a measurement, on the default `auto`, ignoring the
294 // rate limit the manual promises. The nothing-to-do path was the only
295 // one actually held back, i.e. the case that matters least.
296 // A run inside the cooldown now falls back to the stored number with
297 // its age attached rather than billing the quota. (#306 QA issue 1)
298 $diagnosis = Optimize_Diagnosis::build(
299 self::current_settings(),
300 self::score_for( $measure, true )
301 );
302
303 $report = array(
304 'before' => $before,
305 'applied' => $result['applied'],
306 'skipped' => array_merge( $plan['skipped'], $result['skipped'] ),
307 'reverted' => $result['reverted'],
308 'after' => $after,
309 'score' => $diagnosis['score'],
310 'next_steps' => $diagnosis['agent_fixable'],
311 'unfixable' => $diagnosis['human_fixable'],
312 'verified' => array() === $result['reverted'],
313 'message' => self::summary( $diagnosis, count( $result['applied'] ) ),
314 'rounds' => $round,
315 );
316
317 // What `verified` does NOT cover, said out loud, next to the field it
318 // qualifies. Optimize_Verifier reads HTML in PHP: it catches a fatal, a
319 // truncated document, a stylesheet that vanished. It cannot execute
320 // JavaScript, so a page can arrive structurally perfect and still be
321 // broken in a browser — removing jQuery Migrate did exactly that, with
322 // intact HTML and `e.indexOf is not a function` at runtime.
323 //
324 // The caller usually CAN look: an AI assistant driving this has a
325 // browser, and a person has one by definition. Nothing was asking them
326 // to. These two fields are the ask, and they are only attached when a
327 // run actually changed something — a pass that applied nothing has
328 // nothing new to check.
329 if ( array() !== $result['applied'] ) {
330 $report['verify_urls'] = self::verify_urls( $url );
331 $report['verify_note'] = __( 'Changes were applied and the HTML checks passed, which does not prove the page works in a browser — those checks cannot run JavaScript. Open these URLs and confirm each renders correctly with no console errors. If you cannot open them, tell the user to check them and what a problem would look like.', 'xspeed' );
332 }
333
334 return self::filter_report( $report, $context );
335 }
336
337 /**
338 * Fire the report seam.
339 *
340 * Both exit paths that produce a report go through here — the applied-run
341 * one and the nothing-to-do one — so a listener cannot silently miss half
342 * the outcomes.
343 *
344 * @param array<string,mixed> $report The finished report.
345 * @param array<string,mixed> $context Seam context — see the filter docblock below.
346 * @return array<string,mixed>
347 */
348 private static function filter_report( array $report, array $context ): array {
349 /**
350 * Filter the finished report, allowing a listener to run further rounds.
351 *
352 * The seam exists because one pass cannot chase a target score. This
353 * pass applies every step the tier allows and stops; whether that got
354 * anywhere near 90 is unknown until it is measured, and acting on the
355 * answer means planning again against the metric that is actually
356 * weak. That loop is Pro's (FEATURES.md, Score card #9) — but it must
357 * not require forking this file, so it hooks here.
358 *
359 * A listener is expected to RE-ENTER this method for each additional
360 * round and merge the results, rather than reimplementing the apply
361 * loop. Everything that makes a round safe — baseline sampling,
362 * one-step-at-a-time application, revert-on-breakage, the budget
363 * check — lives in Optimizer::run() and is not worth a second
364 * implementation that can drift out of agreement with this one.
365 *
366 * Re-entrancy is the listener's problem to handle: unhook before
367 * recursing, or guard on `$context['round']`, or this filter fires
368 * again inside its own callback and recurses without end.
369 *
370 * Whatever a listener returns is what callers see, so it must keep the
371 * report's shape and its honesty — `unfixable` in particular is the
372 * part that stops a tuner claiming a win it did not make.
373 *
374 * @since 1.2.0
375 *
376 * @param array<string,mixed> $report The finished report.
377 * @param array<string,mixed> $context {
378 * What a further round needs to plan and run.
379 *
380 * @type string $aggressiveness Tier this run used.
381 * @type string $measure_score The caller's measure_score.
382 * @type int $budget_seconds Per-round time budget.
383 * @type string $url URL sampled for verification.
384 * @type int $round 1-based index of the round just finished.
385 * @type int $target_score Optional. Score the caller asked to reach.
386 * Free does not act on it; it is carried
387 * here so a listener can. Absent unless the
388 * caller sent it.
389 * @type int $max_rounds Optional. Ceiling the caller asked for,
390 * same arrangement.
391 * }
392 */
393 $filtered = apply_filters(
394 'xspeed_optimize_report',
395 $report,
396 $context
397 );
398
399 // A listener that returns a non-array — or nothing, the easy mistake in
400 // a filter callback written as an action — must not turn a completed
401 // run into a fatal downstream. Fall back to the unfiltered report.
402 //
403 // An EMPTY array is the same mistake wearing a different hat: it is
404 // technically an array, so it passed this guard and blanked the whole
405 // report, losing what was applied on a run that genuinely changed the
406 // site. A listener with nothing to add returns the report it was
407 // given; one that returns nothing at all has failed, and the honest
408 // answer is our own report rather than silence. (#306 QA, minor 1)
409 $out = ( is_array( $filtered ) && array() !== $filtered ) ? $filtered : $report;
410
411 // The caller asked to reach a score and nothing chased it: no listener
412 // is installed, so this was a single pass. Say so. The tool
413 // description tells an assistant to read `stopped_because` and relay
414 // it, and nothing ever set it — leaving the assistant to either stay
415 // silent about the target or invent a reason (#306 review, issue 2).
416 // Only filled when still absent, so a tuner's own reason always wins.
417 if ( isset( $context['target_score'] ) && ! isset( $out['stopped_because'] ) ) {
418 $out['stopped_because'] = __( 'A target score was requested, but no iterative tuner is installed, so this was a single pass and the target was not chased.', 'xspeed' );
419 }
420
421 return $out;
422 }
423
424 /**
425 * A page exercising a third template — the shop where WooCommerce is
426 * active, otherwise the blog/posts archive, otherwise a category archive.
427 *
428 * Returns '' when none resolves; verify_urls() drops empties.
429 */
430 private static function third_template_url(): string {
431 if ( function_exists( 'wc_get_page_id' ) ) {
432 $shop = wc_get_page_id( 'shop' );
433 if ( $shop > 0 ) {
434 $link = get_permalink( (int) $shop );
435 if ( is_string( $link ) && '' !== $link ) {
436 return $link;
437 }
438 }
439 }
440
441 $posts_page = (int) get_option( 'page_for_posts' );
442 if ( $posts_page > 0 ) {
443 $link = get_permalink( $posts_page );
444 if ( is_string( $link ) && '' !== $link ) {
445 return $link;
446 }
447 }
448
449 $terms = get_terms(
450 array(
451 'taxonomy' => 'category',
452 'number' => 1,
453 'orderby' => 'count',
454 'order' => 'DESC',
455 'hide_empty' => true,
456 )
457 );
458 if ( is_array( $terms ) && ! empty( $terms[0] ) && ! is_wp_error( $terms[0] ) ) {
459 $link = get_term_link( $terms[0] );
460 if ( is_string( $link ) && '' !== $link ) {
461 return $link;
462 }
463 }
464
465 return '';
466 }
467
468 /**
469 * A short list of pages worth looking at after changes land.
470 *
471 * Verification sampled ONE url — the home page — because that is enough to
472 * catch a fatal. It is not enough to catch a broken template: combining CSS
473 * or deferring JS can leave the front page perfect and wreck a single post,
474 * an archive, or a shop page, because those load handles the home page
475 * never enqueued.
476 *
477 * So this returns the sampled page plus a couple of pages that exercise
478 * DIFFERENT templates. Kept to three: a list long enough to feel like
479 * homework gets skipped, and the point is that someone actually looks.
480 *
481 * @param string $sampled The URL verification already sampled.
482 * @return array<int,string>
483 */
484 private static function verify_urls( string $sampled ): array {
485 $urls = array( $sampled );
486
487 // A single post exercises the post template and its assets, which is
488 // where combine/defer breakage usually shows first.
489 $posts = get_posts(
490 array(
491 'numberposts' => 1,
492 'post_status' => 'publish',
493 'suppress_filters' => false,
494 'fields' => 'ids',
495 )
496 );
497 if ( ! empty( $posts ) ) {
498 $link = get_permalink( (int) $posts[0] );
499 if ( is_string( $link ) && '' !== $link ) {
500 $urls[] = $link;
501 }
502 }
503
504 // A third template, and the one most likely to break differently: a
505 // shop page loads WooCommerce's own handles, an archive loads the
506 // theme's list template. Without this the list was always exactly two
507 // — the docblock above promised a spread and named shop pages
508 // specifically, and a WooCommerce site never saw one (#306 review,
509 // issue 4).
510 $urls[] = self::third_template_url();
511
512 // Cap OUR OWN suggestions before filtering, not the filtered result.
513 // The cap ran last, so on a site where three defaults already resolve
514 // — any WooCommerce site — everything a filter added landed past the
515 // limit and was silently dropped. The documented example is a shop's
516 // checkout page: the template most likely to break when scripts are
517 // combined, and the one the cap threw away. (#306 QA issue 4)
518 //
519 // Cleaned BEFORE capping, so the limit counts real, distinct pages.
520 // third_template_url() returns '' when nothing resolves, and the
521 // sampled URL can equal the shop or posts page — capping the raw list
522 // let a placeholder or a duplicate burn a slot that nothing refills.
523 $urls = array_slice( array_values( array_unique( array_filter( $urls ) ) ), 0, self::VERIFY_URL_LIMIT );
524
525 /**
526 * Filter the pages an optimize run asks the caller to check.
527 *
528 * A site whose risky template is a checkout, a login, or a builder
529 * landing page knows that better than this does.
530 *
531 * Receives our suggestions already capped, and whatever it returns is
532 * what the caller is asked to check — the filter is the site owner's
533 * final say, so it is not re-capped afterwards. Return a short list:
534 * the point is pages someone will actually open.
535 *
536 * @since 1.2.0
537 *
538 * @param array<int,string> $urls Suggested URLs, already capped.
539 * @param string $sampled The URL verification sampled.
540 */
541 $urls = apply_filters( 'xspeed_optimize_verify_urls', $urls, $sampled );
542
543 $clean = array();
544 foreach ( (array) $urls as $u ) {
545 $u = esc_url_raw( (string) $u );
546 if ( '' !== $u && ! in_array( $u, $clean, true ) ) {
547 $clean[] = $u;
548 }
549 }
550
551 return $clean;
552 }
553
554 /**
555 * The score to diagnose against, honouring the caller's measure_score.
556 *
557 * `never` is the pre-1.2.0 behaviour and stays available for callers that
558 * genuinely must not spend a measurement. `auto` measures only when the
559 * stored score is stale AND the cooldown allows it, which is what makes a
560 * post-run score mean what a reader assumes it means.
561 *
562 * @param string $measure One of auto|never|always.
563 * @param bool $assume_stale Treat the stored score as stale regardless
564 * of its age — the post-apply path, where
565 * changes just landed. Does NOT spend the
566 * cooldown: only an explicit `always` does
567 * that. (#306 QA issue 1)
568 * @return array<string,mixed>|null
569 */
570 private static function score_for( string $measure, bool $assume_stale = false ): ?array {
571 if ( 'never' === $measure ) {
572 return Optimize_Diagnosis::latest_score();
573 }
574 return Optimize_Diagnosis::measure_fresh( 'always' === $measure, $assume_stale );
575 }
576
577 /**
578 * One sentence the assistant can lead with.
579 *
580 * Written so the honest outcomes read as outcomes rather than failures. A
581 * site where nothing was left to do is not a disappointing result, but
582 * "no changes" with no context reads like one — and an assistant given
583 * that alone will either apologise or invent a win.
584 *
585 * @param array<string,mixed> $diagnosis From Optimize_Diagnosis::build().
586 * @param int $applied How many changes landed.
587 * @param int $planned How many changes are WAITING —
588 * non-zero only on a preview, where
589 * nothing is applied by definition.
590 */
591 private static function summary( array $diagnosis, int $applied, int $planned = 0 ): string {
592 $score = $diagnosis['score']['score'] ?? null;
593 $next = count( $diagnosis['agent_fixable'] );
594 $human = count( $diagnosis['human_fixable'] );
595
596 $parts = array();
597
598 if ( $applied > 0 ) {
599 $parts[] = sprintf(
600 /* translators: %d: number of settings changed */
601 _n( 'Applied %d change.', 'Applied %d changes.', $applied, 'xspeed' ),
602 $applied
603 );
604 } elseif ( $planned > 0 ) {
605 // A preview applies nothing BY DEFINITION, so "nothing was
606 // applied" must not be read as "nothing needed applying". This
607 // sentence is what an assistant relays to the owner, and it
608 // previously opened with "everything is already on" directly above
609 // a list of ten changes it would make — so the assistant reported
610 // a fully optimised site while the work sat waiting.
611 // (#306 QA issue 3)
612 $parts[] = sprintf(
613 /* translators: %d: number of changes a real run would make */
614 _n(
615 'Preview only — %d change would be applied.',
616 'Preview only — %d changes would be applied.',
617 $planned,
618 'xspeed'
619 ),
620 $planned
621 );
622 } else {
623 $parts[] = __( 'Everything that can be turned on safely is already on.', 'xspeed' );
624 }
625
626 if ( null !== $score ) {
627 // The age is not a footnote. This sentence is what an assistant
628 // reads back to the user, and "score: 77" after a run that just
629 // finished says the run produced it. Naming when it was measured
630 // is the difference between a report and a claim.
631 $age = $diagnosis['score']['age_seconds'] ?? null;
632
633 if ( null === $age ) {
634 $when = __( 'date unknown', 'xspeed' );
635 } elseif ( $age < 5 * MINUTE_IN_SECONDS ) {
636 $when = __( 'measured just now', 'xspeed' );
637 } else {
638 $when = sprintf(
639 /* translators: %s: human-readable duration, e.g. "2 hours" */
640 __( 'measured %s ago', 'xspeed' ),
641 human_time_diff( time() - $age, time() )
642 );
643 }
644
645 $parts[] = sprintf(
646 /* translators: 1: performance score, 2: when it was measured */
647 __( 'Score: %1$d (%2$s).', 'xspeed' ),
648 (int) $score,
649 $when
650 );
651 }
652
653 if ( $next > 0 ) {
654 $parts[] = sprintf(
655 /* translators: %d: number of riskier settings available */
656 _n(
657 '%d further setting could help, but can break some sites — ask before enabling it.',
658 '%d further settings could help, but can break some sites — ask before enabling them.',
659 $next,
660 'xspeed'
661 ),
662 $next
663 );
664 }
665
666 if ( $human > 0 ) {
667 $parts[] = sprintf(
668 /* translators: %d: number of problems only the user can fix */
669 _n(
670 '%d problem is outside what caching can reach.',
671 '%d problems are outside what caching can reach.',
672 $human,
673 'xspeed'
674 ),
675 $human
676 );
677 }
678
679 return implode( ' ', $parts );
680 }
681
682 /**
683 * Current settings for every module the plan can touch.
684 *
685 * The `__global` bucket is not a module: it carries options that live
686 * outside the per-module schema, page caching being the one that matters
687 * here. Reading it the same shape as a module keeps Optimize_Plan free of
688 * special cases.
689 *
690 * @return array<string,array<string,mixed>>
691 */
692 private static function current_settings(): array {
693 $out = array();
694 foreach ( array( 'gzip', 'browser-cache', 'minify', 'lazy', 'bloat' ) as $slug ) {
695 $out[ $slug ] = Settings_Manager::get( $slug );
696 }
697
698 $global = Settings::get();
699 $out[ Optimize_Plan::MODULE_GLOBAL ] = array(
700 'cache_enabled' => (bool) ( $global['cache_enabled'] ?? false ),
701 );
702
703 return $out;
704 }
705
706 /**
707 * Write one step's values to wherever they actually live.
708 *
709 * Page caching is not a module setting — it installs the advanced-cache
710 * drop-in and sets WP_CACHE, then records a global flag. Routing it
711 * through Settings_Manager::update() writes a key no schema declares,
712 * which is dropped silently while the call still reports success (#206):
713 * the run then claims "page caching on" over a site that never enabled it.
714 * That exact false success showed up on the first live run of this
715 * feature, which is why the dispatch is explicit rather than uniform.
716 *
717 * @param string $module Module slug, or MODULE_GLOBAL.
718 * @param array<string,mixed> $values Values to write.
719 * @return string|null Reason the write was refused, or null when it landed.
720 */
721 private static function write( string $module, array $values ): ?string {
722 if ( Optimize_Plan::MODULE_GLOBAL !== $module ) {
723 Settings_Manager::update( $module, $values );
724 return null;
725 }
726
727 if ( array_key_exists( 'cache_enabled', $values ) ) {
728 $enabled = (bool) $values['cache_enabled'];
729 // Order matters: the drop-in + wp-config first, the flag second,
730 // so a failure to install never leaves the option claiming a
731 // cache that is not wired up. Persist what toggle() achieved, not
732 // what was asked for — it refuses when another plugin owns the
733 // drop-in, and the flag must follow the refusal.
734 $state = Cache::toggle( $enabled );
735 // And REPORT the refusal. Swallowing it here is what let the run
736 // return "Turn on page caching · verified" for a site where the
737 // drop-in belonged to another plugin and nothing had been
738 // changed. The Optimizer turns a returned reason into a skipped
739 // step.
740 //
741 // `blocked` alone is the test. It used to also require the
742 // operational state to differ from what was asked, and `enabled`
743 // answers "is the cache serving", not "did the write land" — so a
744 // refused step whose outcome happened to match was recorded as
745 // verified with nothing persisted behind it.
746 if ( ! empty( $state['blocked'] ) ) {
747 return is_string( $state['blocked_reason'] ) && '' !== $state['blocked_reason']
748 ? $state['blocked_reason']
749 : __( 'xSpeed would not change the page cache on this site.', 'xspeed' );
750 }
751 }
752
753 return null;
754 }
755
756 /**
757 * A cached-vs-uncached benchmark, reduced to the numbers a report needs.
758 *
759 * Deliberately NOT a Lighthouse score: this runs on the site itself, and
760 * spending someone's PageSpeed quota twice per optimize run is not ours to
761 * do. The caller can run a speed test either side if it wants one.
762 *
763 * @return array<string,mixed>|null
764 */
765 private static function measure(): ?array {
766 if ( ! class_exists( '\XSpeed\Cache_Benchmark' ) ) {
767 return null;
768 }
769 $run = Cache_Benchmark::run();
770 if ( ! is_array( $run ) ) {
771 return null;
772 }
773 return array(
774 'savings_ms' => $run['savings_ms'] ?? null,
775 'savings_pct' => $run['savings_pct'] ?? null,
776 'cache_enabled' => $run['cache_enabled'] ?? null,
777 );
778 }
779
780 /**
781 * Problems this tool cannot solve, named plainly.
782 *
783 * Derived from the site's own health checks rather than invented here, so
784 * the list stays true as those checks improve. Anything a caching plugin
785 * genuinely cannot reach — page weight, hotlinked media, DOM size — belongs
786 * here rather than being silently omitted from a success report.
787 *
788 * @return array<int,array<string,string>>
789 */
790 private static function unfixable(): array {
791 $out = array();
792
793 if ( ! class_exists( '\XSpeed\Health' ) ) {
794 return $out;
795 }
796
797 foreach ( Health::checks() as $check ) {
798 if ( 'warn' !== ( $check['tone'] ?? '' ) && 'fail' !== ( $check['tone'] ?? '' ) ) {
799 continue;
800 }
801 // Environment facts the plugin reports but cannot change itself:
802 // a PHP version, a server config snippet the host must paste.
803 $id = (string) ( $check['id'] ?? '' );
804 if ( in_array( $id, array( 'php_version', 'server', 'static_rewrite_nginx' ), true ) ) {
805 $out[] = array(
806 'issue' => (string) ( $check['label'] ?? $id ),
807 'fix' => (string) ( $check['detail'] ?? '' ),
808 );
809 }
810 }
811
812 return $out;
813 }
814 }
815