PluginProbe
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot / 4.9.1
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot v4.9.1
4.9.1 4.9.0 4.8.2 4.8.1 4.8.0 4.7.0 4.6.2 4.6.1 4.6.0 4.5.6 4.5.5 4.5.4 4.5.3 4.5.2 4.5.1 4.5.0 4.4.1 4.4.0 3.3.4 3.4.0 3.4.1 3.4.2 3.5.0 3.5.1 3.5.2 All 199 releases
betterdocs / includes / Abilities / Runtime.php

Runtime.php in BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot 4.9.1, at includes/Abilities/Runtime.php

358 lines 11.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Ownership of the WordPress Abilities API runtime.
4 *
5 * @package BetterDocs
6 * @since 4.9.0
7 */
8
9 namespace WPDeveloper\BetterDocs\Abilities;
10
11 if ( ! defined( 'ABSPATH' ) ) {
12 exit; // Exit if accessed directly.
13 }
14
15 /**
16 * Decides who owns the Abilities API in the current request and, when WordPress
17 * core owns it, makes BetterDocs' bundled copy inert.
18 *
19 * WordPress ships the Abilities API in core from 6.9 (`wp_register_ability()` is
20 * `@since 6.9.0`). BetterDocs still bundles a copy under `dependencies/` because
21 * the plugin supports WordPress 6.4, where core has no such API — so the bundle
22 * owns the API on 6.4 to 6.8, and core owns it from 6.9 on. Nothing here compares
23 * versions: `owner()` reflects on where `wp_get_abilities()` is actually defined,
24 * which is why this paragraph falling out of date could never change behaviour. The bundled package guards its functions and registry classes with
25 * `function_exists()` / `class_exists()`, so on 7.1 core wins those — but its
26 * `includes/bootstrap.php` also loads two classes core does not define under the
27 * same names (`WP_REST_Abilities_Init`, `WP_Abilities_Assets_Init`) and hooks them
28 * unconditionally. Left alone on 7.1 that means a second handler set on every
29 * `/wp-abilities/v1/*` route and a 137 KB script on every wp-admin screen.
30 *
31 * @since 4.9.0
32 */
33 final class Runtime {
34
35 /**
36 * Option remembering which owner the debug line was last written for.
37 *
38 * Only ever read or written while `WP_DEBUG` is on. Not a health-report source
39 * — call `owner()` for that; this exists solely to keep the log quiet.
40 *
41 * @since 4.9.0
42 *
43 * @var string
44 */
45 const LOGGED_OWNER_OPTION = 'betterdocs_mcp_runtime_logged_owner';
46
47 /**
48 * Memoised result of `owner()` for this request.
49 *
50 * @since 4.9.0
51 *
52 * @var array|null
53 */
54 private static $owner = null;
55
56 /**
57 * Whether the bundled hooks have already been removed once.
58 *
59 * Guards the debug log line, not the removals themselves — `stand_down()` is
60 * idempotent and is deliberately run more than once per request.
61 *
62 * @since 4.9.0
63 *
64 * @var bool
65 */
66 private static $stood_down = false;
67
68 /**
69 * Wires the stand-down. Called from `betterdocs.php` right after the bundled
70 * runtime is required.
71 *
72 * Runs once immediately — the bundle hooks itself while `autoload_packages.php`
73 * is being required, so the hooks already exist by the time this is reached —
74 * and once more very late on `plugins_loaded`, which catches a copy of the same
75 * package carried by a plugin that loads after BetterDocs. Both passes finish
76 * before `init` and `rest_api_init` fire.
77 *
78 * @since 4.9.0
79 *
80 * @return void
81 */
82 public static function init(): void {
83 self::stand_down();
84
85 add_action( 'plugins_loaded', [ self::class, 'stand_down' ], 9999 );
86 }
87
88 /**
89 * Reports which copy of the Abilities API is loaded in this request.
90 *
91 * `source` is one of:
92 * - `core` — WordPress core's own copy, under `ABSPATH . WPINC`.
93 * - `bundled` — the copy shipped by this plugin, under `dependencies/vendor/`.
94 * - `foreign` — a copy owned by something else (another plugin, mu-plugin, a
95 * Composer install elsewhere). Ours lost the Jetpack Autoloader's
96 * newest-version vote, or was never in the running.
97 * - `none` — the API is not loaded at all.
98 *
99 * Memoised: nothing can redeclare a PHP function mid-request, so the answer
100 * cannot change once taken.
101 *
102 * @since 4.9.0
103 *
104 * @return array {
105 * @type string $source One of `core`, `bundled`, `foreign`, `none`.
106 * @type string $path Normalised file that declares `wp_get_abilities`, or ''.
107 * @type string|null $version Bundled package version when `source` is `bundled`, else null.
108 * }
109 */
110 public static function owner(): array {
111 if ( null !== self::$owner ) {
112 return self::$owner;
113 }
114
115 if ( ! function_exists( 'wp_get_abilities' ) ) {
116 self::$owner = [
117 'source' => 'none',
118 'path' => '',
119 'version' => null
120 ];
121
122 return self::$owner;
123 }
124
125 $path = '';
126
127 try {
128 $reflection = new \ReflectionFunction( 'wp_get_abilities' );
129 $file = $reflection->getFileName();
130 $path = is_string( $file ) ? wp_normalize_path( $file ) : '';
131 } catch ( \ReflectionException $e ) {
132 $path = '';
133 }
134
135 $source = 'foreign';
136
137 if ( '' !== $path ) {
138 if ( 0 === strpos( $path, trailingslashit( wp_normalize_path( ABSPATH . WPINC ) ) ) ) {
139 $source = 'core';
140 } elseif ( self::is_bundled_path( $path ) ) {
141 $source = 'bundled';
142 }
143 }
144
145 self::$owner = [
146 'source' => $source,
147 'path' => $path,
148 'version' => 'bundled' === $source ? self::bundled_version() : null
149 ];
150
151 return self::$owner;
152 }
153
154 /**
155 * Whether WordPress core provides the Abilities API in this request.
156 *
157 * Derived from `owner()` rather than from a bare `function_exists()` captured
158 * before the bundle is required. Core loads `wp-includes/abilities-api.php`
159 * from `wp-settings.php`, before any plugin file runs, so when core has the API
160 * the bundle's own `function_exists()` guard never fires and the declaring file
161 * is always core's. That makes `owner()` correct whether it is consulted before
162 * or after the require, and leaves `betterdocs.php` with nothing to capture.
163 *
164 * @since 4.9.0
165 *
166 * @return bool
167 */
168 public static function core_provides_api(): bool {
169 $owner = self::owner();
170
171 return 'core' === $owner['source'];
172 }
173
174 /**
175 * Removes the bundled package's hooks when core owns the Abilities API.
176 *
177 * Only `source === 'core'` triggers this, so on WordPress 6.4–6.8 — where the
178 * bundle *is* the Abilities API and those hooks are the only thing registering
179 * the REST routes and the client script — nothing is removed, by construction.
180 *
181 * The core-abilities registration hooks (`wp_abilities_api_categories_init`,
182 * `wp_abilities_api_init`) are deliberately left alone: the bundle only adds
183 * them when `wp_register_core_abilities()` is undefined, which never happens
184 * when core owns the API, and they are keyed by function name so core's own
185 * registration is not duplicated either way.
186 *
187 * Idempotent — safe to call on every pass.
188 *
189 * @since 4.9.0
190 *
191 * @return void
192 */
193 public static function stand_down(): void {
194 $owner = self::owner();
195
196 if ( 'core' !== $owner['source'] ) {
197 return;
198 }
199
200 $removed = false;
201
202 // bootstrap.php L59-66 — re-registers the six /wp-abilities/v1/* routes that
203 // core already registers from create_initial_rest_routes() at priority 99.
204 if ( class_exists( 'WP_REST_Abilities_Init', false ) ) {
205 if ( remove_action( 'rest_api_init', [ 'WP_REST_Abilities_Init', 'register_routes' ], 11 ) ) {
206 $removed = true;
207 }
208 }
209
210 // bootstrap.php L69-77 — registers the `wp-abilities` script handle and
211 // enqueues it on every wp-admin screen. Core ships a script module instead.
212 if ( class_exists( 'WP_Abilities_Assets_Init', false ) ) {
213 if ( remove_action( 'init', [ 'WP_Abilities_Assets_Init', 'register_assets' ], 10 ) ) {
214 $removed = true;
215 }
216
217 if ( remove_action( 'admin_enqueue_scripts', [ 'WP_Abilities_Assets_Init', 'admin_enqueue_scripts' ], 10 ) ) {
218 $removed = true;
219 }
220 }
221
222 if ( $removed && ! self::$stood_down ) {
223 self::$stood_down = true;
224
225 self::log_owner_change(
226 'Abilities API owned by WordPress core (' . $owner['path'] . '); bundled runtime stood down.',
227 $owner['source']
228 );
229 }
230 }
231
232 /**
233 * Whether a normalised path sits inside this plugin's bundled runtime.
234 *
235 * Both sides are compared raw and through `realpath()`, because a plugin
236 * directory is often a symlink in development and the two ends of the
237 * comparison do not always resolve it the same way.
238 *
239 * @since 4.9.0
240 *
241 * @param string $path Normalised absolute path to test.
242 * @return bool
243 */
244 private static function is_bundled_path( string $path ): bool {
245 $candidates = [ $path ];
246 $real = realpath( $path );
247
248 if ( is_string( $real ) ) {
249 $candidates[] = wp_normalize_path( $real );
250 }
251
252 foreach ( self::bundle_dirs() as $dir ) {
253 foreach ( $candidates as $candidate ) {
254 if ( 0 === strpos( $candidate, $dir ) ) {
255 return true;
256 }
257 }
258 }
259
260 return false;
261 }
262
263 /**
264 * The bundled runtime's vendor directory, raw and resolved.
265 *
266 * Derived from `BETTERDOCS_PLUGIN_FILE` — defined in `betterdocs.php` before the
267 * runtime is required — and not from `BETTERDOCS_ROOT_DIR_PATH`, which is only
268 * defined later, when `Plugin::define_constants()` runs.
269 *
270 * @since 4.9.0
271 *
272 * @return array List of normalised, trailing-slashed directories.
273 */
274 private static function bundle_dirs(): array {
275 $root = defined( 'BETTERDOCS_PLUGIN_FILE' ) ? dirname( BETTERDOCS_PLUGIN_FILE ) : dirname( __DIR__, 2 );
276 $dir = trailingslashit( wp_normalize_path( $root ) ) . 'dependencies/vendor/';
277 $dirs = [ $dir ];
278 $real = realpath( $dir );
279
280 if ( is_string( $real ) ) {
281 $dirs[] = trailingslashit( wp_normalize_path( $real ) );
282 }
283
284 return array_unique( $dirs );
285 }
286
287 /**
288 * Version of the bundled Abilities API package, read from the runtime's own
289 * `composer/installed.json`.
290 *
291 * @since 4.9.0
292 *
293 * @return string|null Package version, or null when it cannot be determined.
294 */
295 private static function bundled_version() {
296 foreach ( self::bundle_dirs() as $dir ) {
297 $file = $dir . 'composer/installed.json';
298
299 if ( ! is_readable( $file ) ) {
300 continue;
301 }
302
303 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- reading a bundled build artefact on disk, not a remote resource.
304 $raw = file_get_contents( $file );
305
306 if ( ! is_string( $raw ) ) {
307 continue;
308 }
309
310 $data = json_decode( $raw, true );
311
312 if ( ! is_array( $data ) || empty( $data['packages'] ) || ! is_array( $data['packages'] ) ) {
313 continue;
314 }
315
316 foreach ( $data['packages'] as $package ) {
317 if ( isset( $package['name'], $package['version'] ) && 'wordpress/abilities-api' === $package['name'] ) {
318 return (string) $package['version'];
319 }
320 }
321 }
322
323 return null;
324 }
325
326 /**
327 * Writes one debug-only diagnostic line, and only when the owner has changed
328 * since the last one was written.
329 *
330 * Standing down is the correct, expected behaviour on WordPress 6.9+, so a line
331 * on every request would be pure noise — a diagnostic log is for anomalies, not
332 * the happy path. What is worth a line is the *change*:
333 * the first stand-down after a WordPress upgrade, or another plugin taking the
334 * API over. The last-logged owner lives in a non-autoloaded option that is only
335 * touched while `WP_DEBUG` is on, so production pays nothing for it.
336 *
337 * @since 4.9.0
338 *
339 * @param string $message Message to log, without the prefix.
340 * @param string $source Owner reported by `owner()['source']`.
341 * @return void
342 */
343 private static function log_owner_change( string $message, string $source ): void {
344 if ( ! defined( 'WP_DEBUG' ) || ! WP_DEBUG ) {
345 return;
346 }
347
348 if ( get_option( self::LOGGED_OWNER_OPTION ) === $source ) {
349 return;
350 }
351
352 update_option( self::LOGGED_OWNER_OPTION, $source, false );
353
354 // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- WP_DEBUG-gated diagnostic.
355 error_log( '[BD-MCP] ' . $message );
356 }
357 }
358