wikimedia/mediawiki-core

View on GitHub
includes/Settings/SettingsBuilder.php

Summary

Maintainability
D
2 days
Test Coverage
<?php

namespace MediaWiki\Settings;

use BagOStuff;
use ExtensionRegistry;
use MediaWiki\Config\Config;
use MediaWiki\Config\HashConfig;
use MediaWiki\Config\IterableConfig;
use MediaWiki\HookContainer\HookContainer;
use MediaWiki\MainConfigNames;
use MediaWiki\Settings\Cache\CacheableSource;
use MediaWiki\Settings\Cache\CachedSource;
use MediaWiki\Settings\Config\ConfigBuilder;
use MediaWiki\Settings\Config\ConfigSchema;
use MediaWiki\Settings\Config\ConfigSchemaAggregator;
use MediaWiki\Settings\Config\GlobalConfigBuilder;
use MediaWiki\Settings\Config\PhpIniSink;
use MediaWiki\Settings\Source\ArraySource;
use MediaWiki\Settings\Source\FileSource;
use MediaWiki\Settings\Source\SettingsFileUtils;
use MediaWiki\Settings\Source\SettingsIncludeLocator;
use MediaWiki\Settings\Source\SettingsSource;
use RuntimeException;
use StatusValue;
use function array_key_exists;

/**
 * Builder class for constructing a Config object from a set of sources
 * during bootstrap. The SettingsBuilder is used in Setup.php to load
 * and combine settings files and eventually produce the Config object that
 * will be used to configure MediaWiki.
 *
 * The SettingsBuilder object keeps track of "stages" of initialization that
 * correspond to sections of Setup.php:
 *
 * The initial stage is "loading". In this stage, SettingsSources are added
 * to the SettingsBuilder using the load* methods. This sets up the config
 * schema and applies custom configuration values.
 *
 * Once all settings sources have been loaded, the SettingsBuilder is moved to the
 * "registration" stage by calling enterRegistrationStage().
 * In this stage, config values may still be altered, but no settings sources may
 * be loaded. During the "registration" stage, dynamic defaults are applied,
 * extension registration callbacks are executed, and maintenance scripts have an
 * opportunity to manipulate settings.
 *
 * Finally, the SettingsBuilder is moved to the "operation" stage by calling
 * enterOperationStage(). This renders the SettingsBuilder read only: config values
 * may no longer be changed. At this point, it becomes safe to use the Config object
 * returned by getConfig() to initialize the service container.
 *
 * @since 1.38
 */
class SettingsBuilder {

    /**
     * @var int The initial stage in which settings can be loaded,
     * but config values cannot be accessed.
     */
    private const STAGE_LOADING = 1;

    /**
     * @var int The intermediate stage in which settings can no longer be loaded,
     * but config values can be accessed and manipulated programmatically.
     */
    private const STAGE_REGISTRATION = 10;

    /**
     * @var int The final stage in which config values can be accessed, but can
     * no longer be changed.
     */
    private const STAGE_READ_ONLY = 100;

    /** @var string */
    private $baseDir;

    /** @var ExtensionRegistry */
    private $extensionRegistry;

    /** @var BagOStuff */
    private $cache;

    /** @var ConfigBuilder */
    private $configSink;

    /** @var array<string,string> */
    private $obsoleteConfig;

    /** @var Config|null */
    private $config;

    /** @var SettingsSource[] */
    private $currentBatch;

    /** @var ConfigSchemaAggregator */
    private $configSchema;

    /** @var PhpIniSink */
    private $phpIniSink;

    /**
     * Configuration that applies to SettingsBuilder itself.
     * Initialized by the constructor, may be overwritten by regular
     * config values. Merge strategies are currently not implemented
     * but can be added if needed.
     *
     * @var array
     */
    private $settingsConfig;

    /**
     * The stage of the settings builder. This is used to determine
     * which settings are allowed to be changed.
     *
     * @var int see self::STAGE_*
     */
    private $stage = self::STAGE_LOADING;

    /**
     * Whether we have to apply reverse-merging when applying defaults.
     * This will initially be false, and become true once any config settings have been
     * assigned a value.
     *
     * This is used as an optimization, to avoid costly merge logic when loading initial
     * defaults before any config variables have been set.
     *
     * @var bool
     */
    private $defaultsNeedMerging = false;

    /** @var string[] */
    private $warnings = [];

    private static bool $accessDisabledForUnitTests = false;

    /**
     * Accessor for the global SettingsBuilder instance.
     *
     * @note It is always preferable to have a SettingsBuilder injected!
     *       But as long as we can't to this everywhere, this is the preferred way of
     *       getting the global instance of SettingsBuilder.
     *
     * @return SettingsBuilder
     */
    public static function getInstance(): self {
        static $instance = null;

        if ( self::$accessDisabledForUnitTests ) {
            throw new RuntimeException( 'Access is disabled in unit tests' );
        }

        if ( !$instance ) {
            // NOTE: SettingsBuilder is used during bootstrap, before MediaWikiServices
            //       is available. It has to be, because it is used to construct the
            //       configuration that is used when constructing services. Because of
            //       this, we have to instantiate SettingsBuilder directly, we can't
            //       use service wiring.
            $instance = new SettingsBuilder(
                MW_INSTALL_PATH,
                ExtensionRegistry::getInstance(),
                new GlobalConfigBuilder( 'wg' ),
                new PhpIniSink()
            );
        }

        return $instance;
    }

    /**
     * @internal
     */
    public static function disableAccessForUnitTests(): void {
        if ( !defined( 'MW_PHPUNIT_TEST' ) ) {
            throw new RuntimeException( 'Can only be called in tests' );
        }
        self::$accessDisabledForUnitTests = true;
    }

    /**
     * @internal
     */
    public static function enableAccessAfterUnitTests(): void {
        if ( !defined( 'MW_PHPUNIT_TEST' ) ) {
            throw new RuntimeException( 'Can only be called in tests' );
        }
        self::$accessDisabledForUnitTests = false;
    }

    /**
     * @param string $baseDir
     * @param ExtensionRegistry $extensionRegistry
     * @param ConfigBuilder $configSink
     * @param PhpIniSink $phpIniSink
     * @param BagOStuff|null $cache BagOStuff used to cache settings loaded
     *  from each source. The caller should beware that secrets contained in
     *  any source passed to {@link load} or {@link loadFile} will be cached as
     *  well.
     */
    public function __construct(
        string $baseDir,
        ExtensionRegistry $extensionRegistry,
        ConfigBuilder $configSink,
        PhpIniSink $phpIniSink,
        BagOStuff $cache = null
    ) {
        $this->baseDir = $baseDir;
        $this->extensionRegistry = $extensionRegistry;
        $this->cache = $cache;
        $this->configSink = $configSink;
        $this->obsoleteConfig = [];
        $this->configSchema = new ConfigSchemaAggregator();
        $this->phpIniSink = $phpIniSink;
        $this->settingsConfig = [
            MainConfigNames::ExtensionDirectory => "$baseDir/extensions",
            MainConfigNames::StyleDirectory => "$baseDir/skins",
        ];
        $this->reset();
    }

    /**
     * Load settings from a {@link SettingsSource}.
     * Only allowed during the "loading" stage.
     *
     * @param SettingsSource $source
     * @return $this
     */
    public function load( SettingsSource $source ): self {
        $this->assertStillLoading( __METHOD__ );

        // XXX: We may want to cache the entire batch instead, see T304493.
        $this->currentBatch[] = $this->wrapSource( $source );

        return $this;
    }

    /**
     * Load settings from an array.
     *
     * @param array $newSettings
     *
     * @return $this
     */
    public function loadArray( array $newSettings ): self {
        return $this->load( new ArraySource( $newSettings ) );
    }

    /**
     * Load settings from an array.
     * For internal use. Allowed during "loading" and "registration" stage.
     *
     * @param array $newSettings
     * @param string $func
     *
     * @return $this
     */
    private function loadArrayInternal( array $newSettings, string $func ): self {
        $this->assertNotReadOnly( $func );

        $source = new ArraySource( $newSettings );
        $this->currentBatch[] = $this->wrapSource( $source );

        return $this;
    }

    /**
     * Load settings from a file.
     *
     * @param string $path
     * @return $this
     */
    public function loadFile( string $path ): self {
        return $this->load( $this->makeSource( $path ) );
    }

    /**
     * Checks whether the given file exists relative to the settings builder's
     * base directory.
     *
     * @param string $path
     * @return bool
     */
    public function fileExists( string $path ): bool {
        $path = SettingsFileUtils::resolveRelativeLocation( $path, $this->baseDir );
        return file_exists( $path );
    }

    /**
     * @param SettingsSource $source
     *
     * @return SettingsSource
     */
    private function wrapSource( SettingsSource $source ): SettingsSource {
        if ( $this->cache !== null && $source instanceof CacheableSource ) {
            $source = new CachedSource( $this->cache, $source );
        }
        return $source;
    }

    /**
     * @param string $location
     * @return SettingsSource
     */
    private function makeSource( $location ): SettingsSource {
        // NOTE: Currently, files are the only kind of location, but we could add others.
        //       The set of supported source locations will be hard-coded here.
        //       Custom SettingsSource would have to be instantiated directly and passed to load().
        $path = SettingsFileUtils::resolveRelativeLocation( $location, $this->baseDir );

        return $this->wrapSource( new FileSource( $path ) );
    }

    /**
     * Assert that the config loaded so far conforms the schema loaded so far.
     *
     * @note this is slow, so you probably don't want to do this on every request.
     *
     * @return StatusValue
     */
    public function validate(): StatusValue {
        $config = $this->getConfig();
        return $this->configSchema->validateConfig( $config );
    }

    /**
     * Detect usage of deprecated settings. A setting is counted as used if
     * it has a value other than the default. Note that deprecated settings are
     * expected to be supported. Settings that have become non-functional should
     * be marked as obsolete instead.
     *
     * @note this is slow, so you probably don't want to do this on every request.
     * @note Code that needs to call detectDeprecatedConfig() should probably also
     *       call detectObsoleteConfig() and getWarnings().
     *
     * @return array<string,string> an associative array mapping config keys
     *         to the deprecation messages from the schema.
     */
    public function detectDeprecatedConfig(): array {
        $config = $this->getConfig();
        $keys = $this->getDefinedConfigKeys();
        $deprecated = [];

        foreach ( $keys as $key ) {
            $sch = $this->configSchema->getSchemaFor( $key );
            if ( !isset( $sch['deprecated'] ) ) {
                continue;
            }

            $default = $sch['default'] ?? null;
            $value = $config->get( $key );

            if ( $value !== $default ) {
                $deprecated[$key] = $sch['deprecated'];
            }
        }

        return $deprecated;
    }

    /**
     * Detect usage of obsolete settings. A setting is counted as used if it is
     * defined in any way. Note that obsolete settings are non-functional, while
     * deprecated settings are still supported.
     *
     * @note this is slow, so you probably don't want to do this on every request.
     * @note Code that calls detectObsoleteConfig() may also want to
     *       call detectDeprecatedConfig() and getWarnings().
     *
     * @return array<string,string> an associative array mapping config keys
     *         to the deprecation messages from the schema.
     */
    public function detectObsoleteConfig(): array {
        $config = $this->getConfig();
        $obsolete = [];

        foreach ( $this->obsoleteConfig as $key => $msg ) {
            if ( $config->has( $key ) ) {
                $obsolete[$key] = $msg;
            }
        }

        return $obsolete;
    }

    /**
     * Return a Config object with default for all settings from all schemas loaded so far.
     * If the schema for a setting doesn't specify a default, null is assumed.
     *
     * @note This will implicitly call apply()
     *
     * @return IterableConfig
     */
    public function getDefaultConfig(): IterableConfig {
        $this->apply();
        $defaults = $this->configSchema->getDefaults();
        $nulls = array_fill_keys( $this->configSchema->getDefinedKeys(), null );

        return new HashConfig( array_merge( $nulls, $defaults ) );
    }

    /**
     * Return the configuration schema.
     *
     * @note This will implicitly call apply()
     *
     * @return ConfigSchema
     */
    public function getConfigSchema(): ConfigSchema {
        $this->apply();
        return $this->configSchema;
    }

    /**
     * Returns the names of all defined configuration variables
     *
     * @return string[]
     */
    public function getDefinedConfigKeys(): array {
        $this->apply();
        return $this->configSchema->getDefinedKeys();
    }

    /**
     * Apply any settings loaded so far to the runtime environment.
     *
     * @note This usually makes all configuration available in global variables.
     * This may however not be the case in the future.
     *
     * @return $this
     * @throws SettingsBuilderException
     */
    public function apply(): self {
        if ( !$this->currentBatch ) {
            return $this;
        }

        $this->assertNotReadOnly( __METHOD__ );
        $this->config = null;

        // XXX: We may want to cache the entire batch after merging together
        //      settings from all sources, see T304493.
        $allSettings = $this->loadRecursive( $this->currentBatch );

        foreach ( $allSettings as $settings ) {
            $this->applySettings( $settings );
        }
        $this->reset();
        return $this;
    }

    /**
     * Loads all sources in the current batch, recursively resolving includes.
     *
     * @param SettingsSource[] $batch The batch of sources to load
     * @param string[] $stack The current stack of includes, for cycle detection
     *
     * @return array[] an array of settings arrays
     */
    private function loadRecursive( array $batch, array $stack = [] ): array {
        $allSettings = [];

        // Depth-first traversal of settings sources.
        foreach ( $batch as $source ) {
            $sourceName = (string)$source;

            if ( in_array( $sourceName, $stack ) ) {
                throw new SettingsBuilderException(
                    'Recursive include chain detected: ' . implode( ', ', $stack )
                );
            }

            $settings = $source->load();
            $settings['source-name'] = $sourceName;

            $allSettings[] = $settings;

            $nextBatch = [];
            foreach ( $settings['includes'] ?? [] as $location ) {
                // Try to resolve the include relative to the source,
                // if the source supports that.
                if ( $source instanceof SettingsIncludeLocator ) {
                    $location = $source->locateInclude( $location );
                }

                $nextBatch[] = $this->makeSource( $location );
            }

            $nextStack = array_merge( $stack, [ $settings['source-name'] ] );
            $nextSettings = $this->loadRecursive( $nextBatch, $nextStack );
            $allSettings = array_merge( $allSettings, $nextSettings );
        }

        return $allSettings;
    }

    /**
     * Updates config settings relevant to the behavior if SettingsBuilder itself.
     *
     * @param array $config
     *
     * @return string
     */
    private function updateSettingsConfig( $config ): string {
        // No merge strategies are applied, defaults are set in the constructor.
        foreach ( $this->settingsConfig as $key => $dummy ) {
            if ( array_key_exists( $key, $config ) ) {
                $this->settingsConfig[ $key ] = $config[ $key ];
            }
        }
        // @phan-suppress-next-line PhanTypeMismatchReturnNullable,PhanPossiblyUndeclaredVariable Always set
        return $key;
    }

    /**
     * Notify SettingsBuilder that it can no longer assume that is has full knowledge of
     * all configuration variables that have been set. This would be the case when other code
     * (such as LocalSettings.php) is manipulating global variables which represent config
     * values.
     *
     * This is used for optimization: up until this method is called, default values can be set
     * directly for any config values that have not been set yet. This avoids the need to
     * run merge logic for all default values during initialization.
     *
     * @note It is useful to call apply() just before this method, so any settings already queued
     * will still benefit from assuming that globals are not dirty.
     *
     * @return self
     */
    public function assumeDirtyConfig(): SettingsBuilder {
        $this->defaultsNeedMerging = true;
        return $this;
    }

    /**
     * Apply schemas from the settings array.
     *
     * This returns the default values to apply, splits into two two categories:
     * "hard" defaults, which can be applied as config overrides without merging.
     * And "soft" defaults, which have to be reverse-merged.
     * Defaults can be considered "hard" if no config value was yet set for them. However,
     * we can only know that as long as we can be sure that nothing has changed config values
     * in a way that bypasses SettingsLoader (e.g. by setting global variables in LocalSettings.php).
     *
     * @param array $settings A settings structure.
     */
    private function applySchemas( array $settings ) {
        $defaults = [];

        if ( isset( $settings['config-schema-inverse'] ) ) {
            $defaults = $settings['config-schema-inverse']['default'] ?? [];
            $this->configSchema->addDefaults(
                $defaults,
                $settings['source-name']
            );
            $this->configSchema->addMergeStrategies(
                $settings['config-schema-inverse']['mergeStrategy'] ?? [],
                $settings['source-name']
            );
            $this->configSchema->addTypes(
                $settings['config-schema-inverse']['type'] ?? [],
                $settings['source-name']
            );
            $this->configSchema->addDynamicDefaults(
                $settings['config-schema-inverse']['dynamicDefault'] ?? [],
                $settings['source-name']
            );
        }

        if ( isset( $settings['config-schema'] ) ) {
            foreach ( $settings['config-schema'] as $key => $schema ) {
                $this->configSchema->addSchema( $key, $schema );

                if ( $this->configSchema->hasDefaultFor( $key ) ) {
                    $defaults[$key] = $this->configSchema->getDefaultFor( $key );
                }
            }
        }

        if ( $this->defaultsNeedMerging ) {
            $mergeStrategies = $this->configSchema->getMergeStrategies();
            $this->configSink->setMultiDefault( $defaults, $mergeStrategies );
        } else {
            // Optimization: no merge strategy, just override in one go
            $this->configSink->setMulti( $defaults );
        }
    }

    /**
     * Apply the settings array.
     *
     * @param array $settings
     */
    private function applySettings( array $settings ) {
        // First extract config variables that change the behavior of SettingsBuilder.
        // No merge strategies are applied, defaults are set in the constructor.
        if ( isset( $settings['config'] ) ) {
            $this->updateSettingsConfig( $settings['config'] );
        }
        if ( isset( $settings['config-overrides'] ) ) {
            $this->updateSettingsConfig( $settings['config-overrides'] );
        }

        $this->applySchemas( $settings );

        if ( isset( $settings['config'] ) ) {
            $mergeStrategies = $this->configSchema->getMergeStrategies();
            $this->configSink->setMulti( $settings['config'], $mergeStrategies );
        }

        if ( isset( $settings['config-overrides'] ) ) {
            // no merge strategies, just override in one go
            $this->configSink->setMulti( $settings['config-overrides'] );
        }

        if ( isset( $settings['obsolete-config'] ) ) {
            $this->obsoleteConfig = array_merge( $this->obsoleteConfig, $settings['obsolete-config'] );
        }

        if ( isset( $settings['config'] ) || isset( $settings['config-overrides'] ) ) {
            // We have set some config variables, we can no longer assume we can blindly set defaults
            // without merging with existing config variables.
            // XXX: We could potentially track which config variables have been set, so we can still
            //      apply defaults for other config vars without merging.
            $this->defaultsNeedMerging = true;
        }

        foreach ( $settings['php-ini'] ?? [] as $option => $value ) {
            $this->phpIniSink->set(
                $option,
                $value
            );
        }

        // TODO: Closely integrate with ExtensionRegistry. Loading extension.json is basically
        //       the same as loading settings files. See T297166.
        //       That would also mean that extensions would actually be loaded here,
        //       not just queued. We can't do this right now, because we need to preserve
        //       interoperability with wfLoadExtension() being called from LocalSettings.php.

        if ( isset( $settings['extensions'] ) ) {
            $extDir = $this->settingsConfig[MainConfigNames::ExtensionDirectory];
            foreach ( $settings['extensions'] ?? [] as $ext ) {
                $path = "$extDir/$ext/extension.json"; // see wfLoadExtension
                $this->extensionRegistry->queue( $path );
            }
        }

        if ( isset( $settings['skins'] ) ) {
            $skinDir = $this->settingsConfig[MainConfigNames::StyleDirectory];
            foreach ( $settings['skins'] ?? [] as $skin ) {
                $path = "$skinDir/$skin/skin.json"; // see wfLoadSkin
                $this->extensionRegistry->queue( $path );
            }
        }
    }

    /**
     * Puts a value into a config variable.
     * Depending on the variable's specification, the new value may
     * be merged with the previous value, or may replace it.
     * This is a shorthand for putConfigValues( [ $key => $value ] ).
     *
     * @see overrideConfigValue
     *
     * @param string $key the name of the config setting
     * @param mixed $value The value to set
     *
     * @return $this
     */
    public function putConfigValue( string $key, $value ): self {
        return $this->putConfigValues( [ $key => $value ] );
    }

    /**
     * Sets the value of multiple config variables.
     * Depending on the variables' specification, the new values may
     * be merged with the previous values, or they may replace them.
     * This is a shorthand for loadArray( [ 'config' => $values ] ).
     *
     * @see overrideConfigValues
     *
     * @param array $values An associative array mapping names to values.
     *
     * @return $this
     */
    public function putConfigValues( array $values ): self {
        return $this->loadArrayInternal( [ 'config' => $values ], __METHOD__ );
    }

    /**
     * Override the value of a config variable.
     * This ignores any merge strategies and discards any previous value.
     * This is a shorthand for overrideConfigValues( [ $key => $value ] ).
     *
     * @see putConfigValue
     *
     * @param string $key the name of the config setting
     * @param mixed $value The value to set
     *
     * @return $this
     */
    public function overrideConfigValue( string $key, $value ): self {
        return $this->overrideConfigValues( [ $key => $value ] );
    }

    /**
     * Override the value of multiple config variables.
     * This ignores any merge strategies and discards any previous value.
     * This is a shorthand for loadArray( [ 'config-overrides' => $values ] ).
     *
     * @see putConfigValues
     *
     * @param array $values An associative array mapping names to values.
     *
     * @return $this
     */
    public function overrideConfigValues( array $values ): self {
        return $this->loadArrayInternal( [ 'config-overrides' => $values ], __METHOD__ );
    }

    /**
     * Register hook handlers.
     *
     * @param array<string,mixed> $handlers An associative array using the same structure
     *        as the Hooks config setting:
     *        Each value is a list of handler callbacks for the hook.
     *
     * @return $this
     * @see HookContainer::register()
     */
    public function registerHookHandlers( array $handlers ): self {
        // NOTE: Rely on the merge strategy for the Hooks setting.
        // TODO: Make hook handlers a separate structure in settings files,
        //       like they are in extension.json.
        return $this->loadArrayInternal( [ 'config' => [ 'Hooks' => $handlers ] ], __METHOD__ );
    }

    /**
     * Register a hook handler.
     *
     * @param string $hook
     * @param mixed $handler
     *
     * @return $this
     * @see HookContainer::register()
     */
    public function registerHookHandler( string $hook, $handler ): self {
        // NOTE: Rely on the merge strategy for the Hooks setting.
        // TODO: Make hook handlers a separate structure in settings files,
        //       like they are in extension.json.
        return $this->loadArray( [ 'config' => [ 'Hooks' => [ $hook => [ $handler ] ] ] ] );
    }

    /**
     * Returns the config loaded so far. Implicitly triggers apply() when needed.
     *
     * @note This will implicitly call apply()
     *
     * @return Config
     */
    public function getConfig(): Config {
        // XXX: Would be nice if we could forbid using this method
        //   before enterRegistrationStage() is called. But we need
        //   access to some configuration earlier, e.g. WikiFarmSettingsDirectory.

        if ( $this->config && !$this->currentBatch ) {
            return $this->config;
        }

        $this->apply();
        $this->config = $this->configSink->build();

        return $this->config;
    }

    private function reset() {
        $this->currentBatch = [];
    }

    private function assertNotReadOnly( string $func ): void {
        if ( $this->stage === self::STAGE_READ_ONLY ) {
            throw new SettingsBuilderException(
                "$func not supported in operation stage."
            );
        }
    }

    private function assertStillLoading( string $func ): void {
        if ( $this->stage !== self::STAGE_LOADING ) {
            throw new SettingsBuilderException(
                "$func only supported while still in the loading stage."
            );
        }
    }

    /**
     * Sets the SettingsBuilder read-only.
     *
     * Call this before using the configuration returned by getConfig() to construct services objects
     * or initialize the service container.
     *
     * @internal For use in Setup.php.
     */
    public function enterReadOnlyStage(): void {
        $this->apply();
        $this->stage = self::STAGE_READ_ONLY;
    }

    /**
     * Prevents additional settings from being loaded, but still allows manipulation of config values.
     *
     * Call this before applying dynamic defaults and executing extension registration callbacks.
     *
     * @internal For use in Setup.php.
     */
    public function enterRegistrationStage(): void {
        $this->apply();
        $this->stage = self::STAGE_REGISTRATION;
    }

    /**
     * @internal For use in Setup.php, pending a better solution.
     * @return ConfigBuilder
     */
    public function getConfigBuilder(): ConfigBuilder {
        $this->apply();
        return $this->configSink;
    }

    /**
     * Log a settings related warning, such as a deprecated config variable.
     *
     * This can be used during bootstrapping, when the regular logger is not yet available.
     * The warnings will be passed to a regular logger after bootstrapping is complete.
     * In addition, the updater will fail if it finds any warnings.
     * This allows us to warn about deprecated settings, and make sure they are
     * replaced before the update proceeds.
     *
     * @param string $msg
     */
    public function warning( string $msg ) {
        $this->assertNotReadOnly( __METHOD__ );
        $this->warnings[] = trim( $msg );
    }

    /**
     * Returns any warnings logged by calling warning().
     *
     * @internal
     * @return string[]
     */
    public function getWarnings(): array {
        return $this->warnings;
    }

}