• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

dg / dresscode / 39713065381

03 Sep 2026 05:06AM UTC coverage: 97.877% (+0.3%) from 97.606%
39713065381

push

github

dg
released version 1.0.0

17195 of 17568 relevant lines covered (97.88%)

0.98 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

98.69
/src/Config/ConfigResolver.php
1
<?php declare(strict_types=1);
2

3
/**
4
 * This file is part of the DressCode, a coding style and upgrade tool for PHP (https://dresscode.run)
5
 * Copyright (c) 2026 David Grudl (https://davidgrudl.com)
6
 */
7

8
namespace DressCode\Config;
9

10
use DressCode\{Config, ConfigurationException, Decision, DecisionKind, Plugin, Profile, Rule, RuleInfo, Value, Values};
11
use PhpSyntax\SymbolKind;
12
use function count, is_array, is_string;
13

14

15
/**
16
 * Lays the profiles that decide for a file one over another, each above the presets it uses, and resolves every
17
 * decision and the rule owning it once into a ResolvedConfig.
18
 * @internal
19
 */
20
final class ConfigResolver
21
{
22
        /** @var array<string, string> */
23
        private array $warnings = [];
24

25
        /** @var array<string, Profile>  the presets by the path of their file */
26
        private array $presetFiles = [];
27

28
        /** @var list<string>  the preset files being laid, each used by the one before it */
29
        private array $using = [];
30

31
        /** @var \WeakMap<Config, array<class-string<Rule>, true>>  the rules some override of a resolved configuration turns on */
32
        private \WeakMap $rulesOfOverrides;
33

34

35
        public function __construct(
36
                private readonly PluginRegistry $registry,
1 ✔
37
                /** the directory a file in `use` of the configuration and its overrides is relative to */
38
                private readonly string $root = '.',
1 ✔
39
        ) {
40
                $this->rulesOfOverrides = new \WeakMap;
1 ✔
41
        }
1 ✔
42

43

44
        /**
45
         * What the caller should tell the user about the configuration, each thing once.
46
         * @return list<string>
47
         */
48
        public function getWarnings(): array
49
        {
50
                return array_values($this->warnings);
1 ✔
51
        }
52

53

54
        /**
55
         * The configuration as data for a file the given overrides match: what every decision ends up with and where it came
56
         * from, the rules owning those that take effect, and why a rule that does not run does not.
57
         * @param  string  $phpTarget  the versions the code is written for as a Composer constraint, unless a profile says another
58
         * @param  list<int>  $overrides  indexes of the overrides that match the file
59
         * @param  ?Profile  $commandLine  laid over everything else
60
         * @param  ?list<string>  $only  decisions, sections, presets and classes of rules the run is narrowed to
61
         * @throws ConfigurationException
62
         */
63
        public function resolve(
64
                Config $config,
65
                string $phpTarget,
66
                array $overrides = [],
67
                ?Profile $commandLine = null,
68
                ?array $only = null,
69
        ): ResolvedConfig
70
        {
71
                $fixRisky = $fixRiskyPaths = $severities = $use = $suppressionComments = $suppressionExamples = [];
1 ✔
72
                $symbols = [SymbolKind::Function->name => [], SymbolKind::Constant->name => []];
1 ✔
73
                $php = $resolution = null;
1 ✔
74
                $decisionLayers = [];
1 ✔
75
                ['layers' => $collected, 'repeated' => $repeated] = $this->collectLayers(self::listProfiles($config, $overrides, $commandLine));
1 ✔
76
                foreach ($collected as [$layer, $profile]) {
1 ✔
77
                        try {
78
                                if ($layer->kind === LayerKind::Preset) {
1 ✔
79
                                        $use[] = (string) $layer->name;
1 ✔
80
                                }
81

82
                                $php = $profile->targets['php'] ?? $php;
1 ✔
83
                                $resolution = $profile->nameResolution ?? $resolution;
1 ✔
84

85
                                self::addSymbols($symbols, $profile->namespaces, $layer->describe());
1 ✔
86

87
                                if ($profile->decisions !== []) {
1 ✔
88
                                        new DecisionResolver($this->getCatalogue())->checkLayer($profile->decisions);
1 ✔
89
                                        $decisionLayers[] = [$layer, $profile->decisions];
1 ✔
90
                                }
91

92
                                foreach ($profile->fixRisky as $name) {
1 ✔
93
                                        $expanded = $this->expandName($name);
1 ✔
94
                                        $fixRisky += array_fill_keys($expanded->rules, true);
1 ✔
95
                                        $fixRiskyPaths += array_fill_keys($expanded->paths, true);
1 ✔
96
                                }
97

98
                                $severities[] = $this->collectSeverity($profile->severity);
1 ✔
99

100
                                foreach ($profile->suppressionComments as $pattern => $entry) {
1 ✔
101
                                        // a pattern a later layer says again is that layer's, the example with it
102
                                        $suppressionComments[$pattern] = array_values(array_unique(array_merge(
1 ✔
103
                                                ...array_map($this->resolveSuppressedName(...), $entry['decisions']),
1 ✔
104
                                        )));
105
                                        $suppressionExamples[$pattern] = $entry['example'];
1 ✔
106
                                }
107

108
                        } catch (ConfigurationException $e) {
1 ✔
109
                                throw self::locate($e, $layer);
1 ✔
110
                        }
111
                }
112

113
                $narrowed = $only ? $this->resolveOnly($only) : null;
1 ✔
114
                // an override is resolved for a file it matches, so a name or an option it gets wrong would pass unnoticed until
115
                // such a file comes; each of them is resolved together with the configuration
116
                $resolvedOverrides = $overrides === []
1 ✔
117
                        ? array_map(
1 ✔
118
                                fn(int $index) => $this->resolve($config, $phpTarget, [$index], $commandLine, $only),
1 ✔
119
                                array_keys($config->overrides),
1 ✔
120
                        )
121
                        : [];
1 ✔
122
                // the rules some override turns on, whichever file it applies to, kept for the overrides resolved later; those
123
                // resolved above go without, only what runs being read of them
124
                if ($overrides === []) {
1 ✔
125
                        $ofOverrides = [];
1 ✔
126
                        foreach ($resolvedOverrides as $resolvedOverride) {
1 ✔
127
                                foreach ($resolvedOverride->getActiveRules() as $rule) {
1 ✔
128
                                        $ofOverrides[$rule->class] = true;
1 ✔
129
                                }
130
                        }
131

132
                        $this->rulesOfOverrides[$config] = $ofOverrides;
1 ✔
133
                }
134

135
                $ofOverrides = $this->rulesOfOverrides[$config] ?? [];
1 ✔
136

137
                [$phpTarget, $phpVersion] = $this->raiseToMinPhpVersion($php ?? $phpTarget);
1 ✔
138
                $resolver = new DecisionResolver(
1 ✔
139
                        $this->getCatalogue(),
1 ✔
140
                        $phpTarget,
141
                        certainNames: $resolution === 'certain',
1 ✔
142
                );
143
                $decisions = $resolver->resolve($decisionLayers);
1 ✔
144
                $values = $resolver->createValues($decisions, $narrowed === null ? null : $this->collectSelection($narrowed));
1 ✔
145

146
                $factories = $config->getRuleFactories() + $this->registry->ruleFactories;
1 ✔
147
                $byRule = $active = $inactive = [];
1 ✔
148
                foreach ($decisions as $path => $decision) {
1 ✔
149
                        foreach ($decision->rules as $class) {
1 ✔
150
                                $byRule[$class][$path] = $decision;
1 ✔
151
                        }
152
                }
153

154
                foreach ($this->getCatalogue()->getRuleOrder() as $class) {
1 ✔
155
                        $resolved = $this->resolveRule(
1 ✔
156
                                $class,
157
                                $byRule[$class] ?? [],
1 ✔
158
                                $resolver->findRuleReason($class),
1 ✔
159
                                $values,
160
                                $phpTarget,
161
                                $factories[$class] ?? null,
1 ✔
162
                                isset($fixRisky[$class]),
1 ✔
163
                                isset($ofOverrides[$class]),
1 ✔
164
                        );
165
                        $resolved->isActive() ? $active[$class] = $resolved : $inactive[$class] = $resolved;
1 ✔
166
                }
167

168
                $this->warnRepeated($repeated);
1 ✔
169
                if ($overrides === []) {
1 ✔
170
                        $narrowed === null
1 ✔
171
                                ? $this->checkFixRisky($config, $active, $ofOverrides)
1 ✔
172
                                : $this->checkOnly($narrowed, $active, $inactive, $ofOverrides, $use);
1 ✔
173
                }
174

175
                // one entry per symbol, spelled the way the first layer naming it spells it
176
                $bySource = fn(array $entries): array => array_column($entries, 1, 0);
1 ✔
177
                return new ResolvedConfig(
1 ✔
178
                        [...array_values($active), ...array_values($inactive)],
1 ✔
179
                        self::resolveIndent($values->get('indentation.unit')),
1 ✔
180
                        self::resolveLineEnding($values->get('file.lineEnding')),
1 ✔
181
                        $phpVersion,
182
                        $use,
183
                        namespacedFunctions: $bySource($symbols[SymbolKind::Function->name]),
1 ✔
184
                        namespacedConstants: $bySource($symbols[SymbolKind::Constant->name]),
1 ✔
185
                        nameResolution: $resolution ?? 'uncertain',
1 ✔
186
                        lineLength: self::resolveLineLength($values->get('file.lineLength')),
1 ✔
187
                        tabWidth: $values->get('indentation.tabWidth')->getCountRange()[0],
1 ✔
188
                        plugins: array_map(
1 ✔
189
                                fn(string|Plugin $plugin) => is_string($plugin) ? $plugin : $plugin::class,
1 ✔
190
                                [...$config->plugins, ...$commandLine instanceof Config ? $commandLine->plugins : []],
1 ✔
191
                        ),
192
                        suppressionComments: $suppressionComments,
193
                        suppressionExamples: array_filter($suppressionExamples),
1 ✔
194
                        decisions: $decisions,
195
                        values: $values,
196
                        fixRisky: $fixRiskyPaths,
197
                        warningDecisions: self::findWarningDecisions($decisions, array_column($collected, 0), $severities),
1 ✔
198
                        overrides: $resolvedOverrides,
199
                );
200
        }
201

202

203
        /**
204
         * The decisions each of the three standards makes, resolved alone.
205
         * @return array<string, array<string, ResolvedDecision>>  standard => path => its decision
206
         * @throws ConfigurationException
207
         */
208
        public function resolveStandards(string $phpTarget): array
209
        {
210
                $standards = [];
1 ✔
211
                foreach (CorePlugin::Standards as $standard) {
1 ✔
212
                        $standards[$standard] = $this->resolve(new Config(use: ["dresscode/$standard"]), $phpTarget)->decisions;
1 ✔
213
                }
214

215
                return $standards;
1 ✔
216
        }
217

218

219
        /**
220
         * The catalogue of the registered rules, as the registry keeps it.
221
         * @throws ConfigurationException
222
         */
223
        public function getCatalogue(): Catalogue
224
        {
225
                return $this->registry->getCatalogue();
1 ✔
226
        }
227

228

229
        /**
230
         * A rule runs where one of its requirements or facts takes effect and lies in the mask of the run; otherwise it says
231
         * why it does not. A preset may decide what cannot run here; a project deciding it asked for what it cannot get,
232
         * which is a warning for the target, unless another rule of the decision runs.
233
         * @param  class-string<Rule>  $class
234
         * @param  array<string, ResolvedDecision>  $decisions  those the rule declares
235
         * @param  ?InactiveReason  $ruleReason  why the rule does not run whatever its values
236
         * @param  ?\Closure(): Rule  $factory
237
         * @throws ConfigurationException
238
         */
239
        private function resolveRule(
240
                string $class,
241
                array $decisions,
242
                ?InactiveReason $ruleReason,
243
                Values $values,
244
                string $phpTarget,
245
                ?\Closure $factory,
246
                bool $fixRisky,
247
                bool $ofOverride,
248
        ): ResolvedRule
249
        {
250
                $own = array_filter($decisions, fn(ResolvedDecision $decision) => $decision->decision->kind !== DecisionKind::Parameter);
1 ✔
251
                $effective = $ruleReason === null ? array_filter($own, fn(ResolvedDecision $decision) => $decision->inactive === null) : [];
1 ✔
252
                $asked = array_filter($own, function (ResolvedDecision $decision): bool {
1 ✔
253
                        $top = $decision->findTopLayer();
1 ✔
254
                        return $top?->origin?->isProject() === true && !$top->isKept() && $decision->inactive !== null;
1 ✔
255
                });
1 ✔
256
                // a value a group gives a decision its target leaves out is skipped silently, as one of a preset
257
                $named = array_keys(array_filter($asked, fn(ResolvedDecision $decision) => !$decision->cascaded));
1 ✔
258
                $info = RuleInfo::fromRule($class);
1 ✔
259
                if ($named !== [] && $ruleReason === InactiveReason::Php) {
1 ✔
260
                        $this->warnings[$class] = "Decision `$named[0]` needs PHP {$info->requires['php']} and the target is $phpTarget; skipped.";
1 ✔
261
                }
262

263
                [$reason, $message] = match (true) {
1 ✔
264
                        $effective !== []
265
                                && array_any($effective, fn(ResolvedDecision $decision) => $values->isSelected($decision->decision->path)) => [
1 ✔
266
                                        null,
1 ✔
267
                                        null,
268
                                ],
269
                        $effective !== [] => [InactiveReason::Narrowed, 'the run is narrowed to other decisions'],
1 ✔
270
                        $ruleReason === InactiveReason::Php => [
1 ✔
271
                                InactiveReason::Php,
1 ✔
272
                                "it needs PHP {$info->requires['php']} and the target is $phpTarget",
1 ✔
273
                        ],
274
                        array_any($own, fn(ResolvedDecision $decision) => $decision->layers !== []) => [
1 ✔
275
                                InactiveReason::TurnedOff,
1 ✔
276
                                'its decisions are `keep`',
1 ✔
277
                        ],
278
                        $ofOverride => [InactiveReason::OnlyOverride, 'only an override turns it on'],
1 ✔
279
                        default => [InactiveReason::NotMentioned, 'no preset or layer of the configuration names its decisions'],
1 ✔
280
                };
281
                // who said the decisive value: of a decision that takes effect, or of the last one turned off
282
                $said = $reason === null ? $effective : array_filter($own, fn(ResolvedDecision $decision) => $decision->layers !== []);
1 ✔
283
                $top = array_values($said)[0]->layers ?? [];
1 ✔
284
                $source = $top === [] ? null : $top[count($top) - 1]->origin;
1 ✔
285
                return new ResolvedRule($class, $source, $reason, $message, $factory, $fixRisky);
1 ✔
286
        }
287

288

289
        /**
290
         * The severity a layer says of each requirement its names cover, the most specific name winning: a decision, a
291
         * deeper group, a shallower one, a section, the class of a rule, a preset.
292
         * @param  array<string, string>  $names  name => `warning` or `error`
293
         * @return array<string, array{int, string}>  path => the rank of the name and the severity
294
         * @throws ConfigurationException
295
         */
296
        private function collectSeverity(array $names): array
297
        {
298
                $severity = $by = [];
1 ✔
299
                foreach ($names as $name => $value) {
1 ✔
300
                        $expanded = $this->expandName($name);
1 ✔
301
                        $paths = array_filter(
1 ✔
302
                                $expanded->paths,
1 ✔
303
                                fn(string $path) => $this->getCatalogue()->find($path)?->kind !== DecisionKind::Parameter,
1 ✔
304
                        );
305
                        if ($paths === []) {
1 ✔
306
                                throw new ConfigurationException("Name `$name` in `severity` covers no decision that reports; a parameter has no severity of its own.");
1 ✔
307
                        }
308

309
                        $rank = match (true) {
1 ✔
310
                                $expanded->preset => 0,
1 ✔
311
                                $expanded->rule !== null => 1,
1 ✔
312
                                $this->getCatalogue()->find($name) !== null => PHP_INT_MAX,
1 ✔
313
                                default => 1 + count(explode('.', $expanded->name)),
1 ✔
314
                        };
315
                        foreach ($paths as $path) {
1 ✔
316
                                [$was, $wasValue] = $severity[$path] ?? [-1, null];
1 ✔
317
                                if ($was === $rank && $wasValue !== $value) {
1 ✔
318
                                        throw new ConfigurationException("Names `{$by[$path]}` and `$name` in `severity` say two severities of `$path`; name it alone.");
1 ✔
319
                                } elseif ($was < $rank) {
1 ✔
320
                                        $severity[$path] = [$rank, $value];
1 ✔
321
                                        $by[$path] = $name;
1 ✔
322
                                }
323
                        }
324
                }
325

326
                return $severity;
1 ✔
327
        }
328

329

330
        /**
331
         * The requirements whose violations only warn: a severity holds for a decision whose value the layer saying it, or
332
         * one below it, said, so a layer writing the value anew without a severity makes it an error again.
333
         * @param  array<string, ResolvedDecision>  $decisions
334
         * @param  list<Layer>  $layers  from the lowest
335
         * @param  list<array<string, array{int, string}>>  $severities  of each layer, by path
336
         * @return array<string, true>
337
         */
338
        private static function findWarningDecisions(array $decisions, array $layers, array $severities): array
339
        {
340
                $warnings = [];
1 ✔
341
                foreach ($decisions as $path => $decision) {
1 ✔
342
                        $top = $decision->findTopLayer()?->origin;
1 ✔
343
                        $from = $top === null ? 0 : max(0, (int) array_search($top, $layers, strict: true));
1 ✔
344
                        for ($i = count($layers) - 1; $i >= $from; $i--) {
1 ✔
345
                                if (isset($severities[$i][$path])) {
1 ✔
346
                                        if ($severities[$i][$path][1] === 'warning') {
1 ✔
347
                                                $warnings[$path] = true;
1 ✔
348
                                        }
349

350
                                        break;
1 ✔
351
                                }
352
                        }
353
                }
354

355
                return $warnings;
1 ✔
356
        }
357

358

359
        /**
360
         * The paths the decisions of the run are narrowed to, those the names of `only` stand for.
361
         * @param  list<ExpandedName>  $narrowed
362
         * @return list<string>
363
         */
364
        private function collectSelection(array $narrowed): array
365
        {
366
                return array_values(array_unique(array_merge(...array_map(fn(ExpandedName $name) => $name->paths, $narrowed))));
1 ✔
367
        }
368

369

370
        /**
371
         * The decisions a name of `suppressionComments` silences.
372
         * @return list<string>
373
         * @throws ConfigurationException
374
         */
375
        private function resolveSuppressedName(string $name): array
376
        {
377
                return $this->registry->expandSuppressedName($name)
1 ✔
378
                        ?: throw new ConfigurationException("`suppressionComments` names `$name`, which is no decision, section or rule.");
1 ✔
379
        }
380

381

382
        /**
383
         * Adds the functions and the constants the namespaces of a layer declare, each spelled the way the first layer
384
         * naming it spells it.
385
         * @param  array<string, array<string, array{string, string}>>  $symbols
386
         * @param  array{functions: list<string>, constants: list<string>}  $namespaces
387
         */
388
        private static function addSymbols(array &$symbols, array $namespaces, string $layer): void
389
        {
390
                foreach ([[SymbolKind::Function, 'functions'], [SymbolKind::Constant, 'constants']] as [$kind, $key]) {
1 ✔
391
                        foreach ($namespaces[$key] as $name) {
1 ✔
392
                                $symbols[$kind->name][self::toSymbolKey($kind, $name)] ??= [$name, $layer];
1 ✔
393
                        }
394
                }
395
        }
1 ✔
396

397

398
        /**
399
         * The key two spellings of one symbol share, the way PHP reads them: a function in any letter case, a constant
400
         * in any letter case of its namespace but not of its own name.
401
         */
402
        public static function toSymbolKey(SymbolKind $kind, string $name): string
403
        {
404
                $name = ltrim($name, '\\');
1 ✔
405
                $pos = (int) strrpos($name, '\\');
1 ✔
406
                return $kind === SymbolKind::Constant
1 ✔
407
                        ? strtolower(substr($name, 0, $pos)) . substr($name, $pos)
1 ✔
408
                        : strtolower($name);
1 ✔
409
        }
410

411

412
        /**
413
         * The versions the code is checked for and the lowest of them, the one a rule asks about: the target, or the oldest
414
         * DressCode fixes code for, with a warning.
415
         * @return array{string, string}
416
         */
417
        private function raiseToMinPhpVersion(string $target): array
418
        {
419
                $version = Versions::findLowestVersion($target);
1 ✔
420
                if ($version !== null && version_compare($version, Config::MinPhpVersion, '>=')) {
1 ✔
421
                        return [$target, $version];
1 ✔
422
                }
423

424
                $this->warnings['php'] = 'The target PHP ' . ($version ?? $target) . ' is older than PHP ' . Config::MinPhpVersion . ', the oldest DressCode fixes code for;'
1 ✔
425
                        . ' the code is checked as PHP ' . Config::MinPhpVersion . ', so a fix may write syntax the target does not have.';
1 ✔
426
                return [Config::MinPhpVersion, Config::MinPhpVersion];
1 ✔
427
        }
428

429

430
        /** The characters of a level of indentation the unit names, a tab where it is `keep`. */
431
        private static function resolveIndent(Value $unit): string
432
        {
433
                return $unit->isKept() || $unit->getWord() === 'tab' ? "\t" : str_repeat(' ', (int) $unit->getWord());
1 ✔
434
        }
435

436

437
        /** The line ending the decision names; null for the one each file uses most. */
438
        private static function resolveLineEnding(Value $lineEnding): ?string
439
        {
440
                return match ($lineEnding->isKept() ? null : $lineEnding->getWord()) {
1 ✔
441
                        'LF' => "\n",
1 ✔
442
                        'CRLF' => "\r\n",
1 ✔
443
                        default => null,
1 ✔
444
                };
445
        }
446

447

448
        /** The widest line, null for none. */
449
        private static function resolveLineLength(Value $length): ?int
450
        {
451
                return $length->content === 'none' ? null : $length->getCountRange()[0];
1 ✔
452
        }
453

454

455
        /**
456
         * The profiles that decide for a file matching the overrides, lowest first, each with the name an error or an origin
457
         * is told by.
458
         * @param  list<int>  $overrides
459
         * @return list<array{Layer, Profile}>
460
         */
461
        private static function listProfiles(Config $config, array $overrides, ?Profile $commandLine = null): array
462
        {
463
                $profiles = [[new Layer(LayerKind::Configuration), $config]];
1 ✔
464
                foreach ($overrides as $index) {
1 ✔
465
                        $profiles[] = [
1 ✔
466
                                new Layer(LayerKind::Override, implode(', ', $config->overrides[$index]->paths)),
1 ✔
467
                                $config->overrides[$index]->profile,
1 ✔
468
                        ];
469
                }
470

471
                if ($commandLine !== null) {
1 ✔
472
                        $profiles[] = [new Layer(LayerKind::CommandLine), $commandLine];
1 ✔
473
                }
474

475
                return $profiles;
1 ✔
476
        }
477

478

479
        /**
480
         * The profiles as layers, lowest first: each of them above what it uses, in the order it names them, and a preset
481
         * where it is named first; what a profile names that is already laid, it names for nothing.
482
         * @param  list<array{Layer, Profile}>  $profiles  who says it and what it says
483
         * @return array{layers: list<array{Layer, Profile}>, repeated: list<array{Layer, Layer, Layer}>}  the layers, each
484
         *   who says it and what it says; and every entry named for nothing, as its layer, the one that laid it and the one
485
         *   that named it again
486
         * @throws ConfigurationException
487
         */
488
        private function collectLayers(array $profiles): array
489
        {
490
                $layers = $visited = $repeated = [];
1 ✔
491
                foreach ($profiles as [$layer, $profile]) {
1 ✔
492
                        $this->collectUsed($profile, $layer, $layers, $visited, $repeated);
1 ✔
493
                        $layers[] = [$layer, $profile];
1 ✔
494
                }
495

496
                return ['layers' => $layers, 'repeated' => $repeated];
1 ✔
497
        }
498

499

500
        /**
501
         * Lays the presets the profile uses, each above what it uses in turn, once, where it is named first; a preset
502
         * that comes back to itself through what it uses is an error, unlike one named again by another layer, which
503
         * does nothing.
504
         * @param  list<array{Layer, Profile}>  $layers
505
         * @param  array<string, Layer>  $visited  the name of a preset => the layer that laid it
506
         * @param  list<array{Layer, Layer, Layer}>  $repeated
507
         * @throws ConfigurationException
508
         */
509
        private function collectUsed(Profile $profile, Layer $source, array &$layers, array &$visited, array &$repeated): void
510
        {
511
                foreach ($profile->use as $entry) {
1 ✔
512
                        try {
513
                                // a preset by its name, or a file of the same shape by its path, which is its name too
514
                                $name = str_ends_with($entry, '.neon')
1 ✔
515
                                        ? NeonReader::resolvePresetFile($entry, $this->root)
1 ✔
516
                                        : $this->registry->resolvePreset($entry);
1 ✔
517
                                $file = $this->registry->presets[$name] ?? $name;
1 ✔
518
                        } catch (ConfigurationException $e) {
1 ✔
519
                                throw self::locate($e, $source);
1 ✔
520
                        } catch (\InvalidArgumentException $e) {
1 ✔
521
                                throw self::locate(new ConfigurationException($e->getMessage(), previous: $e), $source);
1 ✔
522
                        }
523

524
                        $layer = new Layer(LayerKind::Preset, $name);
1 ✔
525
                        if (in_array($name, $this->using, true)) {
1 ✔
526
                                $cycle = implode('`, `', array_slice($this->using, array_search($name, $this->using, strict: true) + 1));
1 ✔
527
                                throw new ConfigurationException(($file === $name ? 'Preset file' : 'Preset') . " `$name` uses itself through `$cycle`; a preset cannot come back to itself.");
1 ✔
528
                        } elseif (isset($visited[$name])) {
1 ✔
529
                                $repeated[] = [$layer, $visited[$name], $source];
1 ✔
530
                                continue;
1 ✔
531
                        }
532

533
                        $visited[$name] = $source;
1 ✔
534
                        $preset = $this->presetFiles[$file] ??= NeonReader::readPreset($file, $file === $name ? null : $name);
1 ✔
535
                        $this->using[] = $name;
1 ✔
536
                        try {
537
                                $this->collectUsed($preset, $layer, $layers, $visited, $repeated);
1 ✔
538
                        } finally {
1 ✔
539
                                array_pop($this->using);
1 ✔
540
                        }
541

542
                        $layers[] = [$layer, $preset];
1 ✔
543
                }
544
        }
1 ✔
545

546

547
        /**
548
         * An entry named again where a layer below already laid it does nothing, and the run says so.
549
         * @param  list<array{Layer, Layer, Layer}>  $repeated
550
         */
551
        private function warnRepeated(array $repeated): void
552
        {
553
                foreach ($repeated as [$layer, $laidBy, $namedBy]) {
1 ✔
554
                        // what a preset uses is not the project's to change, and the command line reaches what an override does not
555
                        if (
556
                                $namedBy->kind !== LayerKind::Preset
1 ✔
557
                                && !($laidBy->kind === LayerKind::Override && $namedBy->kind === LayerKind::CommandLine)
1 ✔
558
                        ) {
559
                                $this->warnings['repeated ' . $layer->describe() . ' ' . $namedBy->describe()] = ucfirst($namedBy->formatReference())
1 ✔
560
                                        . ' uses ' . $layer->formatReference()
1 ✔
561
                                        . ', which ' . $laidBy->formatReference() . ' already brings; the entry does nothing.';
1 ✔
562
                        }
563
                }
564
        }
1 ✔
565

566

567
        /** An error said in a layer the reader has to be sent to, named in front of it; the configuration needs no pointing at. */
568
        private static function locate(ConfigurationException $e, Layer $layer): ConfigurationException
569
        {
570
                return $layer->kind === LayerKind::Configuration
1 ✔
571
                        ? $e
1 ✔
572
                        : new ConfigurationException(ucfirst($layer->formatReference()) . ": {$e->getMessage()}", $e->docs, $e);
1 ✔
573
        }
574

575

576
        /**
577
         * The rules a run narrowed by `only` keeps, per name it was given.
578
         * @param  list<string>  $names
579
         * @return list<ExpandedName>
580
         * @throws ConfigurationException
581
         */
582
        private function resolveOnly(array $names): array
583
        {
584
                return array_map($this->expandName(...), $names);
1 ✔
585
        }
586

587

588
        /**
589
         * What a name of `only`, `fixRisky` or `severity` stands for: a decision or a section for the decisions under it, a
590
         * rule for its own, a preset for every decision it and its parents make, each with the rules owning them. A section
591
         * named like a preset is written `section.*`, the bare name being refused as both.
592
         * @throws ConfigurationException
593
         */
594
        private function expandName(string $name): ExpandedName
595
        {
596
                $section = str_ends_with($name, '.*') ? substr($name, 0, -2) : null;
1 ✔
597
                $paths = array_keys($this->getCatalogue()->getDecisionsUnder($section ?? $name));
1 ✔
598
                $preset = $paths !== [] && $section === null && !str_contains($name, '.') ? $this->registry->findPreset($name) : null;
1 ✔
599
                if ($preset !== null) {
1 ✔
600
                        throw new ConfigurationException("Name `$name` is both a section of decisions and the preset `$preset`; write `$name.*` for the section or `$preset` for the preset.");
1 ✔
601
                }
602

603
                $preset = null;
1 ✔
604
                if ($paths === [] && $section !== null) {
1 ✔
605
                        throw new ConfigurationException("Unknown section `$section`.");
1 ✔
606
                } elseif ($paths === [] && in_array($name, $this->registry->rules, true)) {
1 ✔
607
                        $requirements = array_filter(
1 ✔
608
                                $this->getCatalogue()->getDecisionsOf($name),
1 ✔
609
                                fn(Decision $decision) => $decision->kind !== DecisionKind::Parameter,
1 ✔
610
                        );
611
                        return new ExpandedName($name, false, $name, [$name], array_keys($requirements));
1 ✔
612
                } elseif ($paths === [] && class_exists($name) && is_subclass_of($name, Rule::class)) {
1 ✔
613
                        throw new ConfigurationException("Rule `$name` is not registered; add it to `rules` of the configuration.");
1 ✔
614
                } elseif ($paths === []) {
1 ✔
615
                        $preset = $this->registry->findPreset($name) ?? throw $this->registry->createUnknownNameException($name);
1 ✔
616
                        foreach ($this->collectLayers([[new Layer(LayerKind::Caller), new Profile(use: [$preset])]])['layers'] as [, $profile]) {
1 ✔
617
                                array_push($paths, ...$this->collectPaths($profile->decisions));
1 ✔
618
                        }
619
                }
620

621
                $paths = array_values(array_unique($paths));
1 ✔
622
                $rules = array_merge(...array_map($this->getCatalogue()->getRulesOf(...), $paths));
1 ✔
623
                return new ExpandedName($preset ?? $section ?? $name, $preset !== null, null, array_values(array_unique($rules)), $paths);
1 ✔
624
        }
625

626

627
        /**
628
         * The paths of the decisions a layer makes, every one under a structure it makes as a whole.
629
         * @param  array<mixed>  $tree
630
         * @return list<string>
631
         */
632
        private function collectPaths(array $tree, string $prefix = ''): array
633
        {
634
                $paths = [];
1 ✔
635
                foreach ($tree as $key => $value) {
1 ✔
636
                        $path = $prefix . $key;
1 ✔
637
                        if ($this->getCatalogue()->find($path) !== null) {
1 ✔
638
                                $paths[] = $path;
1 ✔
639
                        } elseif (is_array($value)) {
1 ✔
640
                                array_push($paths, ...$this->collectPaths($value, "$path."));
1 ✔
641
                        } else {
642
                                array_push($paths, ...array_keys($this->getCatalogue()->getDecisionsUnder($path)));
1 ✔
643
                        }
644
                }
645

646
                return $paths;
1 ✔
647
        }
648

649

650
        /**
651
         * A name of `only` that lets in nothing that runs would make a run that checks nothing and says it is clean, and
652
         * a section or a preset of which only some rules run here would make one that checks less than it says; a rule that
653
         * runs only where an override enables it counts as running.
654
         * @param  list<ExpandedName>  $narrowed
655
         * @param  array<class-string<Rule>, ResolvedRule>  $active
656
         * @param  array<class-string<Rule>, ResolvedRule>  $inactive
657
         * @param  array<class-string<Rule>, true>  $ofOverrides
658
         * @param  list<string>  $use  the presets the run lays
659
         * @throws ConfigurationException
660
         */
661
        private function checkOnly(array $narrowed, array $active, array $inactive, array $ofOverrides, array $use): void
662
        {
663
                $skipped = [];
1 ✔
664
                foreach ($narrowed as $expanded) {
1 ✔
665
                        [$class, $rules] = [$expanded->rule, $expanded->rules];
1 ✔
666
                        $running = array_filter($rules, fn(string $rule) => isset($active[$rule]) || isset($ofOverrides[$rule]));
1 ✔
667
                        // a name whose rules the project cannot run, too new for its target, is skipped, so that one command of
668
                        // `--only` serves projects of several targets
669
                        if (
670
                                !$running
1 ✔
671
                                && $rules !== []
1 ✔
672
                                && array_all($rules, fn(string $rule) => ($inactive[$rule]->inactiveReason ?? null) === InactiveReason::Php)
1 ✔
673
                        ) {
674
                                $skipped[$expanded->name] = [
1 ✔
675
                                        'Option `--only` names ' . ($class === null ? $expanded->format() : "rule `$class`")
1 ✔
676
                                        . ", which cannot run here: {$inactive[$rules[array_key_first($rules)]]->inactiveMessage}",
1 ✔
677
                                        $rules,
1 ✔
678
                                ];
679
                                continue;
1 ✔
680
                        }
681

682
                        if ($class !== null) {
1 ✔
683
                                if (!$running) {
1 ✔
684
                                        throw new ConfigurationException("Option `--only` names rule `$class`, which cannot run here: {$inactive[$class]->inactiveMessage}.");
1 ✔
685
                                }
686

687
                                continue;
1 ✔
688
                        }
689

690
                        // left out is what the project turned off, and where it does not use the name what the name would bring; a rule
691
                        // its standard turns off or that cannot run here, too new for the target, is not
692
                        $collective = PluginRegistry::abbreviate($expanded->name);
1 ✔
693
                        $laid = in_array($collective, array_map(PluginRegistry::abbreviate(...), $use), true);
1 ✔
694
                        $left = array_filter($rules, fn(string $rule) => match ($inactive[$rule]->inactiveReason ?? null) {
1 ✔
695
                                InactiveReason::TurnedOff => $inactive[$rule]->source?->isProject() === true,
1 ✔
696
                                InactiveReason::NotMentioned => !$laid,
1 ✔
697
                                default => false,
1 ✔
698
                        });
1 ✔
699
                        // a preset the configuration does not use is brought by `--use`, a decision it does not make by `--set`
700
                        $hint = match (true) {
1 ✔
701
                                $expanded->preset => "`--use $collective --only $collective` runs all its rules",
1 ✔
702
                                $this->getCatalogue()->find($collective) !== null => "`--set $collective=<value>` decides it for the run",
1 ✔
703
                                default => '`--set <path>=<value>` decides one of them for the run',
1 ✔
704
                        };
705
                        if (!$running) {
1 ✔
706
                                throw new ConfigurationException('Option `--only` names ' . $expanded->format() . ', which has no rule that runs here' . ($laid ? '.' : "; $hint."));
1 ✔
707
                        } elseif ($left) {
1 ✔
708
                                // a rule left out is named by its requirements, which is what the user writes
709
                                $names = array_map(fn(string $path) => "`$path`", array_keys(array_filter(
1 ✔
710
                                        array_merge(...array_map(fn(string $rule) => $this->getCatalogue()->getDecisionsOf($rule), array_values($left))),
1 ✔
711
                                        fn(Decision $decision) => $decision->kind !== DecisionKind::Parameter,
1 ✔
712
                                )));
713
                                $this->warnings["only $expanded->name"] = 'Option `--only` keeps ' . count($running) . ' of the ' . (count(
1 ✔
714
                                        $running,
1 ✔
715
                                ) + count(
716
                                        $left,
1 ✔
717
                                ))
718
                                        . ' rules of ' . $expanded->format() . ' that may run here'
1 ✔
719
                                        . ($laid || !$expanded->preset
1 ✔
720
                                                ? ', without ' . implode(', ', array_slice($names, 0, 5)) . (count($names) > 5
1 ✔
721
                                                        ? ' and ' . (count($names) - 5) . ' more'
×
722
                                                        : '')
1 ✔
723
                                                        . ($laid ? ', which the configuration turns off.' : ", which the configuration does not decide; $hint.")
1 ✔
724
                                                : "; $hint.");
1 ✔
725
                        }
726
                }
727

728
                if ($skipped !== [] && count($skipped) === count($narrowed)) {
1 ✔
729
                        throw new ConfigurationException(reset($skipped)[0] . '.');
1 ✔
730
                }
731

732
                foreach ($skipped as $name => [$message, $rules]) {
1 ✔
733
                        // a rule whose decision the project wrote is said to be skipped already
734
                        if (!array_all($rules, fn(string $rule) => isset($this->warnings[$rule]))) {
1 ✔
735
                                $this->warnings["only $name"] = "$message; it is skipped.";
×
736
                        }
737
                }
738
        }
1 ✔
739

740

741
        /**
742
         * A name in fixRisky whose rules run nowhere, not even where an override turns them on, makes the entry a line that
743
         * does nothing, which is worth a warning.
744
         * @param  array<class-string<Rule>, ResolvedRule>  $active
745
         * @param  array<class-string<Rule>, true>  $ofOverrides
746
         * @throws ConfigurationException
747
         */
748
        private function checkFixRisky(Config $config, array $active, array $ofOverrides): void
749
        {
750
                foreach (self::listProfiles($config, array_keys($config->overrides)) as [$layer, $profile]) {
1 ✔
751
                        foreach ($profile->fixRisky as $entry) {
1 ✔
752
                                try {
753
                                        $expanded = $this->expandName($entry);
1 ✔
754
                                } catch (ConfigurationException $e) {
×
755
                                        throw self::locate($e, $layer);
×
756
                                }
757

758
                                if (!array_filter($expanded->rules, fn(string $class) => isset($active[$class]) || isset($ofOverrides[$class]))) {
1 ✔
759
                                        $this->warnings["fixRisky $expanded->name"] = ucfirst($expanded->format())
1 ✔
760
                                                . ' is named in `fixRisky` but runs nowhere; the entry does nothing.';
1 ✔
761
                                }
762
                        }
763
                }
764
        }
1 ✔
765
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc