From 8583f3e3741da04a4516f5a7c8ecd1059da91ea7 Mon Sep 17 00:00:00 2001 From: Chris Portscheller Date: Sat, 26 Sep 2026 10:06:29 -0500 Subject: [PATCH] fix: load AI referrals after its class is included 2.10.0 called WebDecoy_AI_Referrals::register() from init_hooks(), which runs when webdecoy.php is included. The class is required later, by load_includes() on plugins_loaded, so every request failed with a Class not found fatal. Registration now runs on plugins_loaded at priority 20. BootOrderTest reads webdecoy.php and fails if the constructor path calls any class that load_includes() loads. --- tests/BootOrderTest.php | 54 +++++++++++++++++++++++++++++++++++++++++ webdecoy.php | 13 ++++++++-- 2 files changed, 65 insertions(+), 2 deletions(-) create mode 100644 tests/BootOrderTest.php diff --git a/tests/BootOrderTest.php b/tests/BootOrderTest.php new file mode 100644 index 0000000..4ce7458 --- /dev/null +++ b/tests/BootOrderTest.php @@ -0,0 +1,54 @@ + 0; $i++) { + $depth += $src[$i] === '{' ? 1 : ($src[$i] === '}' ? -1 : 0); + } + return substr($src, $m[0][1], $i - $m[0][1]); +} + +$t('construction never uses a class load_includes() loads later', function () use ($true) { + $root = dirname(__DIR__); + $src = (string) file_get_contents($root . '/webdecoy.php'); + + // Classes declared by the files load_includes() requires. + $deferred = []; + preg_match_all("/'(includes\\/[a-z0-9-]+\\.php)'/", webdecoy_boot_method_body($src, 'load_includes'), $files); + foreach ($files[1] as $file) { + if (preg_match('/^\s*(?:final\s+)?class\s+(\w+)/m', (string) file_get_contents($root . '/' . $file), $c) === 1) { + $deferred[] = $c[1]; + } + } + $true(in_array('WebDecoy_AI_Referrals', $deferred, true), 'load_includes() class list was not parsed'); + + foreach (['__construct', 'load_options', 'init_hooks'] as $method) { + $body = webdecoy_boot_method_body($src, $method); + foreach ($deferred as $class) { + $uses = preg_match('/\bnew\s+' . $class . '\b|\b' . $class . '::(?!class\b)/', $body) === 1; + $true(!$uses, "{$method}() uses {$class}, which is not loaded until plugins_loaded"); + } + } +}); diff --git a/webdecoy.php b/webdecoy.php index f2b071c..b532398 100644 --- a/webdecoy.php +++ b/webdecoy.php @@ -976,8 +976,9 @@ private function init_hooks(): void add_action('webdecoy_flush_violations', [$this, 'cron_flush_violations']); // AI referral counting, when connected to WebDecoy Cloud: visits AI - // products send, as aggregate counts for the AI Traffic page. - WebDecoy_AI_Referrals::register((string) ($this->options['api_key'] ?? '')); + // products send, as aggregate counts for the AI Traffic page. Wired on + // plugins_loaded because its class is loaded there by load_includes(). + add_action('plugins_loaded', [$this, 'register_ai_referrals'], 20); // Load text domain @@ -1329,6 +1330,14 @@ private function build_rule_engine(): ?\WebDecoy\Rules\RuleEngine return new \WebDecoy\Rules\RuleEngine($rules); } + /** + * Register AI referral counting (a no-op unless connected to WebDecoy Cloud). + */ + public function register_ai_referrals(): void + { + WebDecoy_AI_Referrals::register((string) ($this->options['api_key'] ?? '')); + } + /** * Register the WordPress-native query/REST traps (author enumeration), * wired to record synthetic tripwire violations.