PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.2
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.2
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 1.1.5 1.1.6 1.1.7 1.1.8 All 29 releases
← All changes | includes/class-pro-audit.php +269 -0 1.2.41.3.2 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,255 @@
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 + return array();
333 + }
334 +
335 + $out = array();
336 + foreach ( $raw as $row ) {
337 + if ( count( $out ) >= self::MAX_CONTRIBUTED ) {
338 + break;
339 + }
340 + $clean = self::normalize_suggestion( $row );
341 + if ( null !== $clean ) {
342 + $out[] = $clean;
343 + }
344 + }
345 + return $out;
346 + }
347 +
348 + /**
349 + * Coerce one contributed entry into the exact shape run() emits, or drop it.
350 + *
351 + * Everything downstream — the dedupe, the severity sort, the React card,
352 + * the MCP tool's response — is written against `{id, severity, reason,
353 + * fact?}` and nothing re-checks it. An unknown severity alone is enough to
354 + * break the panel, which indexes a style map by it. So this is a whitelist,
355 + * not a merge: unrecognised keys are dropped rather than passed through, and
356 + * an entry that can't supply an id and a reason is dropped whole. A missing
357 + * or invalid severity is not fatal, but it settles at 'low' — a contributor
358 + * who won't say how bad it is doesn't get to outrank anyone.
359 + *
360 + * @param mixed $row Whatever the filter returned in this slot.
361 + * @return array{id:string,severity:string,reason:string,fact?:string}|null
362 + */
363 + private static function normalize_suggestion( $row ): ?array {
364 + if ( ! is_array( $row ) ) {
365 + return null;
366 + }
367 +
368 + $id = sanitize_key( self::as_text( $row['id'] ?? null ) );
369 + if ( '' === $id ) {
370 + return null;
371 + }
372 +
373 + $reason = self::as_text( $row['reason'] ?? null );
374 + $reason = trim( (string) sanitize_text_field( $reason ) );
375 + if ( '' === $reason ) {
376 + return null;
377 + }
378 +
379 + $severity = self::as_text( $row['severity'] ?? null );
380 + if ( ! in_array( $severity, array( self::SEVERITY_HIGH, self::SEVERITY_MED, self::SEVERITY_LOW ), true ) ) {
381 + $severity = self::SEVERITY_LOW;
382 + }
383 +
384 + $clean = array(
385 + 'id' => $id,
386 + 'severity' => $severity,
387 + 'reason' => self::clamp( $reason, self::MAX_REASON_LEN ),
388 + );
389 +
390 + $fact = trim( (string) sanitize_text_field( self::as_text( $row['fact'] ?? null ) ) );
391 + if ( '' !== $fact ) {
392 + $clean['fact'] = self::clamp( $fact, self::MAX_FACT_LEN );
393 + }
394 +
395 + return $clean;
396 + }
397 +
398 + /**
399 + * A string, or '' for anything that isn't honestly one.
400 + *
401 + * Booleans are excluded on purpose: `(string) true` is "1", which would
402 + * sail through an is_scalar() check and land a suggestion whose reason
403 + * reads "1".
404 + *
405 + * @param mixed $value Raw value from a contributed entry.
406 + */
407 + private static function as_text( $value ): string {
408 + if ( is_string( $value ) ) {
409 + return $value;
410 + }
411 + if ( is_int( $value ) || is_float( $value ) ) {
412 + return (string) $value;
413 + }
414 + return '';
415 + }
416 +
417 + /**
418 + * Trim to a hard character budget, ellipsis included in the budget.
419 + *
420 + * @param string $text Text to bound.
421 + * @param int $limit Maximum length of the result.
422 + */
423 + private static function clamp( string $text, int $limit ): string {
424 + if ( function_exists( 'mb_strlen' ) && function_exists( 'mb_substr' ) ) {
425 + if ( mb_strlen( $text ) <= $limit ) {
426 + return $text;
427 + }
428 + return rtrim( mb_substr( $text, 0, $limit - 1 ) ) . '…';
429 + }
430 + return self::clamp_without_mbstring( $text, $limit );
431 + }
432 +
433 + /**
434 + * clamp() on a site with no mbstring.
435 + *
436 + * strlen()/substr() count BYTES, and using them here got the budget wrong
437 + * in both directions at once. Too tight: 400 bytes of accented Latin is
438 + * under 200 characters, so a reason well inside its allowance came back
439 + * truncated. And unsafe: a byte cut can land inside a character, and
440 + * wp_json_encode() answers false for the WHOLE response rather than
441 + * mangling one word — the client loses every finding in the audit.
442 + *
443 + * PCRE counts characters in `/u` mode without mbstring, so the budget
444 + * stays a character budget. Both patterns are bounded by `$limit`, so
445 + * neither walks a long string or builds an array of it.
446 + *
447 + * @param string $text Text to bound.
448 + * @param int $limit Maximum length of the result.
449 + */
450 + private static function clamp_without_mbstring( string $text, int $limit ): string {
451 + $limit = max( 1, $limit );
452 +
453 + // preg_match() returns false — not 0 — when the subject is not valid
454 + // UTF-8, which is how the byte fallback below is reached.
455 + $within = preg_match( '/^.{0,' . $limit . '}$/us', $text );
456 + if ( 1 === $within ) {
457 + return $text;
458 + }
459 + if ( 0 === $within && 1 === preg_match( '/^.{0,' . ( $limit - 1 ) . '}/us', $text, $m ) ) {
460 + return rtrim( $m[0] ) . '…';
461 + }
462 +
463 + // Not valid UTF-8 to begin with — a contributor sent bytes we cannot
464 + // count. Bytes are all there is, but the result still has to encode.
465 + if ( strlen( $text ) <= $limit ) {
466 + return $text;
467 + }
468 + return rtrim( self::whole_characters( substr( $text, 0, $limit - 1 ) ) ) . '…';
469 + }
470 +
471 + /**
472 + * Drop a trailing partial UTF-8 character.
473 + *
474 + * A byte cut can land inside a multibyte character and leave a dangling
475 + * fragment. That makes the string invalid UTF-8, and wp_json_encode()
476 + * answers false for the WHOLE response — the client loses every finding,
477 + * not one accented word. At most three bytes come off.
478 + *
479 + * @param string $bytes Byte-cut text.
480 + */
481 + private static function whole_characters( string $bytes ): string {
482 + // `//u` is an empty pattern with the UTF-8 modifier: it matches
483 + // anything, and fails outright when the subject is not valid UTF-8.
484 + while ( '' !== $bytes && 1 !== preg_match( '//u', $bytes ) ) {
485 + $bytes = substr( $bytes, 0, -1 );
486 + }
487 + return $bytes;
488 + }
489 +
490 + /**
227 491 * Pro's own report of its licence state: 'not_installed' | 'unlicensed'
228 492 * | 'active'. Mirrors Admin::pro_state(), which is private to that
229 493 * class; only 'active' means Pro's features are actually running.
230 494 */
@@ -364,8 +628,13 @@
364 628 ),
365 629 'fact' => $enabled . ' modules',
366 630 );
367 631 }
632 +
633 + // Add-on contributions. Collected after the native rules and before
634 + // the fallback: a site whose only real finding comes from an add-on
635 + // should get that finding, not the generic filler underneath it.
636 + $out = array_merge( $out, self::contributed( $out ) );
368 637
369 638 // Fallback — never return an empty audit. Analytics is the
370 639 // safe always-relevant suggestion (every site has cache
371 640 // activity to chart).