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

dg / dresscode / 39714674839

17 Sep 2026 12:34PM UTC coverage: 97.639% (-0.2%) from 97.877%
39714674839

push

github

dg
released version 1.1.0

18571 of 19020 relevant lines covered (97.64%)

0.98 hits per line

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

98.77
/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 run can get the types the configuration or the command line asks for; without them it resolves as if it asked for none */
38
                private readonly bool $typesAvailable = true,
1 ✔
39
                /** the directory a file in `use` of the configuration and its overrides is relative to */
40
                private readonly string $root = '.',
1 ✔
41
        ) {
42
                $this->rulesOfOverrides = new \WeakMap;
1 ✔
43
        }
1 ✔
44

45

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

55

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

85
                                $php = $profile->targets['php'] ?? $php;
1 ✔
86
                                $resolution = $profile->nameResolution ?? $resolution;
1 ✔
87

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

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

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

101
                                foreach ($profile->reportOnly as $name) {
1 ✔
102
                                        $reportOnly += array_fill_keys($this->expandName($name)->paths, true);
1 ✔
103
                                }
104

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

107
                                foreach ($profile->suppressionComments as $pattern => $entry) {
1 ✔
108
                                        // a pattern a later layer says again is that layer's, the example with it
109
                                        $suppressionComments[$pattern] = array_values(array_unique(array_merge(
1 ✔
110
                                                ...array_map($this->resolveSuppressedName(...), $entry['decisions']),
1 ✔
111
                                        )));
112
                                        $suppressionExamples[$pattern] = $entry['example'];
1 ✔
113
                                }
114

115
                        } catch (ConfigurationException $e) {
1 ✔
116
                                throw self::locate($e, $layer);
1 ✔
117
                        }
118
                }
119

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

139
                        $this->rulesOfOverrides[$config] = $ofOverrides;
1 ✔
140
                }
141

142
                $ofOverrides = $this->rulesOfOverrides[$config] ?? [];
1 ✔
143

144
                [$phpTarget, $phpVersion] = $this->raiseToMinPhpVersion($php ?? $phpTarget);
1 ✔
145
                $resolver = new DecisionResolver(
1 ✔
146
                        $this->getCatalogue(),
1 ✔
147
                        $phpTarget,
148
                        typesAnalyzed: $typeAnalysis !== null && $this->typesAvailable,
1 ✔
149
                        certainNames: $resolution === 'certain',
1 ✔
150
                );
151
                $decisions = $resolver->resolve($decisionLayers);
1 ✔
152
                $values = $resolver->createValues($decisions, $narrowed === null ? null : $this->collectSelection($narrowed));
1 ✔
153

154
                $factories = $config->getRuleFactories() + $this->registry->ruleFactories;
1 ✔
155
                $byRule = $active = $inactive = [];
1 ✔
156
                foreach ($decisions as $path => $decision) {
1 ✔
157
                        foreach ($decision->rules as $class) {
1 ✔
158
                                $byRule[$class][$path] = $decision;
1 ✔
159
                        }
160
                }
161

162
                foreach ($this->getCatalogue()->getRuleOrder() as $class) {
1 ✔
163
                        $resolved = $this->resolveRule(
1 ✔
164
                                $class,
165
                                $byRule[$class] ?? [],
1 ✔
166
                                $resolver->findRuleReason($class),
1 ✔
167
                                $values,
168
                                $phpTarget,
169
                                $factories[$class] ?? null,
1 ✔
170
                                isset($fixRisky[$class]),
1 ✔
171
                                isset($ofOverrides[$class]),
1 ✔
172
                        );
173
                        $resolved->isActive() ? $active[$class] = $resolved : $inactive[$class] = $resolved;
1 ✔
174
                }
175

176
                $this->warnRepeated($repeated);
1 ✔
177
                if ($overrides === []) {
1 ✔
178
                        $narrowed === null
1 ✔
179
                                ? $this->checkFixPolicies($config, $active, $ofOverrides)
1 ✔
180
                                : $this->checkOnly($narrowed, $active, $inactive, $ofOverrides, $use);
1 ✔
181
                }
182

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

212

213
        /**
214
         * The decisions each of the three standards makes, resolved alone.
215
         * @return array<string, array<string, ResolvedDecision>>  standard => path => its decision
216
         * @throws ConfigurationException
217
         */
218
        public function resolveStandards(string $phpTarget): array
219
        {
220
                $standards = [];
1 ✔
221
                foreach (CorePlugin::Standards as $standard) {
1 ✔
222
                        $standards[$standard] = $this->resolve(new Config(use: ["dresscode/$standard"]), $phpTarget)->decisions;
1 ✔
223
                }
224

225
                return $standards;
1 ✔
226
        }
227

228

229
        /**
230
         * The catalogue of the registered rules, as the registry keeps it.
231
         * @throws ConfigurationException
232
         */
233
        public function getCatalogue(): Catalogue
234
        {
235
                return $this->registry->getCatalogue();
1 ✔
236
        }
237

238

239
        /**
240
         * A rule runs where one of its requirements or facts takes effect and lies in the mask of the run; otherwise it says
241
         * why it does not. A preset may decide what cannot run here; a project deciding it asked for what it cannot get,
242
         * which is an error for the types and a warning for the target, unless another rule of the decision runs.
243
         * @param  class-string<Rule>  $class
244
         * @param  array<string, ResolvedDecision>  $decisions  those the rule declares
245
         * @param  ?InactiveReason  $ruleReason  why the rule does not run whatever its values
246
         * @param  ?\Closure(): Rule  $factory
247
         * @throws ConfigurationException
248
         */
249
        private function resolveRule(
250
                string $class,
251
                array $decisions,
252
                ?InactiveReason $ruleReason,
253
                Values $values,
254
                string $phpTarget,
255
                ?\Closure $factory,
256
                bool $fixRisky,
257
                bool $ofOverride,
258
        ): ResolvedRule
259
        {
260
                $own = array_filter($decisions, fn(ResolvedDecision $decision) => $decision->decision->kind !== DecisionKind::Parameter);
1 ✔
261
                $effective = $ruleReason === null ? array_filter($own, fn(ResolvedDecision $decision) => $decision->inactive === null) : [];
1 ✔
262
                $asked = array_filter($own, function (ResolvedDecision $decision): bool {
1 ✔
263
                        $top = $decision->findTopLayer();
1 ✔
264
                        return $top?->origin?->isProject() === true && !$top->isKept() && $decision->inactive !== null;
1 ✔
265
                });
1 ✔
266
                // a value a group gives a decision its target leaves out is skipped silently, as one of a preset; the types never are
267
                $named = array_keys(array_filter($asked, fn(ResolvedDecision $decision) => !$decision->cascaded));
1 ✔
268
                $asked = array_keys($asked);
1 ✔
269
                $info = RuleInfo::fromRule($class);
1 ✔
270
                if ($asked !== [] && $ruleReason === InactiveReason::Types) {
1 ✔
271
                        throw new ConfigurationException($this->typesAvailable
1 ✔
272
                                ? "Decision `$asked[0]` needs the types of the code; set `typeAnalysis: phpstan` in the configuration and install `phpstan/phpstan` beside DressCode."
1 ✔
273
                                : "Decision `$asked[0]` needs the types of the code, but `phpstan/phpstan` is not installed beside DressCode.", docs: 'types#enable');
1 ✔
274
                } elseif ($named !== [] && $ruleReason === InactiveReason::Php) {
1 ✔
275
                        $this->warnings[$class] = "Decision `$named[0]` needs PHP {$info->requires['php']} and the target is $phpTarget; skipped.";
1 ✔
276
                }
277

278
                [$reason, $message] = match (true) {
1 ✔
279
                        $effective !== []
280
                                && array_any($effective, fn(ResolvedDecision $decision) => $values->isSelected($decision->decision->path)) => [
1 ✔
281
                                        null,
1 ✔
282
                                        null,
283
                                ],
284
                        $effective !== [] => [InactiveReason::Narrowed, 'the run is narrowed to other decisions'],
1 ✔
285
                        $ruleReason === InactiveReason::Php => [
1 ✔
286
                                InactiveReason::Php,
1 ✔
287
                                "it needs PHP {$info->requires['php']} and the target is $phpTarget",
1 ✔
288
                        ],
289
                        $ruleReason === InactiveReason::Types
1 ✔
290
                                && array_any(
1 ✔
291
                                        $own,
292
                                        fn(ResolvedDecision $decision) => $decision->decision->isRequirement() && !$decision->value->isKept(),
1 ✔
293
                                ) => [InactiveReason::Types, $this->typesAvailable
1 ✔
294
                                        ? 'it needs the types of the code and the configuration sets no types'
1 ✔
295
                                        : 'it needs the types of the code and phpstan/phpstan is not installed beside DressCode'],
1 ✔
296
                        array_any($own, fn(ResolvedDecision $decision) => $decision->layers !== []) => [
1 ✔
297
                                InactiveReason::TurnedOff,
1 ✔
298
                                'its decisions are `keep`',
1 ✔
299
                        ],
300
                        $ofOverride => [InactiveReason::OnlyOverride, 'only an override turns it on'],
1 ✔
301
                        default => [InactiveReason::NotMentioned, 'no preset or layer of the configuration names its decisions'],
1 ✔
302
                };
303
                // who said the decisive value: of a decision that takes effect, or of the last one turned off
304
                $said = $reason === null ? $effective : array_filter($own, fn(ResolvedDecision $decision) => $decision->layers !== []);
1 ✔
305
                $top = array_values($said)[0]->layers ?? [];
1 ✔
306
                $source = $top === [] ? null : $top[count($top) - 1]->origin;
1 ✔
307
                return new ResolvedRule($class, $source, $reason, $message, $factory, $fixRisky);
1 ✔
308
        }
309

310

311
        /**
312
         * The severity a layer says of each requirement its names cover, the most specific name winning: a decision, a
313
         * deeper group, a shallower one, a section, the class of a rule, a preset.
314
         * @param  array<string, string>  $names  name => `warning` or `error`
315
         * @return array<string, array{int, string}>  path => the rank of the name and the severity
316
         * @throws ConfigurationException
317
         */
318
        private function collectSeverity(array $names): array
319
        {
320
                $severity = $by = [];
1 ✔
321
                foreach ($names as $name => $value) {
1 ✔
322
                        $expanded = $this->expandName($name);
1 ✔
323
                        $paths = array_filter(
1 ✔
324
                                $expanded->paths,
1 ✔
325
                                fn(string $path) => $this->getCatalogue()->find($path)?->kind !== DecisionKind::Parameter,
1 ✔
326
                        );
327
                        if ($paths === []) {
1 ✔
328
                                throw new ConfigurationException("Name `$name` in `severity` covers no decision that reports; a parameter has no severity of its own.");
1 ✔
329
                        }
330

331
                        $rank = match (true) {
1 ✔
332
                                $expanded->preset => 0,
1 ✔
333
                                $expanded->rule !== null => 1,
1 ✔
334
                                $this->getCatalogue()->find($name) !== null => PHP_INT_MAX,
1 ✔
335
                                default => 1 + count(explode('.', $expanded->name)),
1 ✔
336
                        };
337
                        foreach ($paths as $path) {
1 ✔
338
                                [$was, $wasValue] = $severity[$path] ?? [-1, null];
1 ✔
339
                                if ($was === $rank && $wasValue !== $value) {
1 ✔
340
                                        throw new ConfigurationException("Names `{$by[$path]}` and `$name` in `severity` say two severities of `$path`; name it alone.");
1 ✔
341
                                } elseif ($was < $rank) {
1 ✔
342
                                        $severity[$path] = [$rank, $value];
1 ✔
343
                                        $by[$path] = $name;
1 ✔
344
                                }
345
                        }
346
                }
347

348
                return $severity;
1 ✔
349
        }
350

351

352
        /**
353
         * The requirements whose violations only warn: a severity holds for a decision whose value the layer saying it, or
354
         * one below it, said, so a layer writing the value anew without a severity makes it an error again.
355
         * @param  array<string, ResolvedDecision>  $decisions
356
         * @param  list<Layer>  $layers  from the lowest
357
         * @param  list<array<string, array{int, string}>>  $severities  of each layer, by path
358
         * @return array<string, true>
359
         */
360
        private static function findWarningDecisions(array $decisions, array $layers, array $severities): array
361
        {
362
                $warnings = [];
1 ✔
363
                foreach ($decisions as $path => $decision) {
1 ✔
364
                        $top = $decision->findTopLayer()?->origin;
1 ✔
365
                        $from = $top === null ? 0 : max(0, (int) array_search($top, $layers, strict: true));
1 ✔
366
                        for ($i = count($layers) - 1; $i >= $from; $i--) {
1 ✔
367
                                if (isset($severities[$i][$path])) {
1 ✔
368
                                        if ($severities[$i][$path][1] === 'warning') {
1 ✔
369
                                                $warnings[$path] = true;
1 ✔
370
                                        }
371

372
                                        break;
1 ✔
373
                                }
374
                        }
375
                }
376

377
                return $warnings;
1 ✔
378
        }
379

380

381
        /**
382
         * The paths the decisions of the run are narrowed to, those the names of `only` stand for.
383
         * @param  list<ExpandedName>  $narrowed
384
         * @return list<string>
385
         */
386
        private function collectSelection(array $narrowed): array
387
        {
388
                return array_values(array_unique(array_merge(...array_map(fn(ExpandedName $name) => $name->paths, $narrowed))));
1 ✔
389
        }
390

391

392
        /**
393
         * The decisions a name of `suppressionComments` silences.
394
         * @return list<string>
395
         * @throws ConfigurationException
396
         */
397
        private function resolveSuppressedName(string $name): array
398
        {
399
                return $this->registry->expandSuppressedName($name)
1 ✔
400
                        ?: throw new ConfigurationException("`suppressionComments` names `$name`, which is no decision, section or rule.");
1 ✔
401
        }
402

403

404
        /**
405
         * Adds the functions and the constants the namespaces of a layer declare, each spelled the way the first layer
406
         * naming it spells it.
407
         * @param  array<string, array<string, array{string, string}>>  $symbols
408
         * @param  array{functions: list<string>, constants: list<string>}  $namespaces
409
         */
410
        private static function addSymbols(array &$symbols, array $namespaces, string $layer): void
411
        {
412
                foreach ([[SymbolKind::Function, 'functions'], [SymbolKind::Constant, 'constants']] as [$kind, $key]) {
1 ✔
413
                        foreach ($namespaces[$key] as $name) {
1 ✔
414
                                $symbols[$kind->name][self::toSymbolKey($kind, $name)] ??= [$name, $layer];
1 ✔
415
                        }
416
                }
417
        }
1 ✔
418

419

420
        /**
421
         * The key two spellings of one symbol share, the way PHP reads them: a function in any letter case, a constant
422
         * in any letter case of its namespace but not of its own name.
423
         */
424
        public static function toSymbolKey(SymbolKind $kind, string $name): string
425
        {
426
                $name = ltrim($name, '\\');
1 ✔
427
                $pos = (int) strrpos($name, '\\');
1 ✔
428
                return $kind === SymbolKind::Constant
1 ✔
429
                        ? strtolower(substr($name, 0, $pos)) . substr($name, $pos)
1 ✔
430
                        : strtolower($name);
1 ✔
431
        }
432

433

434
        /**
435
         * The versions the code is checked for and the lowest of them, the one a rule asks about: the target, or the oldest
436
         * DressCode fixes code for, with a warning.
437
         * @return array{string, string}
438
         */
439
        private function raiseToMinPhpVersion(string $target): array
440
        {
441
                $version = Versions::findLowestVersion($target);
1 ✔
442
                if ($version !== null && version_compare($version, Config::MinPhpVersion, '>=')) {
1 ✔
443
                        return [$target, $version];
1 ✔
444
                }
445

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

451

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

458

459
        /** The line ending the decision names; null for the one each file uses most. */
460
        private static function resolveLineEnding(Value $lineEnding): ?string
461
        {
462
                return match ($lineEnding->isKept() ? null : $lineEnding->getWord()) {
1 ✔
463
                        'LF' => "\n",
1 ✔
464
                        'CRLF' => "\r\n",
1 ✔
465
                        default => null,
1 ✔
466
                };
467
        }
468

469

470
        /** The widest line, null for none. */
471
        private static function resolveLineLength(Value $length): ?int
472
        {
473
                return $length->content === 'none' ? null : $length->getCountRange()[0];
1 ✔
474
        }
475

476

477
        /**
478
         * The profiles that decide for a file matching the overrides, lowest first, each with the name an error or an origin
479
         * is told by.
480
         * @param  list<int>  $overrides
481
         * @return list<array{Layer, Profile}>
482
         */
483
        private static function listProfiles(Config $config, array $overrides, ?Profile $commandLine = null): array
484
        {
485
                $profiles = [[new Layer(LayerKind::Configuration), $config]];
1 ✔
486
                foreach ($overrides as $index) {
1 ✔
487
                        $profiles[] = [
1 ✔
488
                                new Layer(LayerKind::Override, implode(', ', $config->overrides[$index]->paths)),
1 ✔
489
                                $config->overrides[$index]->profile,
1 ✔
490
                        ];
491
                }
492

493
                if ($commandLine !== null) {
1 ✔
494
                        $profiles[] = [new Layer(LayerKind::CommandLine), $commandLine];
1 ✔
495
                }
496

497
                return $profiles;
1 ✔
498
        }
499

500

501
        /**
502
         * The profiles as layers, lowest first: each of them above what it uses, in the order it names them, and a preset
503
         * where it is named first; what a profile names that is already laid, it names for nothing.
504
         * @param  list<array{Layer, Profile}>  $profiles  who says it and what it says
505
         * @return array{layers: list<array{Layer, Profile}>, repeated: list<array{Layer, Layer, Layer}>}  the layers, each
506
         *   who says it and what it says; and every entry named for nothing, as its layer, the one that laid it and the one
507
         *   that named it again
508
         * @throws ConfigurationException
509
         */
510
        private function collectLayers(array $profiles): array
511
        {
512
                $layers = $visited = $repeated = [];
1 ✔
513
                foreach ($profiles as [$layer, $profile]) {
1 ✔
514
                        $this->collectUsed($profile, $layer, $layers, $visited, $repeated);
1 ✔
515
                        $layers[] = [$layer, $profile];
1 ✔
516
                }
517

518
                return ['layers' => $layers, 'repeated' => $repeated];
1 ✔
519
        }
520

521

522
        /**
523
         * Lays the presets the profile uses, each above what it uses in turn, once, where it is named first; a preset
524
         * that comes back to itself through what it uses is an error, unlike one named again by another layer, which
525
         * does nothing.
526
         * @param  list<array{Layer, Profile}>  $layers
527
         * @param  array<string, Layer>  $visited  the name of a preset => the layer that laid it
528
         * @param  list<array{Layer, Layer, Layer}>  $repeated
529
         * @throws ConfigurationException
530
         */
531
        private function collectUsed(Profile $profile, Layer $source, array &$layers, array &$visited, array &$repeated): void
532
        {
533
                foreach ($profile->use as $entry) {
1 ✔
534
                        try {
535
                                // a preset by its name, or a file of the same shape by its path, which is its name too
536
                                $name = str_ends_with($entry, '.neon')
1 ✔
537
                                        ? NeonReader::resolvePresetFile($entry, $this->root)
1 ✔
538
                                        : $this->registry->resolvePreset($entry);
1 ✔
539
                                $file = $this->registry->presets[$name] ?? $name;
1 ✔
540
                        } catch (ConfigurationException $e) {
1 ✔
541
                                throw self::locate($e, $source);
1 ✔
542
                        } catch (\InvalidArgumentException $e) {
1 ✔
543
                                throw self::locate(new ConfigurationException($e->getMessage(), previous: $e), $source);
1 ✔
544
                        }
545

546
                        $layer = new Layer(LayerKind::Preset, $name);
1 ✔
547
                        if (in_array($name, $this->using, true)) {
1 ✔
548
                                $cycle = implode('`, `', array_slice($this->using, array_search($name, $this->using, strict: true) + 1));
1 ✔
549
                                throw new ConfigurationException(($file === $name ? 'Preset file' : 'Preset') . " `$name` uses itself through `$cycle`; a preset cannot come back to itself.");
1 ✔
550
                        } elseif (isset($visited[$name])) {
1 ✔
551
                                $repeated[] = [$layer, $visited[$name], $source];
1 ✔
552
                                continue;
1 ✔
553
                        }
554

555
                        $visited[$name] = $source;
1 ✔
556
                        $preset = $this->presetFiles[$file] ??= NeonReader::readPreset($file, $file === $name ? null : $name);
1 ✔
557
                        $this->using[] = $name;
1 ✔
558
                        try {
559
                                $this->collectUsed($preset, $layer, $layers, $visited, $repeated);
1 ✔
560
                        } finally {
1 ✔
561
                                array_pop($this->using);
1 ✔
562
                        }
563

564
                        $layers[] = [$layer, $preset];
1 ✔
565
                }
566
        }
1 ✔
567

568

569
        /**
570
         * An entry named again where a layer below already laid it does nothing, and the run says so.
571
         * @param  list<array{Layer, Layer, Layer}>  $repeated
572
         */
573
        private function warnRepeated(array $repeated): void
574
        {
575
                foreach ($repeated as [$layer, $laidBy, $namedBy]) {
1 ✔
576
                        // what a preset uses is not the project's to change, and the command line reaches what an override does not
577
                        if (
578
                                $namedBy->kind !== LayerKind::Preset
1 ✔
579
                                && !($laidBy->kind === LayerKind::Override && $namedBy->kind === LayerKind::CommandLine)
1 ✔
580
                        ) {
581
                                $this->warnings['repeated ' . $layer->describe() . ' ' . $namedBy->describe()] = ucfirst($namedBy->formatReference())
1 ✔
582
                                        . ' uses ' . $layer->formatReference()
1 ✔
583
                                        . ', which ' . $laidBy->formatReference() . ' already brings; the entry does nothing.';
1 ✔
584
                        }
585
                }
586
        }
1 ✔
587

588

589
        /** An error said in a layer the reader has to be sent to, named in front of it; the configuration needs no pointing at. */
590
        private static function locate(ConfigurationException $e, Layer $layer): ConfigurationException
591
        {
592
                return $layer->kind === LayerKind::Configuration
1 ✔
593
                        ? $e
1 ✔
594
                        : new ConfigurationException(ucfirst($layer->formatReference()) . ": {$e->getMessage()}", $e->docs, $e);
1 ✔
595
        }
596

597

598
        /**
599
         * The rules a run narrowed by `only` keeps, per name it was given.
600
         * @param  list<string>  $names
601
         * @return list<ExpandedName>
602
         * @throws ConfigurationException
603
         */
604
        private function resolveOnly(array $names): array
605
        {
606
                return array_map($this->expandName(...), $names);
1 ✔
607
        }
608

609

610
        /**
611
         * What a name of `only`, `fixRisky` or `severity` stands for: a decision or a section for the decisions under it, a
612
         * rule for its own, a preset for every decision it and its parents make, each with the rules owning them. A section
613
         * named like a preset is written `section.*`, the bare name being refused as both.
614
         * @throws ConfigurationException
615
         */
616
        private function expandName(string $name): ExpandedName
617
        {
618
                $section = str_ends_with($name, '.*') ? substr($name, 0, -2) : null;
1 ✔
619
                $paths = array_keys($this->getCatalogue()->getDecisionsUnder($section ?? $name));
1 ✔
620
                $preset = $paths !== [] && $section === null && !str_contains($name, '.') ? $this->registry->findPreset($name) : null;
1 ✔
621
                if ($preset !== null) {
1 ✔
622
                        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 ✔
623
                }
624

625
                $preset = null;
1 ✔
626
                if ($paths === [] && $section !== null) {
1 ✔
627
                        throw new ConfigurationException("Unknown section `$section`.");
1 ✔
628
                } elseif ($paths === [] && in_array($name, $this->registry->rules, true)) {
1 ✔
629
                        $requirements = array_filter(
1 ✔
630
                                $this->getCatalogue()->getDecisionsOf($name),
1 ✔
631
                                fn(Decision $decision) => $decision->kind !== DecisionKind::Parameter,
1 ✔
632
                        );
633
                        return new ExpandedName($name, false, $name, [$name], array_keys($requirements));
1 ✔
634
                } elseif ($paths === [] && class_exists($name) && is_subclass_of($name, Rule::class)) {
1 ✔
635
                        throw new ConfigurationException("Rule `$name` is not registered; add it to `rules` of the configuration.");
1 ✔
636
                } elseif ($paths === []) {
1 ✔
637
                        $preset = $this->registry->findPreset($name) ?? throw $this->registry->createUnknownNameException($name);
1 ✔
638
                        foreach ($this->collectLayers([[new Layer(LayerKind::Caller), new Profile(use: [$preset])]])['layers'] as [, $profile]) {
1 ✔
639
                                array_push($paths, ...$this->collectPaths($profile->decisions));
1 ✔
640
                        }
641
                }
642

643
                $paths = array_values(array_unique($paths));
1 ✔
644
                $rules = array_merge(...array_map($this->getCatalogue()->getRulesOf(...), $paths));
1 ✔
645
                return new ExpandedName($preset ?? $section ?? $name, $preset !== null, null, array_values(array_unique($rules)), $paths);
1 ✔
646
        }
647

648

649
        /**
650
         * The paths of the decisions a layer makes, every one under a structure it makes as a whole.
651
         * @param  array<mixed>  $tree
652
         * @return list<string>
653
         */
654
        private function collectPaths(array $tree, string $prefix = ''): array
655
        {
656
                $paths = [];
1 ✔
657
                foreach ($tree as $key => $value) {
1 ✔
658
                        $path = $prefix . $key;
1 ✔
659
                        if ($this->getCatalogue()->find($path) !== null) {
1 ✔
660
                                $paths[] = $path;
1 ✔
661
                        } elseif (is_array($value)) {
1 ✔
662
                                array_push($paths, ...$this->collectPaths($value, "$path."));
1 ✔
663
                        } else {
664
                                array_push($paths, ...array_keys($this->getCatalogue()->getDecisionsUnder($path)));
1 ✔
665
                        }
666
                }
667

668
                return $paths;
1 ✔
669
        }
670

671

672
        /**
673
         * A name of `only` that lets in nothing that runs would make a run that checks nothing and says it is clean, and
674
         * a section or a preset of which only some rules run here would make one that checks less than it says; a rule that
675
         * runs only where an override enables it counts as running.
676
         * @param  list<ExpandedName>  $narrowed
677
         * @param  array<class-string<Rule>, ResolvedRule>  $active
678
         * @param  array<class-string<Rule>, ResolvedRule>  $inactive
679
         * @param  array<class-string<Rule>, true>  $ofOverrides
680
         * @param  list<string>  $use  the presets the run lays
681
         * @throws ConfigurationException
682
         */
683
        private function checkOnly(array $narrowed, array $active, array $inactive, array $ofOverrides, array $use): void
684
        {
685
                $skipped = [];
1 ✔
686
                foreach ($narrowed as $expanded) {
1 ✔
687
                        [$class, $rules] = [$expanded->rule, $expanded->rules];
1 ✔
688
                        $running = array_filter($rules, fn(string $rule) => isset($active[$rule]) || isset($ofOverrides[$rule]));
1 ✔
689
                        // a name whose rules the project cannot run, too new for its target, is skipped, so that one command of
690
                        // `--only` serves projects of several targets
691
                        if (
692
                                !$running
1 ✔
693
                                && $rules !== []
1 ✔
694
                                && array_all($rules, fn(string $rule) => ($inactive[$rule]->inactiveReason ?? null) === InactiveReason::Php)
1 ✔
695
                        ) {
696
                                $skipped[$expanded->name] = [
1 ✔
697
                                        'Option `--only` names ' . ($class === null ? $expanded->format() : "rule `$class`")
1 ✔
698
                                        . ", which cannot run here: {$inactive[$rules[array_key_first($rules)]]->inactiveMessage}",
1 ✔
699
                                        $rules,
1 ✔
700
                                ];
701
                                continue;
1 ✔
702
                        }
703

704
                        if ($class !== null) {
1 ✔
705
                                if (!$running) {
1 ✔
706
                                        throw new ConfigurationException("Option `--only` names rule `$class`, which cannot run here: {$inactive[$class]->inactiveMessage}.");
1 ✔
707
                                }
708

709
                                continue;
1 ✔
710
                        }
711

712
                        // left out is what the project turned off, and where it does not use the name what the name would bring; a rule
713
                        // its standard turns off or that cannot run here, too new for the target or needing the types, is not
714
                        $collective = PluginRegistry::abbreviate($expanded->name);
1 ✔
715
                        $laid = in_array($collective, array_map(PluginRegistry::abbreviate(...), $use), true);
1 ✔
716
                        $left = array_filter($rules, fn(string $rule) => match ($inactive[$rule]->inactiveReason ?? null) {
1 ✔
717
                                InactiveReason::TurnedOff => $inactive[$rule]->source?->isProject() === true,
1 ✔
718
                                InactiveReason::NotMentioned => !$laid,
1 ✔
719
                                default => false,
1 ✔
720
                        });
1 ✔
721
                        // a preset the configuration does not use is brought by `--use`, a decision it does not make by `--set`
722
                        $hint = match (true) {
1 ✔
723
                                $expanded->preset => "`--use $collective --only $collective` runs all its rules",
1 ✔
724
                                $this->getCatalogue()->find($collective) !== null => "`--set $collective=<value>` decides it for the run",
1 ✔
725
                                default => '`--set <path>=<value>` decides one of them for the run',
1 ✔
726
                        };
727
                        if (!$running) {
1 ✔
728
                                throw new ConfigurationException('Option `--only` names ' . $expanded->format() . ', which has no rule that runs here' . ($laid ? '.' : "; $hint."));
1 ✔
729
                        } elseif ($left) {
1 ✔
730
                                // a rule left out is named by its requirements, which is what the user writes
731
                                $names = array_map(fn(string $path) => "`$path`", array_keys(array_filter(
1 ✔
732
                                        array_merge(...array_map(fn(string $rule) => $this->getCatalogue()->getDecisionsOf($rule), array_values($left))),
1 ✔
733
                                        fn(Decision $decision) => $decision->kind !== DecisionKind::Parameter,
1 ✔
734
                                )));
735
                                $this->warnings["only $expanded->name"] = 'Option `--only` keeps ' . count($running) . ' of the ' . (count(
1 ✔
736
                                        $running,
1 ✔
737
                                ) + count(
738
                                        $left,
1 ✔
739
                                ))
740
                                        . ' rules of ' . $expanded->format() . ' that may run here'
1 ✔
741
                                        . ($laid || !$expanded->preset
1 ✔
742
                                                ? ', without ' . implode(', ', array_slice($names, 0, 5)) . (count($names) > 5
1 ✔
743
                                                        ? ' and ' . (count($names) - 5) . ' more'
×
744
                                                        : '')
1 ✔
745
                                                        . ($laid ? ', which the configuration turns off.' : ", which the configuration does not decide; $hint.")
1 ✔
746
                                                : "; $hint.");
1 ✔
747
                        }
748
                }
749

750
                if ($skipped !== [] && count($skipped) === count($narrowed)) {
1 ✔
751
                        throw new ConfigurationException(reset($skipped)[0] . '.');
1 ✔
752
                }
753

754
                foreach ($skipped as $name => [$message, $rules]) {
1 ✔
755
                        // a rule whose decision the project wrote is said to be skipped already
756
                        if (!array_all($rules, fn(string $rule) => isset($this->warnings[$rule]))) {
1 ✔
757
                                $this->warnings["only $name"] = "$message; it is skipped.";
×
758
                        }
759
                }
760
        }
1 ✔
761

762

763
        /**
764
         * A name in fixRisky or reportOnly whose rules run nowhere, not even where an override turns them on, makes the entry
765
         * a line that does nothing, which is worth a warning.
766
         * @param  array<class-string<Rule>, ResolvedRule>  $active
767
         * @param  array<class-string<Rule>, true>  $ofOverrides
768
         * @throws ConfigurationException
769
         */
770
        private function checkFixPolicies(Config $config, array $active, array $ofOverrides): void
771
        {
772
                foreach (self::listProfiles($config, array_keys($config->overrides)) as [$layer, $profile]) {
1 ✔
773
                        foreach (['fixRisky' => $profile->fixRisky, 'reportOnly' => $profile->reportOnly] as $key => $entries) {
1 ✔
774
                                foreach ($entries as $entry) {
1 ✔
775
                                        try {
776
                                                $expanded = $this->expandName($entry);
1 ✔
777
                                        } catch (ConfigurationException $e) {
×
778
                                                throw self::locate($e, $layer);
×
779
                                        }
780

781
                                        if (!array_filter($expanded->rules, fn(string $class) => isset($active[$class]) || isset($ofOverrides[$class]))) {
1 ✔
782
                                                $this->warnings["$key $expanded->name"] = ucfirst($expanded->format())
1 ✔
783
                                                        . " is named in `$key` but runs nowhere; the entry does nothing.";
1 ✔
784
                                        }
785
                                }
786
                        }
787
                }
788
        }
1 ✔
789
}
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