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
← All changes | includes/class-pro-audit.php +275 -0 1.2.4 → 1.3.7 View file →
@@ -25,8 +25,12 @@
25 25 *
26 26 * Adding a rule: drop another `if (…) $out[] = …` block in run().
27 27 * Rules are independent — keep them small + concrete + factual.
28 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 + *
29 33 * If the suggestion maps to a single Pro module, add it to
30 34 * BACKING_MODULE and guard the rule with `! self::already_active($id)`.
31 35 * A rule that fires on site state alone keeps nagging a customer who
32 36 * already bought and enabled the feature — and any consumer rendering
@@ -48,8 +52,21 @@
48 52 public const SEVERITY_MED = 'med';
49 53 public const SEVERITY_LOW = 'low';
50 54
51 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 + /**
52 69 * Snapshot of state every rule needs. Computed once per audit run
53 70 * so we don't read the same option 8 times.
54 71 *
55 72 * @param array|null $totals_override Test injection — Brain Monkey
@@ -223,8 +240,261 @@
223 240 return ! empty( $opts['enabled'] );
224 241 }
225 242
226 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 + /**
227 497 * Pro's own report of its licence state: 'not_installed' | 'unlicensed'
228 498 * | 'active'. Mirrors Admin::pro_state(), which is private to that
229 499 * class; only 'active' means Pro's features are actually running.
230 500 */
@@ -364,8 +634,13 @@
364 634 ),
365 635 'fact' => $enabled . ' modules',
366 636 );
367 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 ) );
368 643
369 644 // Fallback — never return an empty audit. Analytics is the
370 645 // safe always-relevant suggestion (every site has cache
371 646 // activity to chart).