Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
83.81% covered (warning)
83.81%
176 / 210
59.26% covered (warning)
59.26%
16 / 27
CRAP
0.00% covered (danger)
0.00%
0 / 1
ExtensionRegistry
83.81% covered (warning)
83.81%
176 / 210
59.26% covered (warning)
59.26%
16 / 27
133.30
0.00% covered (danger)
0.00%
0 / 1
 getInstance
n/a
0 / 0
n/a
0 / 0
3
 disableForTest
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 enableForTest
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 setCache
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setCheckDevRequires
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 setLoadTestClassesAndNamespaces
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 queue
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 getCache
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
4.07
 makeCacheKey
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 getVaryHash
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
2
 invalidateProcessCache
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 loadFromQueue
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
4
 saveToCache
46.67% covered (danger)
46.67%
7 / 15
0.00% covered (danger)
0.00%
0 / 1
11.46
 getQueue
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 clearQueue
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 finish
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getAbilities
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 buildVersionChecker
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 readFromQueue
84.62% covered (warning)
84.62%
22 / 26
0.00% covered (danger)
0.00%
0 / 1
10.36
 exportExtractedData
90.91% covered (success)
90.91%
50 / 55
0.00% covered (danger)
0.00%
0 / 1
28.59
 isLoaded
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
 getAttribute
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
3.07
 getLazyLoadedAttribute
83.33% covered (warning)
83.33%
15 / 18
0.00% covered (danger)
0.00%
0 / 1
5.12
 setAttributeForTest
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 getAllThings
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 processAutoLoader
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 setSettingsBuilder
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getSettingsBuilder
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3use Composer\Semver\Semver;
4use MediaWiki\Settings\SettingsBuilder;
5use MediaWiki\Shell\Shell;
6use MediaWiki\ShellDisabledError;
7use MediaWiki\WikiMap\WikiMap;
8use Wikimedia\ScopedCallback;
9
10/**
11 * @defgroup ExtensionRegistry ExtensionRegistry
12 *
13 * For higher level documentation, see <https://www.mediawiki.org/wiki/Manual:Extension_registration/Architecture>.
14 */
15
16/**
17 * Load JSON files, and uses a Processor to extract information.
18 *
19 * This also adds the extension's classes to the AutoLoader.
20 *
21 * @ingroup ExtensionRegistry
22 * @since 1.25
23 */
24class ExtensionRegistry {
25
26    /**
27     * "requires" key that applies to MediaWiki core
28     */
29    public const MEDIAWIKI_CORE = 'MediaWiki';
30
31    /**
32     * Version of the highest supported manifest version
33     * Note: Update MANIFEST_VERSION_MW_VERSION when changing this
34     */
35    public const MANIFEST_VERSION = 2;
36
37    /**
38     * MediaWiki version constraint representing what the current
39     * highest MANIFEST_VERSION is supported in
40     */
41    public const MANIFEST_VERSION_MW_VERSION = '>= 1.29.0';
42
43    /**
44     * Version of the oldest supported manifest version
45     */
46    public const OLDEST_MANIFEST_VERSION = 1;
47
48    /**
49     * Bump whenever the registration cache needs resetting
50     */
51    private const CACHE_VERSION = 8;
52
53    private const CACHE_EXPIRY = 60 * 60 * 24;
54
55    /**
56     * Special key that defines the merge strategy
57     *
58     * @since 1.26
59     */
60    public const MERGE_STRATEGY = '_merge_strategy';
61
62    /**
63     * Attributes that should be lazy-loaded
64     */
65    private const LAZY_LOADED_ATTRIBUTES = [
66        'TrackingCategories',
67        'QUnitTestModules',
68        'SkinLessImportPaths',
69    ];
70
71    /**
72     * Array of loaded things, keyed by name, values are credits information.
73     *
74     * The keys that the credit info arrays may have is defined
75     * by ExtensionProcessor::CREDIT_ATTRIBS (plus a 'path' key that
76     * points to the skin or extension JSON file).
77     *
78     * This info may be accessed via ExtensionRegistry::getAllThings.
79     *
80     * @var array[]
81     */
82    private $loaded = [];
83
84    /**
85     * List of paths that should be loaded
86     *
87     * @var int[]
88     */
89    protected $queued = [];
90
91    /**
92     * Whether we are done loading things
93     *
94     * @var bool
95     */
96    private $finished = false;
97
98    /**
99     * Items in the JSON file that aren't being
100     * set as globals
101     *
102     * @var array
103     */
104    protected $attributes = [];
105
106    /**
107     * Attributes for testing
108     *
109     * @var array
110     */
111    protected $testAttributes = [];
112
113    /**
114     * Lazy-loaded attributes
115     *
116     * @var array
117     */
118    protected $lazyAttributes = [];
119
120    /**
121     * The hash of cache-varying options, lazy-initialised
122     *
123     * @var string|null
124     */
125    private $varyHash;
126
127    /**
128     * Whether to check dev-requires
129     *
130     * @var bool
131     */
132    protected $checkDev = false;
133
134    /**
135     * Whether test classes and namespaces should be added to the auto loader
136     *
137     * @var bool
138     */
139    protected $loadTestClassesAndNamespaces = false;
140
141    /**
142     * @var ExtensionRegistry
143     */
144    private static $instance;
145
146    /**
147     * @var ?BagOStuff
148     */
149    private $cache = null;
150
151    /**
152     * @var ?SettingsBuilder
153     */
154    private ?SettingsBuilder $settingsBuilder = null;
155
156    private static bool $accessDisabledForUnitTests = false;
157
158    /**
159     * @codeCoverageIgnore
160     * @return ExtensionRegistry
161     */
162    public static function getInstance() {
163        if ( self::$accessDisabledForUnitTests ) {
164            throw new RuntimeException( 'Access is disabled in unit tests' );
165        }
166        if ( self::$instance === null ) {
167            self::$instance = new self();
168        }
169
170        return self::$instance;
171    }
172
173    /**
174     * @internal
175     */
176    public static function disableForTest(): void {
177        if ( !defined( 'MW_PHPUNIT_TEST' ) ) {
178            throw new RuntimeException( 'Can only be called in tests' );
179        }
180        self::$accessDisabledForUnitTests = true;
181    }
182
183    /**
184     * @internal
185     */
186    public static function enableForTest(): void {
187        if ( !defined( 'MW_PHPUNIT_TEST' ) ) {
188            throw new RuntimeException( 'Can only be called in tests' );
189        }
190        self::$accessDisabledForUnitTests = false;
191    }
192
193    /**
194     * Set the cache to use for extension info.
195     * Intended for use during testing.
196     *
197     * @internal
198     *
199     * @param BagOStuff $cache
200     */
201    public function setCache( BagOStuff $cache ): void {
202        $this->cache = $cache;
203    }
204
205    /**
206     * @since 1.34
207     * @param bool $check
208     */
209    public function setCheckDevRequires( $check ) {
210        $this->checkDev = $check;
211        $this->invalidateProcessCache();
212    }
213
214    /**
215     * Controls if classes and namespaces defined under the keys TestAutoloadClasses and
216     * TestAutoloadNamespaces should be added to the autoloader.
217     *
218     * @since 1.35
219     * @param bool $load
220     */
221    public function setLoadTestClassesAndNamespaces( $load ) {
222        $this->loadTestClassesAndNamespaces = $load;
223    }
224
225    /**
226     * @param string $path Absolute path to the JSON file
227     */
228    public function queue( $path ) {
229        global $wgExtensionInfoMTime;
230
231        $mtime = $wgExtensionInfoMTime;
232        if ( $mtime === false ) {
233            // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged
234            $mtime = @filemtime( $path );
235            // @codeCoverageIgnoreStart
236            if ( $mtime === false ) {
237                $err = error_get_last();
238                throw new MissingExtensionException( $path, $err['message'] );
239                // @codeCoverageIgnoreEnd
240            }
241        }
242        $this->queued[$path] = $mtime;
243        $this->invalidateProcessCache();
244    }
245
246    private function getCache(): BagOStuff {
247        if ( !$this->cache ) {
248            // NOTE: Copy of ObjectCacheFactory::getDefaultKeyspace
249            //
250            // Can't call MediaWikiServices here, as we must not cause services
251            // to be instantiated before extensions have loaded.
252            global $wgCachePrefix;
253            $keyspace = ( is_string( $wgCachePrefix ) && $wgCachePrefix !== '' )
254                ? $wgCachePrefix
255                : WikiMap::getCurrentWikiDbDomain()->getId();
256            return ObjectCache::makeLocalServerCache( $keyspace );
257        }
258
259        return $this->cache;
260    }
261
262    private function makeCacheKey( BagOStuff $cache, $component, ...$extra ) {
263        // Allow reusing cached ExtensionRegistry metadata between wikis (T274648)
264        return $cache->makeGlobalKey(
265            "registration-$component",
266            $this->getVaryHash(),
267            ...$extra
268        );
269    }
270
271    /**
272     * Get the cache varying hash
273     *
274     * @return string
275     */
276    private function getVaryHash() {
277        if ( $this->varyHash === null ) {
278            // We vary the cache on the current queue (what will be or already was loaded)
279            // plus various versions of stuff for VersionChecker
280            $vary = [
281                'registration' => self::CACHE_VERSION,
282                'mediawiki' => MW_VERSION,
283                'abilities' => $this->getAbilities(),
284                'checkDev' => $this->checkDev,
285                'queue' => $this->queued,
286            ];
287            $this->varyHash = md5( json_encode( $vary ) );
288        }
289        return $this->varyHash;
290    }
291
292    /**
293     * Invalidate the cache of the vary hash and the lazy options.
294     */
295    private function invalidateProcessCache() {
296        $this->varyHash = null;
297        $this->lazyAttributes = [];
298    }
299
300    public function loadFromQueue() {
301        if ( !$this->queued ) {
302            return;
303        }
304
305        if ( $this->finished ) {
306            throw new LogicException(
307                "The following paths tried to load late: "
308                . implode( ', ', array_keys( $this->queued ) )
309            );
310        }
311
312        $cache = $this->getCache();
313        // See if this queue is in APC
314        $key = $this->makeCacheKey( $cache, 'main' );
315        $data = $cache->get( $key );
316        if ( !$data ) {
317            $data = $this->readFromQueue( $this->queued );
318            $this->saveToCache( $cache, $data );
319        }
320        $this->exportExtractedData( $data );
321    }
322
323    /**
324     * Save data in the cache
325     *
326     * @param BagOStuff $cache
327     * @param array $data
328     */
329    protected function saveToCache( BagOStuff $cache, array $data ) {
330        global $wgDevelopmentWarnings;
331        if ( $data['warnings'] && $wgDevelopmentWarnings ) {
332            // If warnings were shown, don't cache it
333            return;
334        }
335        $lazy = [];
336        // Cache lazy-loaded attributes separately
337        foreach ( self::LAZY_LOADED_ATTRIBUTES as $attrib ) {
338            if ( isset( $data['attributes'][$attrib] ) ) {
339                $lazy[$attrib] = $data['attributes'][$attrib];
340                unset( $data['attributes'][$attrib] );
341            }
342        }
343        $mainKey = $this->makeCacheKey( $cache, 'main' );
344        $cache->set( $mainKey, $data, self::CACHE_EXPIRY );
345        foreach ( $lazy as $attrib => $value ) {
346            $cache->set(
347                $this->makeCacheKey( $cache, 'lazy-attrib', $attrib ),
348                $value,
349                self::CACHE_EXPIRY
350            );
351        }
352    }
353
354    /**
355     * Get the current load queue. Not intended to be used
356     * outside of the installer.
357     *
358     * @return int[] Map of extension.json files' modification timestamps keyed by absolute path
359     */
360    public function getQueue() {
361        return $this->queued;
362    }
363
364    /**
365     * Clear the current load queue. Not intended to be used
366     * outside of the installer.
367     */
368    public function clearQueue() {
369        $this->queued = [];
370        $this->invalidateProcessCache();
371    }
372
373    /**
374     * After this is called, no more extensions can be loaded
375     *
376     * @since 1.29
377     */
378    public function finish() {
379        $this->finished = true;
380    }
381
382    /**
383     * Get the list of abilities and their values
384     * @return bool[]
385     */
386    private function getAbilities() {
387        return [
388            'shell' => !Shell::isDisabled(),
389        ];
390    }
391
392    /**
393     * Queries information about the software environment and constructs an appropriate version checker
394     *
395     * @return VersionChecker
396     */
397    private function buildVersionChecker() {
398        // array to optionally specify more verbose error messages for
399        // missing abilities
400        $abilityErrors = [
401            'shell' => ( new ShellDisabledError() )->getMessage(),
402        ];
403
404        return new VersionChecker(
405            MW_VERSION,
406            PHP_MAJOR_VERSION . '.' . PHP_MINOR_VERSION . '.' . PHP_RELEASE_VERSION,
407            get_loaded_extensions(),
408            $this->getAbilities(),
409            $abilityErrors
410        );
411    }
412
413    /**
414     * Process a queue of extensions and return their extracted data
415     *
416     * @internal since 1.39. Extensions should use ExtensionProcessor instead.
417     *
418     * @param int[] $queue keys are filenames, values are ignored
419     * @return array extracted info
420     * @throws InvalidArgumentException
421     * @throws ExtensionDependencyError
422     */
423    public function readFromQueue( array $queue ) {
424        $processor = new ExtensionProcessor();
425        $versionChecker = $this->buildVersionChecker();
426        $extDependencies = [];
427        $warnings = false;
428        foreach ( $queue as $path => $mtime ) {
429            $json = file_get_contents( $path );
430            if ( $json === false ) {
431                throw new InvalidArgumentException( "Unable to read $path, does it exist?" );
432            }
433            $info = json_decode( $json, /* $assoc = */ true );
434            if ( !is_array( $info ) ) {
435                throw new InvalidArgumentException( "$path is not a valid JSON file." );
436            }
437
438            $version = $info['manifest_version'];
439            if ( $version < self::OLDEST_MANIFEST_VERSION || $version > self::MANIFEST_VERSION ) {
440                throw new InvalidArgumentException( "$path: unsupported manifest_version: {$version}" );
441            }
442
443            // get all requirements/dependencies for this extension
444            $requires = $processor->getRequirements( $info, $this->checkDev );
445
446            // validate the information needed and add the requirements
447            if ( is_array( $requires ) && $requires && isset( $info['name'] ) ) {
448                $extDependencies[$info['name']] = $requires;
449            }
450
451            // Compatible, read and extract info
452            $processor->extractInfo( $path, $info, $version );
453        }
454        $data = $processor->getExtractedInfo( $this->loadTestClassesAndNamespaces );
455        $data['warnings'] = $warnings;
456
457        // check for incompatible extensions
458        $incompatible = $versionChecker
459            ->setLoadedExtensionsAndSkins( $data['credits'] )
460            ->checkArray( $extDependencies );
461
462        if ( $incompatible ) {
463            throw new ExtensionDependencyError( $incompatible );
464        }
465
466        return $data;
467    }
468
469    protected function exportExtractedData( array $info ) {
470        foreach ( $info['globals'] as $key => $val ) {
471            // If a merge strategy is set, read it and remove it from the value
472            // so it doesn't accidentally end up getting set.
473            if ( is_array( $val ) && isset( $val[self::MERGE_STRATEGY] ) ) {
474                $mergeStrategy = $val[self::MERGE_STRATEGY];
475                unset( $val[self::MERGE_STRATEGY] );
476            } else {
477                $mergeStrategy = 'array_merge';
478            }
479
480            if ( $mergeStrategy === 'provide_default' ) {
481                if ( !array_key_exists( $key, $GLOBALS ) ) {
482                    $GLOBALS[$key] = $val;
483                }
484                continue;
485            }
486
487            // Optimistic: If the global is not set, or is an empty array, replace it entirely.
488            // Will be O(1) performance.
489            if ( !array_key_exists( $key, $GLOBALS ) || ( is_array( $GLOBALS[$key] ) && !$GLOBALS[$key] ) ) {
490                $GLOBALS[$key] = $val;
491                continue;
492            }
493
494            if ( !is_array( $GLOBALS[$key] ) || !is_array( $val ) ) {
495                // config setting that has already been overridden, don't set it
496                continue;
497            }
498
499            switch ( $mergeStrategy ) {
500                case 'array_merge_recursive':
501                    $GLOBALS[$key] = array_merge_recursive( $GLOBALS[$key], $val );
502                    break;
503                case 'array_replace_recursive':
504                    $GLOBALS[$key] = array_replace_recursive( $val, $GLOBALS[$key] );
505                    break;
506                case 'array_plus_2d':
507                    $GLOBALS[$key] = wfArrayPlus2d( $GLOBALS[$key], $val );
508                    break;
509                case 'array_plus':
510                    $GLOBALS[$key] += $val;
511                    break;
512                case 'array_merge':
513                    $GLOBALS[$key] = array_merge( $val, $GLOBALS[$key] );
514                    break;
515                default:
516                    throw new UnexpectedValueException( "Unknown merge strategy '$mergeStrategy'" );
517            }
518        }
519
520        if ( isset( $info['autoloaderNS'] ) ) {
521            AutoLoader::registerNamespaces( $info['autoloaderNS'] );
522        }
523
524        if ( isset( $info['autoloaderClasses'] ) ) {
525            AutoLoader::registerClasses( $info['autoloaderClasses'] );
526        }
527
528        foreach ( $info['defines'] as $name => $val ) {
529            if ( !defined( $name ) ) {
530                define( $name, $val );
531            } elseif ( constant( $name ) !== $val ) {
532                throw new UnexpectedValueException(
533                    "$name cannot be re-defined with $val it has already been set with " . constant( $name )
534                );
535            }
536        }
537
538        if ( isset( $info['autoloaderPaths'] ) ) {
539            AutoLoader::loadFiles( $info['autoloaderPaths'] );
540        }
541
542        $this->loaded += $info['credits'];
543        if ( $info['attributes'] ) {
544            if ( !$this->attributes ) {
545                $this->attributes = $info['attributes'];
546            } else {
547                $this->attributes = array_merge_recursive( $this->attributes, $info['attributes'] );
548            }
549        }
550
551        // XXX: SettingsBuilder should really be a parameter to this method.
552        $settings = $this->getSettingsBuilder();
553
554        foreach ( $info['callbacks'] as $name => $cb ) {
555            if ( !is_callable( $cb ) ) {
556                if ( is_array( $cb ) ) {
557                    $cb = '[ ' . implode( ', ', $cb ) . ' ]';
558                }
559                throw new UnexpectedValueException( "callback '$cb' is not callable" );
560            }
561            $cb( $info['credits'][$name], $settings );
562        }
563    }
564
565    /**
566     * Whether a thing has been loaded
567     * @param string $name
568     * @param string $constraint The required version constraint for this dependency
569     * @throws LogicException if a specific constraint is asked for,
570     *                        but the extension isn't versioned
571     * @return bool
572     */
573    public function isLoaded( $name, $constraint = '*' ) {
574        $isLoaded = isset( $this->loaded[$name] );
575        if ( $constraint === '*' || !$isLoaded ) {
576            return $isLoaded;
577        }
578        // if a specific constraint is requested, but no version is set, throw an exception
579        if ( !isset( $this->loaded[$name]['version'] ) ) {
580            $msg = "{$name} does not expose its version, but an extension or a skin"
581                    . " requires: {$constraint}.";
582            throw new LogicException( $msg );
583        }
584
585        return Semver::satisfies( $this->loaded[$name]['version'], $constraint );
586    }
587
588    /**
589     * @param string $name
590     * @return array
591     */
592    public function getAttribute( $name ) {
593        if ( isset( $this->testAttributes[$name] ) ) {
594            return $this->testAttributes[$name];
595        }
596
597        if ( in_array( $name, self::LAZY_LOADED_ATTRIBUTES, true ) ) {
598            return $this->getLazyLoadedAttribute( $name );
599        }
600
601        return $this->attributes[$name] ?? [];
602    }
603
604    /**
605     * Get an attribute value that isn't cached by reading each
606     * extension.json file again
607     * @param string $name
608     * @return array
609     */
610    protected function getLazyLoadedAttribute( $name ) {
611        if ( isset( $this->testAttributes[$name] ) ) {
612            return $this->testAttributes[$name];
613        }
614        if ( isset( $this->lazyAttributes[$name] ) ) {
615            return $this->lazyAttributes[$name];
616        }
617
618        // See if it's in the cache
619        $cache = $this->getCache();
620        $key = $this->makeCacheKey( $cache, 'lazy-attrib', $name );
621        $data = $cache->get( $key );
622        if ( $data !== false ) {
623            $this->lazyAttributes[$name] = $data;
624            return $data;
625        }
626
627        $paths = [];
628        foreach ( $this->loaded as $info ) {
629            // mtime (array value) doesn't matter here since
630            // we're skipping cache, so use a dummy time
631            $paths[$info['path']] = 1;
632        }
633
634        $result = $this->readFromQueue( $paths );
635        $data = $result['attributes'][$name] ?? [];
636        $this->saveToCache( $cache, $result );
637        $this->lazyAttributes[$name] = $data;
638
639        return $data;
640    }
641
642    /**
643     * Force override the value of an attribute during tests
644     *
645     * @param string $name Name of attribute to override
646     * @param array $value Value to set
647     * @return ScopedCallback to reset
648     * @since 1.33
649     */
650    public function setAttributeForTest( $name, array $value ) {
651        // @codeCoverageIgnoreStart
652        if ( !defined( 'MW_PHPUNIT_TEST' ) ) {
653            throw new LogicException( __METHOD__ . ' can only be used in tests' );
654        }
655        // @codeCoverageIgnoreEnd
656        if ( isset( $this->testAttributes[$name] ) ) {
657            throw new InvalidArgumentException( "The attribute '$name' has already been overridden" );
658        }
659        $this->testAttributes[$name] = $value;
660        return new ScopedCallback( function () use ( $name ) {
661            unset( $this->testAttributes[$name] );
662        } );
663    }
664
665    /**
666     * Get credits information about all installed extensions and skins.
667     *
668     * @return array[] Keyed by component name.
669     */
670    public function getAllThings() {
671        return $this->loaded;
672    }
673
674    /**
675     * Fully expand autoloader paths
676     *
677     * @param string $dir
678     * @param string[] $files
679     * @return array
680     */
681    protected static function processAutoLoader( $dir, array $files ) {
682        // Make paths absolute, relative to the JSON file
683        foreach ( $files as &$file ) {
684            $file = "$dir/$file";
685        }
686        return $files;
687    }
688
689    /**
690     * @internal for use by Setup. Hopefully in the future, we find a better way.
691     * @param SettingsBuilder $settingsBuilder
692     */
693    public function setSettingsBuilder( SettingsBuilder $settingsBuilder ) {
694        $this->settingsBuilder = $settingsBuilder;
695    }
696
697    private function getSettingsBuilder(): SettingsBuilder {
698        if ( $this->settingsBuilder === null ) {
699            $this->settingsBuilder = SettingsBuilder::getInstance();
700        }
701        return $this->settingsBuilder;
702    }
703}