optionsResolver()`; the service container key * `options_repository` remains as the internal plumbing handle used by * auth-time callers (PluginAdminAccessPolicy) that cannot route through * PluginLogic without creating a resolution cycle. */ class ABJ_404_Solution_PluginLogicOptionsResolver { /** @var array|null */ private $rawCache = null; /** @var array|null */ private $resolvedSkipDbCheck = null; /** @var array|null */ private $resolvedWithDbCheck = null; /** @var self|null */ private static $instance = null; /** @return self */ public static function getInstance(): self { if (self::$instance !== null) { return self::$instance; } self::$instance = new self(); return self::$instance; } /** Reset cached instance and in-process caches (test seam). @return void */ public static function reset(): void { if (self::$instance !== null) { self::$instance->clearCache(); } self::$instance = null; } /** Clear in-process option caches. @return void */ public function clearCache(): void { $this->rawCache = null; $this->resolvedSkipDbCheck = null; $this->resolvedWithDbCheck = null; } /** * Return only the delegated plugin-admin option needed by the * authorization policy. This intentionally avoids the full getOptions() * read path because capability checks can run while plugin_logic is being * resolved; the full path performs suggestion-template normalization via * PluginLogicSettingsUpdate and would create an auth-time service cycle. * * @return mixed String, array, or default value accepted by the policy normalizer. */ public function getPluginAdminUsersOption() { $optionResult = get_option('abj404_settings'); if (!is_array($optionResult)) { return ABJ_404_Solution_PluginLogicDefaults::defaults()['plugin_admin_users']; } $normalizedOptions = ABJ_404_Solution_StorageOptionContracts::normalizeForRead( ABJ_404_Solution_StorageOptionContracts::OPTION_SETTINGS, $optionResult ); if (array_key_exists('plugin_admin_users', $normalizedOptions)) { return $normalizedOptions['plugin_admin_users']; } return ABJ_404_Solution_PluginLogicDefaults::defaults()['plugin_admin_users']; } /** * Resolve the current plugin options. With $skip_db_check=true, the * DB_VERSION pipeline is skipped (used during the version-upgrade * sequence itself and from contexts that must not trigger upgrades). * * @param bool $skip_db_check * @return array */ public function getOptions(bool $skip_db_check = false): array { if (!$skip_db_check && is_array($this->resolvedWithDbCheck)) { return $this->resolvedWithDbCheck; } if ($skip_db_check) { if (is_array($this->resolvedSkipDbCheck)) { return $this->resolvedSkipDbCheck; } if (is_array($this->resolvedWithDbCheck)) { return $this->resolvedWithDbCheck; } } $legacyOptions = $this->legacyPluginLogicOptionsOverride(); if (is_array($legacyOptions)) { return array_merge(ABJ_404_Solution_PluginLogicDefaults::defaults(), $legacyOptions); } if ($this->rawCache === null) { $optionResult = get_option('abj404_settings'); if (is_array($optionResult)) { $normalizedOptions = ABJ_404_Solution_StorageOptionContracts::normalizeForRead( ABJ_404_Solution_StorageOptionContracts::OPTION_SETTINGS, $optionResult ); $this->rawCache = $normalizedOptions; if ($normalizedOptions !== $optionResult) { $this->updateOptions($normalizedOptions); } } else { $this->rawCache = null; } } $options = $this->rawCache; if (!is_array($options)) { add_option('abj404_settings', '', '', false); $options = array(); } $defaults = ABJ_404_Solution_PluginLogicDefaults::defaults(); $missing = false; foreach ($defaults as $key => $value) { if (!isset($options[$key]) || $options[$key] === '') { $options[$key] = $value; $missing = true; } } if ($missing) { $this->updateOptions($options); } if ($skip_db_check == false) { if (!array_key_exists('DB_VERSION', $options) || $options['DB_VERSION'] != ABJ404_VERSION) { $versionUpgrade = abj_service('version_upgrade'); if (is_object($versionUpgrade) && method_exists($versionUpgrade, 'upgradeIfNeeded')) { $options = $versionUpgrade->upgradeIfNeeded($options); } else { $this->warn('version_upgrade service unavailable while reading options; skipped upgrade check.'); } } } $pluginLogic = abj_service('plugin_logic'); $pluginLogicClass = 'ABJ_404_Solution_PluginLogic'; $settingsUpdate = is_object($pluginLogic) && method_exists($pluginLogic, 'settingsUpdate') && (!(class_exists($pluginLogicClass) && is_a($pluginLogic, $pluginLogicClass)) || get_class($pluginLogic) === $pluginLogicClass) ? $pluginLogic->settingsUpdate() : null; if (is_object($settingsUpdate) && method_exists($settingsUpdate, 'normalizeSuggestionTemplateOptions') && $settingsUpdate->normalizeSuggestionTemplateOptions($options)) { $this->updateOptions($options); } if ($skip_db_check) { $this->resolvedSkipDbCheck = $options; } else { $this->resolvedWithDbCheck = $options; } return $options; } /** * Persist plugin options. Merges defaults, runs the storage write * contract, calls update_option, then invalidates the in-process cache. * * @param array $options * @return void */ public function updateOptions(array $options): void { $options = array_merge(ABJ_404_Solution_PluginLogicDefaults::defaults(), $options); $options = ABJ_404_Solution_StorageOptionContracts::prepareForWrite( ABJ_404_Solution_StorageOptionContracts::OPTION_SETTINGS, $options ); update_option('abj404_settings', $options); $this->rawCache = $options; $this->resolvedSkipDbCheck = null; $this->resolvedWithDbCheck = null; } /** * Legacy test seam: returns options seeded by a test before the real * WordPress option pipeline (and its DB_VERSION upgrade) is consulted. * Production callers never trigger either branch. * * Two seam shapes are honored, both anchored on PluginLogic::$instance: * 1. Reflection seam (older tests). ABJ_404_Solution_PluginLogic::$options * is set to an array via ReflectionProperty + PluginLogicOptionsResolver::reset(). * Used by SpellChecker*Test, CodeReviewIssuesTest, LoggingTest, etc. * 2. Subclass-getOptions seam (post-b2ab795d tests). A test subclass that * extends ABJ_404_Solution_PluginLogic overrides getOptions() and is * installed as the singleton. Real PluginLogic has no getOptions() * method (deleted with the options migration), so method_exists() is a * reliable test-subclass discriminator. * * @return array|null */ private function legacyPluginLogicOptionsOverride() { if (!class_exists('ABJ_404_Solution_PluginLogic')) { return null; } $pluginLogic = $this->readPluginLogicInstance(); if (!is_object($pluginLogic)) { return null; } // Only the real PluginLogic class declares the private $options property; anonymous // stubs (e.g. ShouldUpdatePluginTest::makeUpgrades) install a sibling class and would // raise on the reflection. Guard so the subclass-getOptions branch below is reached. if (is_a($pluginLogic, 'ABJ_404_Solution_PluginLogic')) { $reflectedOptions = $this->readPluginLogicOptionsProperty($pluginLogic); if (is_array($reflectedOptions)) { return $reflectedOptions; } } if (method_exists($pluginLogic, 'getOptions')) { $maybeOptions = $this->callPluginLogicGetOptions($pluginLogic); if (is_array($maybeOptions)) { return $maybeOptions; } } return null; } /** @return object|null PluginLogic singleton instance, or null when reflection fails. */ private function readPluginLogicInstance() { try { $instanceProperty = new ReflectionProperty('ABJ_404_Solution_PluginLogic', 'instance'); $value = $instanceProperty->getValue(); return is_object($value) ? $value : null; } catch (Throwable $e) { $this->warn('PluginLogicOptionsResolver could not read PluginLogic::$instance via reflection (' . $e->getMessage() . '); falling back to WordPress options.'); return null; } } /** * @param ABJ_404_Solution_PluginLogic $pluginLogic Real PluginLogic instance (caller verified). * @return array|null Seeded options array, or null when the property is absent / non-array. */ private function readPluginLogicOptionsProperty($pluginLogic) { try { $optionsProperty = new ReflectionProperty('ABJ_404_Solution_PluginLogic', 'options'); $options = $optionsProperty->getValue($pluginLogic); if (!is_array($options)) { return null; } /** @var array $typed */ $typed = $options; return $typed; } catch (Throwable $e) { $this->warn('PluginLogicOptionsResolver could not read PluginLogic::$options via reflection (' . $e->getMessage() . '); falling through to subclass-getOptions seam.'); return null; } } /** * Invoke a test-installed PluginLogic singleton's getOptions(true) override. * Real PluginLogic has no getOptions() method (removed in b2ab795d) so this * is only reached when a test subclass installs one as the singleton. * * @param object $pluginLogic Test stub with a getOptions(bool) method. * @return array|null Subclass-provided options, or null when the override raised. */ private function callPluginLogicGetOptions($pluginLogic) { try { /** @var callable $callable */ $callable = array($pluginLogic, 'getOptions'); $maybeOptions = call_user_func($callable, true); if (!is_array($maybeOptions)) { return null; } /** @var array $typed */ $typed = $maybeOptions; return $typed; } catch (Throwable $e) { $this->warn('PluginLogicOptionsResolver subclass-getOptions seam raised (' . $e->getMessage() . '); falling back to WordPress options.'); return null; } } private function warn(string $message): void { $logger = function_exists('abj_service_optional') ? abj_service_optional('logging') : null; if (is_object($logger) && method_exists($logger, 'warn')) { $logger->warn($message); return; } abj404_logPhpFallback('service-resolution-fallback', $message); } }