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