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