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