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

dg / dresscode / 39600608269

21 Oct 2026 12:24PM UTC coverage: 97.597% (-0.006%) from 97.603%
39600608269

push

github

dg
upgrading: a name of only, fixRisky and severity stands for a library too, for the files about it

A package shipping upgrading files stands for what all its files set, `dresscode/upgrade-nette` for twenty libraries;
the name of one of them, `nette/forms`, now stands for the files about it, whichever package ships them, and `php`
for the data of PHP. A name that is both a package shipping files and a library the files of another package are
about is an error rather than a choice of one of them, which would change its meaning once such a package is
installed.

17 of 17 new or added lines in 1 file covered. (100.0%)

65 existing lines in 13 files now uncovered.

24855 of 25467 relevant lines covered (97.6%)

0.98 hits per line

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

99.52
/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 DressCode\Domains\Map;
12
use PhpSyntax\SymbolKind;
13
use function count, is_array, is_string;
14

15

16
/**
17
 * Lays the profiles that decide for a file one over another, each above the presets it uses, what the installed
18
 * packages say under them all, and resolves every decision and the rule owning it once into a ResolvedConfig.
19
 * @internal
20
 */
21
final class ConfigResolver
22
{
23
        /** the decision that lays the maps of the upgrading data of the packages under those of the project */
24
        private const PackagesDecision = 'upgrading.packages';
25

26
        /** @var array<string, string> */
27
        private array $warnings = [];
28

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

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

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

38

39
        public function __construct(
40
                private readonly PluginRegistry $registry,
1 ✔
41
                /**
42
                 * @var list<UpgradingData>  what the installed packages say, the maps of each laid under the project where it says
43
                 * `upgrading.packages: yes`
44
                 */
45
                private readonly array $upgradingData = [],
1 ✔
46
                /** the packages the project stands on, which decide whether a rule requiring one runs */
47
                private readonly ProjectPackages $project = new ProjectPackages,
1 ✔
48
                /** the run can get the types the configuration or the command line asks for; without them it resolves as if it asked for none */
49
                private readonly bool $typesAvailable = true,
1 ✔
50
                /** the directory a file in `use` of the configuration and its overrides is relative to */
51
                private readonly string $root = '.',
1 ✔
52
                /** @var list<string>  the packages whose plugins the project does not let in, so the maps of their rules are unknown */
53
                private readonly array $pluginsSkipped = [],
1 ✔
54
                /**
55
                 * @var list<string>  presets laid over those the configuration uses and beneath its own decisions and its overrides,
56
                 * so that the project keeps the last word, as `upgrade` lays its sets
57
                 */
58
                private readonly array $laid = [],
1 ✔
59
        ) {
60
                $this->rulesOfOverrides = new \WeakMap;
1 ✔
61
        }
1 ✔
62

63

64
        /**
65
         * What the caller should tell the user about the configuration, each thing once.
66
         * @return list<string>
67
         */
68
        public function getWarnings(): array
69
        {
70
                return array_values($this->warnings);
1 ✔
71
        }
72

73

74
        /**
75
         * The configuration as data for a file the given overrides match: what every decision ends up with and where it came
76
         * from, the rules owning those that take effect, and why a rule that does not run does not.
77
         * @param  string  $phpTarget  the versions the code is written for as a Composer constraint, unless a profile says another
78
         * @param  list<int>  $overrides  indexes of the overrides that match the file
79
         * @param  ?Profile  $commandLine  laid over everything else
80
         * @param  ?list<string>  $only  decisions, sections, presets and classes of rules the run is narrowed to
81
         * @throws ConfigurationException
82
         */
83
        public function resolve(
84
                Config $config,
85
                string $phpTarget,
86
                array $overrides = [],
87
                ?Profile $commandLine = null,
88
                ?array $only = null,
89
        ): ResolvedConfig
90
        {
91
                $fixRisky = $fixRiskyPaths = $reportOnly = $severities = $use = $suppressionComments = $suppressionExamples = [];
1 ✔
92
                $symbols = [SymbolKind::Function->name => [], SymbolKind::Constant->name => []];
1 ✔
93
                $php = $resolution = null;
1 ✔
94
                $typeAnalysis = ($commandLine instanceof Config ? $commandLine->typeAnalysis : null) ?? $config->typeAnalysis;
1 ✔
95
                $decisionLayers = [];
1 ✔
96
                // what the packages declare lies under every layer, which may add to it
97
                foreach ($this->upgradingData as $package) {
1 ✔
98
                        self::addSymbols($symbols, $package->namespaces, $package->layer->describe());
1 ✔
99
                }
100

101
                ['layers' => $collected, 'repeated' => $repeated] = $this->collectLayers(self::listProfiles($config, $overrides, $commandLine));
1 ✔
102
                foreach ($collected as [$layer, $profile]) {
1 ✔
103
                        try {
104
                                if ($layer->kind === LayerKind::Preset) {
1 ✔
105
                                        $use[] = (string) $layer->name;
1 ✔
106
                                }
107

108
                                $php = $profile->targets['php'] ?? $php;
1 ✔
109
                                $resolution = $profile->nameResolution ?? $resolution;
1 ✔
110

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

113
                                if ($profile->decisions !== []) {
1 ✔
114
                                        new DecisionResolver($this->getCatalogue(), translator: $this->registry->translator)->checkLayer($profile->decisions);
1 ✔
115
                                        $decisionLayers[] = [$layer, $profile->decisions];
1 ✔
116
                                }
117

118
                                foreach ($profile->fixRisky as $name) {
1 ✔
119
                                        $expanded = $this->expandName($name);
1 ✔
120
                                        $fixRisky += array_fill_keys($expanded->rules, true);
1 ✔
121
                                        $fixRiskyPaths += array_fill_keys($expanded->paths, true);
1 ✔
122
                                }
123

124
                                foreach ($profile->reportOnly as $name) {
1 ✔
125
                                        $reportOnly += array_fill_keys($this->expandName($name)->paths, true);
1 ✔
126
                                }
127

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

130
                                foreach ($profile->suppressionComments as $pattern => $entry) {
1 ✔
131
                                        // a pattern a later layer says again is that layer's, the example with it
132
                                        $suppressionComments[$pattern] = array_values(array_unique(array_merge(
1 ✔
133
                                                ...array_map($this->resolveSuppressedName(...), $entry['decisions']),
1 ✔
134
                                        )));
135
                                        $suppressionExamples[$pattern] = $entry['example'];
1 ✔
136
                                }
137

138
                        } catch (ConfigurationException $e) {
1 ✔
139
                                throw self::locate($e, $layer);
1 ✔
140
                        }
141
                }
142

143
                $narrowed = $only ? $this->resolveOnly($only) : null;
1 ✔
144
                // an override is resolved for a file it matches, so a name or an option it gets wrong would pass unnoticed until
145
                // such a file comes; each of them is resolved together with the configuration
146
                $resolvedOverrides = $overrides === []
1 ✔
147
                        ? array_map(
1 ✔
148
                                fn(int $index) => $this->resolve($config, $phpTarget, [$index], $commandLine, $only),
1 ✔
149
                                array_keys($config->overrides),
1 ✔
150
                        )
151
                        : [];
1 ✔
152
                // the rules some override turns on, whichever file it applies to, kept for the overrides resolved later; those
153
                // resolved above go without, only what runs being read of them
154
                if ($overrides === []) {
1 ✔
155
                        $ofOverrides = [];
1 ✔
156
                        foreach ($resolvedOverrides as $resolvedOverride) {
1 ✔
157
                                foreach ($resolvedOverride->getActiveRules() as $rule) {
1 ✔
158
                                        $ofOverrides[$rule->class] = true;
1 ✔
159
                                }
160
                        }
161

162
                        $this->rulesOfOverrides[$config] = $ofOverrides;
1 ✔
163
                }
164

165
                $ofOverrides = $this->rulesOfOverrides[$config] ?? [];
1 ✔
166

167
                [$phpTarget, $phpVersion] = $this->raiseToMinPhpVersion($php ?? $phpTarget);
1 ✔
168
                $resolver = new DecisionResolver(
1 ✔
169
                        $this->getCatalogue(),
1 ✔
170
                        $phpTarget,
171
                        $this->project,
1 ✔
172
                        typesAnalyzed: $typeAnalysis !== null && $this->typesAvailable,
1 ✔
173
                        certainNames: $resolution === 'certain',
1 ✔
174
                        translator: $this->registry->translator,
1 ✔
175
                );
176
                $decisions = $resolver->resolve($decisionLayers);
1 ✔
177
                if ($packageLayers = $this->collectPackageMaps($decisions, $narrowed)) {
1 ✔
178
                        $decisions = $resolver->resolve([...$packageLayers, ...$decisionLayers]);
1 ✔
179
                }
180

181
                $values = $resolver->createValues($decisions, $narrowed === null ? null : $this->collectSelection($narrowed));
1 ✔
182
                foreach ($this->getCatalogue()->getDecisions() as $path => $decision) {
1 ✔
183
                        // a map the run is narrowed away from is refused where its entries are wrong all the same, no rule reading it
184
                        if (
185
                                $decision->domain instanceof Map
1 ✔
186
                                && $decision->domain->grammar !== null
1 ✔
187
                                && !$values->isKept($path)
1 ✔
188
                                && !$values->isSelected($path)
1 ✔
189
                        ) {
190
                                $values->readMap($path);
1 ✔
191
                        }
192
                }
193

194
                $factories = $config->getRuleFactories() + $this->registry->ruleFactories;
1 ✔
195
                $byRule = $active = $inactive = [];
1 ✔
196
                foreach ($decisions as $path => $decision) {
1 ✔
197
                        foreach ($decision->rules as $class) {
1 ✔
198
                                $byRule[$class][$path] = $decision;
1 ✔
199
                        }
200
                }
201

202
                foreach ($this->getCatalogue()->getRuleOrder() as $class) {
1 ✔
203
                        $resolved = $this->resolveRule(
1 ✔
204
                                $class,
205
                                $byRule[$class] ?? [],
1 ✔
206
                                $resolver->findRuleReason($class),
1 ✔
207
                                $values,
208
                                $phpTarget,
209
                                $factories[$class] ?? null,
1 ✔
210
                                isset($fixRisky[$class]),
1 ✔
211
                                isset($ofOverrides[$class]),
1 ✔
212
                        );
213
                        $resolved->isActive() ? $active[$class] = $resolved : $inactive[$class] = $resolved;
1 ✔
214
                }
215

216
                $this->warnRepeated($repeated);
1 ✔
217
                if ($overrides === []) {
1 ✔
218
                        $narrowed === null
1 ✔
219
                                ? $this->checkFixPolicies($config, $active, $ofOverrides)
1 ✔
220
                                : $this->checkOnly($narrowed, $active, $inactive, $ofOverrides, $use);
1 ✔
221
                }
222

223
                // one entry per symbol, spelled the way the first layer naming it spells it
224
                $bySource = fn(array $entries): array => array_column($entries, 1, 0);
1 ✔
225
                return new ResolvedConfig(
1 ✔
226
                        [...array_values($active), ...array_values($inactive)],
1 ✔
227
                        self::resolveIndent($values->get('indentation.unit')),
1 ✔
228
                        self::resolveLineEnding($values->get('file.lineEnding')),
1 ✔
229
                        $phpVersion,
230
                        $use,
231
                        namespacedFunctions: $bySource($symbols[SymbolKind::Function->name]),
1 ✔
232
                        namespacedConstants: $bySource($symbols[SymbolKind::Constant->name]),
1 ✔
233
                        nameResolution: $resolution ?? 'uncertain',
1 ✔
234
                        lineLength: self::resolveLineLength($values->get('file.lineLength')),
1 ✔
235
                        tabWidth: $values->get('indentation.tabWidth')->getCountRange()[0],
1 ✔
236
                        typeAnalysis: $this->typesAvailable ? $typeAnalysis : null,
1 ✔
237
                        packageTargets: array_diff_key($config->targets, ['php' => true]),
1 ✔
238
                        plugins: array_map(
1 ✔
239
                                fn(string|Plugin $plugin) => is_string($plugin) ? $plugin : $plugin::class,
1 ✔
240
                                [...$config->plugins, ...$commandLine instanceof Config ? $commandLine->plugins : []],
1 ✔
241
                        ),
242
                        suppressionComments: $suppressionComments,
243
                        suppressionExamples: array_filter($suppressionExamples),
1 ✔
244
                        decisions: $decisions,
245
                        values: $values,
246
                        fixRisky: $fixRiskyPaths,
247
                        reportOnly: $reportOnly,
248
                        warningDecisions: self::findWarningDecisions($decisions, array_column($collected, 0), $severities),
1 ✔
249
                        overrides: $resolvedOverrides,
250
                );
251
        }
252

253

254
        /**
255
         * The decisions each of the standards makes, resolved alone.
256
         * @return array<string, array<string, ResolvedDecision>>  standard => path => its decision
257
         * @throws ConfigurationException
258
         */
259
        public function resolveStandards(string $phpTarget): array
260
        {
261
                $standards = [];
1 ✔
262
                foreach (CorePlugin::Standards as $standard) {
1 ✔
263
                        $standards[$standard] = $this->resolve(new Config(use: ["dresscode/$standard"]), $phpTarget)->decisions;
1 ✔
264
                }
265

266
                return $standards;
1 ✔
267
        }
268

269

270
        /**
271
         * The catalogue of the registered rules, as the registry keeps it.
272
         * @throws ConfigurationException
273
         */
274
        public function getCatalogue(): Catalogue
275
        {
276
                return $this->registry->getCatalogue();
1 ✔
277
        }
278

279

280
        /**
281
         * The maps of the upgrading files of the installed packages, and the requirements of the rules of their plugins they
282
         * turn on, as layers under every other one, where `upgrading.packages` is `yes`, those of PHP where
283
         * `upgrading.php.deprecatedApi` is not `keep`. A run narrowed by `only` gets the maps of a package it names, and
284
         * those of the others only where another of its names covers the map, or the requirement laying them.
285
         * @param  array<string, ResolvedDecision>  $decisions
286
         * @param  ?list<ExpandedName>  $narrowed
287
         * @return list<array{Layer, array<string, mixed>}>
288
         * @throws ConfigurationException  where a file turns on a rule another plugin or the core brings
289
         */
290
        private function collectPackageMaps(array $decisions, ?array $narrowed): array
291
        {
292
                $named = $covered = [];
1 ✔
293
                foreach ($narrowed ?? [] as $expanded) {
1 ✔
294
                        $expanded->package ? $named[$expanded->name] = true : $covered += array_fill_keys($expanded->paths, true);
1 ✔
295
                }
296

297
                $layers = [];
1 ✔
298
                foreach ($this->upgradingData as $package) {
1 ✔
299
                        $php = $package->package === PackageDiscovery::Php;
1 ✔
300
                        $layer = $package->buildDecisions(function (string $map) use ($package, $php, $narrowed, $named, $covered): ?string {
1 ✔
301
                                $path = $this->findDataPath($map);
1 ✔
302
                                $section = $this->registry->findPackageSection($package->layer->package ?? '');
1 ✔
303
                                if ($path === null && !in_array($package->layer->package, $this->pluginsSkipped, true)) { // the plugin skipped says so
1 ✔
304
                                        $this->warnings[$package->layer->describe() . " $map"] = "Map `$map`, which "
1 ✔
305
                                                . $package->layer->formatReference()
1 ✔
306
                                                . ' sets, is not known to this DressCode; skipped.';
1 ✔
307
                                } elseif (
308
                                        $path !== null
1 ✔
309
                                        && !$this->getCatalogue()->find($path)?->domain instanceof Map
1 ✔
310
                                        && ($section === null || !str_starts_with($path, "$section."))
1 ✔
311
                                ) {
312
                                        throw new ConfigurationException(
1 ✔
313
                                                ucfirst($package->layer->formatReference()) . " turns on `$path`, which is no rule of the plugin of its package.",
1 ✔
314
                                        );
315
                                }
316

317
                                return $path !== null
1 ✔
318
                                        && ($narrowed === null
1 ✔
319
                                                || isset($named[$package->layer->package ?? ''])
1 ✔
320
                                                || isset($named[$package->package])
1 ✔
321
                                                || isset($covered[$path])
1 ✔
322
                                                || ($php && isset($covered[PackageDiscovery::PhpDecision])))
1 ✔
323
                                        ? $path
1 ✔
324
                                        : null;
1 ✔
325
                        });
1 ✔
326
                        $laid = $php
1 ✔
327
                                ? ($decisions[PackageDiscovery::PhpDecision] ?? null)?->value->isKept() === false
1 ✔
328
                                : ($decisions[self::PackagesDecision] ?? null)?->value->getFlag();
1 ✔
329
                        if ($layer !== [] && $laid) {
1 ✔
330
                                $layers[] = [$package->layer, $layer];
1 ✔
331
                        }
332
                }
333

334
                return $layers;
1 ✔
335
        }
336

337

338
        /**
339
         * The decision a key of a section of an upgrading file sets: a map of the libraries by its name, a map of a plugin
340
         * or a requirement it turns on by its path.
341
         */
342
        private function findDataPath(string $key): ?string
343
        {
344
                $decision = $this->getCatalogue()->find($key);
1 ✔
345
                return match (true) {
346
                        $this->getCatalogue()->find("upgrading.api.$key")?->domain instanceof Map => "upgrading.api.$key",
1 ✔
347
                        $decision?->domain instanceof Map, $decision?->isRequirement() === true => $key,
1 ✔
348
                        default => null,
1 ✔
349
                };
350
        }
351

352

353
        /**
354
         * A rule runs where one of its requirements or facts takes effect and lies in the mask of the run; otherwise it says
355
         * why it does not. A preset may decide what cannot run here; a project deciding it asked for what it cannot get,
356
         * which is an error for the types and a warning for the target and for a package, unless another rule of the
357
         * decision runs.
358
         * @param  class-string<Rule>  $class
359
         * @param  array<string, ResolvedDecision>  $decisions  those the rule declares
360
         * @param  ?InactiveReason  $ruleReason  why the rule does not run whatever its values
361
         * @param  ?\Closure(): Rule  $factory
362
         * @throws ConfigurationException
363
         */
364
        private function resolveRule(
365
                string $class,
366
                array $decisions,
367
                ?InactiveReason $ruleReason,
368
                Values $values,
369
                string $phpTarget,
370
                ?\Closure $factory,
371
                bool $fixRisky,
372
                bool $ofOverride,
373
        ): ResolvedRule
374
        {
375
                $own = array_filter($decisions, fn(ResolvedDecision $decision) => $decision->decision->kind !== DecisionKind::Parameter);
1 ✔
376
                $effective = $ruleReason === null ? array_filter($own, fn(ResolvedDecision $decision) => $decision->inactive === null) : [];
1 ✔
377
                $asked = array_filter($own, function (ResolvedDecision $decision): bool {
1 ✔
378
                        $top = $decision->findTopLayer();
1 ✔
379
                        return $top?->origin?->isProject() === true && !$top->isKept() && $decision->inactive !== null;
1 ✔
380
                });
1 ✔
381
                // a value a group gives a decision its target leaves out is skipped silently, as one of a preset; the types never are
382
                $named = array_keys(array_filter($asked, fn(ResolvedDecision $decision) => !$decision->cascaded));
1 ✔
383
                $asked = array_keys($asked);
1 ✔
384
                $info = RuleInfo::fromRule($class);
1 ✔
385
                if ($asked !== [] && $ruleReason === InactiveReason::Types) {
1 ✔
386
                        throw new ConfigurationException($this->typesAvailable
1 ✔
387
                                ? "Decision `$asked[0]` needs the types of the code; set `typeAnalysis: phpstan` in the configuration and install `phpstan/phpstan` beside DressCode."
1 ✔
388
                                : "Decision `$asked[0]` needs the types of the code, but `phpstan/phpstan` is not installed beside DressCode.", docs: 'types#enable');
1 ✔
389
                } elseif ($named !== [] && $ruleReason === InactiveReason::Php) {
1 ✔
390
                        $this->warnings[$class] = "Decision `$named[0]` needs PHP {$info->requires['php']} and the target is $phpTarget; skipped.";
1 ✔
391
                } elseif ($named !== [] && $ruleReason === InactiveReason::Package) {
1 ✔
392
                        $this->warnings[$class] = "Decision `$named[0]` needs "
1 ✔
393
                                . ($this->project->findUnmetRequirement($info->getRequiredPackages()) ?? throw new \LogicException)->format()
1 ✔
394
                                . '; skipped.';
1 ✔
395
                }
396

397
                [$reason, $message] = match (true) {
1 ✔
398
                        $effective !== []
399
                                && array_any($effective, fn(ResolvedDecision $decision) => $values->isSelected($decision->decision->path)) => [
1 ✔
400
                                        null,
1 ✔
401
                                        null,
402
                                ],
403
                        $effective !== [] => [InactiveReason::Narrowed, 'the run is narrowed to other decisions'],
1 ✔
404
                        $ruleReason === InactiveReason::Php => [
1 ✔
405
                                InactiveReason::Php,
1 ✔
406
                                "it needs PHP {$info->requires['php']} and the target is $phpTarget",
1 ✔
407
                        ],
408
                        $ruleReason === InactiveReason::Package => [InactiveReason::Package, 'it needs a package the project does not have'],
1 ✔
409
                        $ruleReason === InactiveReason::Types
1 ✔
410
                                && array_any(
1 ✔
411
                                        $own,
412
                                        fn(ResolvedDecision $decision) => $decision->decision->isRequirement() && !$decision->value->isKept(),
1 ✔
413
                                ) => [InactiveReason::Types, $this->typesAvailable
1 ✔
414
                                        ? 'it needs the types of the code and the configuration sets no types'
1 ✔
415
                                        : 'it needs the types of the code and phpstan/phpstan is not installed beside DressCode'],
1 ✔
416
                        array_any($own, fn(ResolvedDecision $decision) => $decision->layers !== []) => [
1 ✔
417
                                InactiveReason::TurnedOff,
1 ✔
418
                                'its decisions are `keep`',
1 ✔
419
                        ],
420
                        $ofOverride => [InactiveReason::OnlyOverride, 'only an override turns it on'],
1 ✔
421
                        default => [InactiveReason::NotMentioned, 'no preset or layer of the configuration names its decisions'],
1 ✔
422
                };
423
                // who said the decisive value: of a decision that takes effect, or of the last one turned off
424
                $said = $reason === null ? $effective : array_filter($own, fn(ResolvedDecision $decision) => $decision->layers !== []);
1 ✔
425
                $top = array_values($said)[0]->layers ?? [];
1 ✔
426
                $source = $top === [] ? null : $top[count($top) - 1]->origin;
1 ✔
427
                return new ResolvedRule($class, $source, $reason, $message, $factory, $fixRisky);
1 ✔
428
        }
429

430

431
        /**
432
         * The severity a layer says of each requirement its names cover, the most specific name winning: a decision, a
433
         * deeper group, a shallower one, a section, the class of a rule, a preset.
434
         * @param  array<string, string>  $names  name => `warning` or `error`
435
         * @return array<string, array{int, string}>  path => the rank of the name and the severity
436
         * @throws ConfigurationException
437
         */
438
        private function collectSeverity(array $names): array
439
        {
440
                $severity = $by = [];
1 ✔
441
                foreach ($names as $name => $value) {
1 ✔
442
                        $expanded = $this->expandName($name);
1 ✔
443
                        $paths = array_filter(
1 ✔
444
                                $expanded->paths,
1 ✔
445
                                fn(string $path) => $this->getCatalogue()->find($path)?->kind !== DecisionKind::Parameter,
1 ✔
446
                        );
447
                        if ($paths === []) {
1 ✔
448
                                throw new ConfigurationException("Name `$name` in `severity` covers no decision that reports; a parameter has no severity of its own.");
1 ✔
449
                        }
450

451
                        $rank = match (true) {
1 ✔
452
                                $expanded->preset => 0,
1 ✔
453
                                $expanded->rule !== null => 1,
1 ✔
454
                                $this->getCatalogue()->find($name) !== null => PHP_INT_MAX,
1 ✔
455
                                default => 1 + count(explode('.', $expanded->name)),
1 ✔
456
                        };
457
                        foreach ($paths as $path) {
1 ✔
458
                                [$was, $wasValue] = $severity[$path] ?? [-1, null];
1 ✔
459
                                if ($was === $rank && $wasValue !== $value) {
1 ✔
460
                                        throw new ConfigurationException("Names `{$by[$path]}` and `$name` in `severity` say two severities of `$path`; name it alone.");
1 ✔
461
                                } elseif ($was < $rank) {
1 ✔
462
                                        $severity[$path] = [$rank, $value];
1 ✔
463
                                        $by[$path] = $name;
1 ✔
464
                                }
465
                        }
466
                }
467

468
                return $severity;
1 ✔
469
        }
470

471

472
        /**
473
         * The requirements whose violations only warn: a severity holds for a decision whose value the layer saying it, or
474
         * one below it, said, so a layer writing the value anew without a severity makes it an error again.
475
         * @param  array<string, ResolvedDecision>  $decisions
476
         * @param  list<Layer>  $layers  from the lowest
477
         * @param  list<array<string, array{int, string}>>  $severities  of each layer, by path
478
         * @return array<string, true>
479
         */
480
        private static function findWarningDecisions(array $decisions, array $layers, array $severities): array
481
        {
482
                $warnings = [];
1 ✔
483
                foreach ($decisions as $path => $decision) {
1 ✔
484
                        $top = $decision->findTopLayer()?->origin;
1 ✔
485
                        $from = $top === null ? 0 : max(0, (int) array_search($top, $layers, strict: true));
1 ✔
486
                        for ($i = count($layers) - 1; $i >= $from; $i--) {
1 ✔
487
                                if (isset($severities[$i][$path])) {
1 ✔
488
                                        if ($severities[$i][$path][1] === 'warning') {
1 ✔
489
                                                $warnings[$path] = true;
1 ✔
490
                                        }
491

492
                                        break;
1 ✔
493
                                }
494
                        }
495
                }
496

497
                return $warnings;
1 ✔
498
        }
499

500

501
        /**
502
         * The paths the decisions of the run are narrowed to, those the names of `only` stand for.
503
         * @param  list<ExpandedName>  $narrowed
504
         * @return list<string>
505
         */
506
        private function collectSelection(array $narrowed): array
507
        {
508
                return array_values(array_unique(array_merge(...array_map(fn(ExpandedName $name) => $name->paths, $narrowed))));
1 ✔
509
        }
510

511

512
        /**
513
         * The decisions a name of `suppressionComments` silences.
514
         * @return list<string>
515
         * @throws ConfigurationException
516
         */
517
        private function resolveSuppressedName(string $name): array
518
        {
519
                return $this->registry->expandSuppressedName($name)
1 ✔
520
                        ?: throw new ConfigurationException("`suppressionComments` names `$name`, which is no decision, section or rule.");
1 ✔
521
        }
522

523

524
        /**
525
         * Adds the functions and the constants the namespaces of a layer declare, each spelled the way the first layer
526
         * naming it spells it.
527
         * @param  array<string, array<string, array{string, string}>>  $symbols
528
         * @param  array{functions: list<string>, constants: list<string>}  $namespaces
529
         */
530
        private static function addSymbols(array &$symbols, array $namespaces, string $layer): void
531
        {
532
                foreach ([[SymbolKind::Function, 'functions'], [SymbolKind::Constant, 'constants']] as [$kind, $key]) {
1 ✔
533
                        foreach ($namespaces[$key] as $name) {
1 ✔
534
                                $symbols[$kind->name][self::toSymbolKey($kind, $name)] ??= [$name, $layer];
1 ✔
535
                        }
536
                }
537
        }
1 ✔
538

539

540
        /**
541
         * The key two spellings of one symbol share, the way PHP reads them: a function in any letter case, a constant
542
         * in any letter case of its namespace but not of its own name.
543
         */
544
        public static function toSymbolKey(SymbolKind $kind, string $name): string
545
        {
546
                $name = ltrim($name, '\\');
1 ✔
547
                $pos = (int) strrpos($name, '\\');
1 ✔
548
                return $kind === SymbolKind::Constant
1 ✔
549
                        ? strtolower(substr($name, 0, $pos)) . substr($name, $pos)
1 ✔
550
                        : strtolower($name);
1 ✔
551
        }
552

553

554
        /**
555
         * The versions the code is checked for and the lowest of them, the one a rule asks about: the target, or the oldest
556
         * DressCode fixes code for, with a warning.
557
         * @return array{string, string}
558
         */
559
        private function raiseToMinPhpVersion(string $target): array
560
        {
561
                $version = Versions::findLowestVersion($target);
1 ✔
562
                if ($version !== null && version_compare($version, Config::MinPhpVersion, '>=')) {
1 ✔
563
                        return [$target, $version];
1 ✔
564
                }
565

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

571

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

578

579
        /** The line ending the decision names; null for the one each file uses most. */
580
        private static function resolveLineEnding(Value $lineEnding): ?string
581
        {
582
                return match ($lineEnding->isKept() ? null : $lineEnding->getWord()) {
1 ✔
583
                        'LF' => "\n",
1 ✔
584
                        'CRLF' => "\r\n",
1 ✔
585
                        default => null,
1 ✔
586
                };
587
        }
588

589

590
        /** The widest line, null for none. */
591
        private static function resolveLineLength(Value $length): ?int
592
        {
593
                return $length->content === 'none' ? null : $length->getCountRange()[0];
1 ✔
594
        }
595

596

597
        /**
598
         * The profiles that decide for a file matching the overrides, lowest first, each with the name an error or an origin
599
         * is told by.
600
         * @param  list<int>  $overrides
601
         * @return list<array{Layer, Profile}>
602
         */
603
        private static function listProfiles(Config $config, array $overrides, ?Profile $commandLine = null): array
604
        {
605
                $profiles = [[new Layer(LayerKind::Configuration), $config]];
1 ✔
606
                foreach ($overrides as $index) {
1 ✔
607
                        $profiles[] = [
1 ✔
608
                                new Layer(LayerKind::Override, implode(', ', $config->overrides[$index]->paths)),
1 ✔
609
                                $config->overrides[$index]->profile,
1 ✔
610
                        ];
611
                }
612

613
                if ($commandLine !== null) {
1 ✔
614
                        $profiles[] = [new Layer(LayerKind::CommandLine), $commandLine];
1 ✔
615
                }
616

617
                return $profiles;
1 ✔
618
        }
619

620

621
        /**
622
         * The profiles as layers, lowest first: each of them above what it uses, in the order it names them, and a preset
623
         * where it is named first; what a profile names that is already laid, it names for nothing.
624
         * @param  list<array{Layer, Profile}>  $profiles  who says it and what it says
625
         * @return array{layers: list<array{Layer, Profile}>, repeated: list<array{Layer, Layer, Layer}>}  the layers, each
626
         *   who says it and what it says; and every entry named for nothing, as its layer, the one that laid it and the one
627
         *   that named it again
628
         * @throws ConfigurationException
629
         */
630
        private function collectLayers(array $profiles): array
631
        {
632
                $layers = $visited = $repeated = [];
1 ✔
633
                foreach ($profiles as [$layer, $profile]) {
1 ✔
634
                        $this->collectUsed($profile, $layer, $layers, $visited, $repeated);
1 ✔
635
                        if ($layer->kind === LayerKind::Configuration && $this->laid !== []) {
1 ✔
636
                                $this->collectUsed(new Profile(use: $this->laid), new Layer(LayerKind::Caller), $layers, $visited, $repeated);
1 ✔
637
                        }
638

639
                        $layers[] = [$layer, $profile];
1 ✔
640
                }
641

642
                return ['layers' => $layers, 'repeated' => $repeated];
1 ✔
643
        }
644

645

646
        /**
647
         * Lays the presets the profile uses, each above what it uses in turn, once, where it is named first; a preset
648
         * that comes back to itself through what it uses is an error, unlike one named again by another layer, which
649
         * does nothing.
650
         * @param  list<array{Layer, Profile}>  $layers
651
         * @param  array<string, Layer>  $visited  the name of a preset => the layer that laid it
652
         * @param  list<array{Layer, Layer, Layer}>  $repeated
653
         * @throws ConfigurationException
654
         */
655
        private function collectUsed(Profile $profile, Layer $source, array &$layers, array &$visited, array &$repeated): void
656
        {
657
                foreach ($profile->use as $entry) {
1 ✔
658
                        if ($this->registry->isPluginPackage($entry)) { // it lets the plugin of the package in, which is no layer
1 ✔
659
                                continue;
1 ✔
660
                        }
661

662
                        try {
663
                                // a preset by its name, or a file of the same shape by its path, which is its name too
664
                                $name = str_ends_with($entry, '.neon')
1 ✔
665
                                        ? NeonReader::resolvePresetFile($entry, $this->root)
1 ✔
666
                                        : $this->registry->resolvePreset($entry);
1 ✔
667
                                $file = $this->registry->presets[$name] ?? $name;
1 ✔
668
                        } catch (ConfigurationException $e) {
1 ✔
669
                                throw self::locate($e, $source);
1 ✔
670
                        } catch (\InvalidArgumentException $e) {
1 ✔
671
                                throw self::locate(new ConfigurationException($e->getMessage(), previous: $e), $source);
1 ✔
672
                        }
673

674
                        $layer = new Layer(LayerKind::Preset, $name);
1 ✔
675
                        if (in_array($name, $this->using, true)) {
1 ✔
676
                                $cycle = implode('`, `', array_slice($this->using, array_search($name, $this->using, strict: true) + 1));
1 ✔
677
                                throw new ConfigurationException(($file === $name ? 'Preset file' : 'Preset') . " `$name` uses itself through `$cycle`; a preset cannot come back to itself.");
1 ✔
678
                        } elseif (isset($visited[$name])) {
1 ✔
679
                                $repeated[] = [$layer, $visited[$name], $source];
1 ✔
680
                                continue;
1 ✔
681
                        }
682

683
                        $visited[$name] = $source;
1 ✔
684
                        $preset = $this->presetFiles[$file] ??= NeonReader::readPreset($file, $file === $name ? null : $name);
1 ✔
685
                        $this->using[] = $name;
1 ✔
686
                        try {
687
                                $this->collectUsed($preset, $layer, $layers, $visited, $repeated);
1 ✔
688
                        } finally {
1 ✔
689
                                array_pop($this->using);
1 ✔
690
                        }
691

692
                        $layers[] = [$layer, $preset];
1 ✔
693
                }
694
        }
1 ✔
695

696

697
        /**
698
         * An entry named again where a layer below already laid it does nothing, and the run says so.
699
         * @param  list<array{Layer, Layer, Layer}>  $repeated
700
         */
701
        private function warnRepeated(array $repeated): void
702
        {
703
                foreach ($repeated as [$layer, $laidBy, $namedBy]) {
1 ✔
704
                        // what a preset or the caller lays is not the project's to change, and the command line reaches what an override
705
                        // does not
706
                        if (
707
                                $namedBy->kind !== LayerKind::Preset
1 ✔
708
                                && $namedBy->kind !== LayerKind::Caller
1 ✔
709
                                && $laidBy->kind !== LayerKind::Caller
1 ✔
710
                                && !($laidBy->kind === LayerKind::Override && $namedBy->kind === LayerKind::CommandLine)
1 ✔
711
                        ) {
712
                                $this->warnings['repeated ' . $layer->describe() . ' ' . $namedBy->describe()] = ucfirst($namedBy->formatReference())
1 ✔
713
                                        . ' uses ' . $layer->formatReference()
1 ✔
714
                                        . ', which ' . $laidBy->formatReference() . ' already brings; the entry does nothing.';
1 ✔
715
                        }
716
                }
717
        }
1 ✔
718

719

720
        /** An error said in a layer the reader has to be sent to, named in front of it; the configuration needs no pointing at. */
721
        private static function locate(ConfigurationException $e, Layer $layer): ConfigurationException
722
        {
723
                return $layer->kind === LayerKind::Configuration
1 ✔
724
                        ? $e
1 ✔
725
                        : new ConfigurationException(ucfirst($layer->formatReference()) . ": {$e->getMessage()}", $e->docs, $e);
1 ✔
726
        }
727

728

729
        /**
730
         * The rules a run narrowed by `only` keeps, per name it was given.
731
         * @param  list<string>  $names
732
         * @return list<ExpandedName>
733
         * @throws ConfigurationException
734
         */
735
        private function resolveOnly(array $names): array
736
        {
737
                return array_map($this->expandName(...), $names);
1 ✔
738
        }
739

740

741
        /**
742
         * What a name of `only`, `fixRisky` or `severity` stands for: a decision or a section for the decisions under it,
743
         * a package of upgrading data for what its files set, a library for what the files about it set, a rule for its own,
744
         * a preset for every decision it and its parents make, each with the rules owning them. A section named like a preset
745
         * is written `section.*`, the bare name being refused as both.
746
         * @throws ConfigurationException
747
         */
748
        private function expandName(string $name): ExpandedName
749
        {
750
                $package = $this->findDataPackage($name);
1 ✔
751
                if ($package !== null) {
1 ✔
752
                        $paths = [];
1 ✔
753
                        foreach ($this->upgradingData as $data) {
1 ✔
754
                                if ($data->layer->package === $package || $data->package === $package) {
1 ✔
755
                                        $keys = [...array_keys($data->maps), ...array_keys($data->requirements)];
1 ✔
756
                                        array_push($paths, ...array_filter(array_map($this->findDataPath(...), $keys)));
1 ✔
757
                                }
758
                        }
759

760
                        $paths = array_values(array_unique($paths));
1 ✔
761
                        $rules = array_merge(...array_map($this->getCatalogue()->getRulesOf(...), $paths));
1 ✔
762
                        return new ExpandedName($package, false, null, array_values(array_unique($rules)), $paths, package: true);
1 ✔
763
                }
764

765
                $section = str_ends_with($name, '.*') ? substr($name, 0, -2) : null;
1 ✔
766
                $paths = array_keys($this->getCatalogue()->getDecisionsUnder($section ?? $name));
1 ✔
767
                $preset = $paths !== [] && $section === null && !str_contains($name, '.') ? $this->registry->findPreset($name) : null;
1 ✔
768
                if ($preset !== null) {
1 ✔
769
                        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 ✔
770
                }
771

772
                $preset = null;
1 ✔
773
                if ($paths === [] && $section !== null) {
1 ✔
774
                        throw new ConfigurationException("Unknown section `$section`.");
1 ✔
775
                } elseif ($paths === [] && in_array($name, $this->registry->rules, true)) {
1 ✔
776
                        $requirements = array_filter(
1 ✔
777
                                $this->getCatalogue()->getDecisionsOf($name),
1 ✔
778
                                fn(Decision $decision) => $decision->kind !== DecisionKind::Parameter,
1 ✔
779
                        );
780
                        return new ExpandedName($name, false, $name, [$name], array_keys($requirements));
1 ✔
781
                } elseif ($paths === [] && class_exists($name) && is_subclass_of($name, Rule::class)) {
1 ✔
782
                        throw new ConfigurationException("Rule `$name` is not registered; add it to `rules` of the configuration.");
1 ✔
783
                } elseif ($paths === []) {
1 ✔
784
                        $preset = $this->registry->findPreset($name) ?? throw $this->registry->createUnknownNameException($name);
1 ✔
785
                        foreach ($this->collectLayers([[new Layer(LayerKind::Caller), new Profile(use: [$preset])]])['layers'] as [, $profile]) {
1 ✔
786
                                array_push($paths, ...$this->collectPaths($profile->decisions));
1 ✔
787
                        }
788
                }
789

790
                $paths = array_values(array_unique($paths));
1 ✔
791
                $rules = array_merge(...array_map($this->getCatalogue()->getRulesOf(...), $paths));
1 ✔
792
                return new ExpandedName($preset ?? $section ?? $name, $preset !== null, null, array_values(array_unique($rules)), $paths);
1 ✔
793
        }
794

795

796
        /**
797
         * The full name of the installed package the name stands for, one shipping upgrading files or one they are about,
798
         * one of the core without its vendor too.
799
         * @throws ConfigurationException  where it is both, and another package ships files about it
800
         */
801
        private function findDataPackage(string $name): ?string
802
        {
803
                $names = [$name, PluginRegistry::qualify($name)];
1 ✔
804
                $carrier = $library = null;
1 ✔
805
                $others = [];
1 ✔
806
                foreach ($this->upgradingData as $data) {
1 ✔
807
                        if ($data->layer->name !== null && in_array($data->layer->package, $names, true)) {
1 ✔
808
                                $carrier = $data->layer->package;
1 ✔
809
                        }
810

811
                        if (in_array($data->package, $names, true)) {
1 ✔
812
                                $library = $data->package;
1 ✔
813
                                if ($data->layer->package !== $library) {
1 ✔
814
                                        $others[] = (string) $data->layer->package;
1 ✔
815
                                }
816
                        }
817
                }
818

819
                if ($carrier !== null && $others !== []) {
1 ✔
820
                        throw new ConfigurationException(
1 ✔
821
                                "Name `$name` stands for package `$carrier`, which ships upgrading files, and for the library the files of `"
1 ✔
822
                                . implode('`, `', array_unique($others))
1 ✔
823
                                . '` are about; name the package of those files, or the decisions.',
1 ✔
824
                        );
825
                }
826

827
                return $carrier ?? $library;
1 ✔
828
        }
829

830

831
        /**
832
         * The paths of the decisions a layer makes, every one under a structure it makes as a whole.
833
         * @param  array<mixed>  $tree
834
         * @return list<string>
835
         */
836
        private function collectPaths(array $tree, string $prefix = ''): array
837
        {
838
                $paths = [];
1 ✔
839
                foreach ($tree as $key => $value) {
1 ✔
840
                        $path = $prefix . $key;
1 ✔
841
                        if ($this->getCatalogue()->find($path) !== null) {
1 ✔
842
                                $paths[] = $path;
1 ✔
843
                        } elseif (is_array($value)) {
1 ✔
844
                                array_push($paths, ...$this->collectPaths($value, "$path."));
1 ✔
845
                        } else {
846
                                array_push($paths, ...array_keys($this->getCatalogue()->getDecisionsUnder($path)));
1 ✔
847
                        }
848
                }
849

850
                return $paths;
1 ✔
851
        }
852

853

854
        /**
855
         * A name of `only` that lets in nothing that runs would make a run that checks nothing and says it is clean, and
856
         * a section or a preset of which only some rules run here would make one that checks less than it says; a rule that
857
         * runs only where an override enables it counts as running.
858
         * @param  list<ExpandedName>  $narrowed
859
         * @param  array<class-string<Rule>, ResolvedRule>  $active
860
         * @param  array<class-string<Rule>, ResolvedRule>  $inactive
861
         * @param  array<class-string<Rule>, true>  $ofOverrides
862
         * @param  list<string>  $use  the presets the run lays
863
         * @throws ConfigurationException
864
         */
865
        private function checkOnly(array $narrowed, array $active, array $inactive, array $ofOverrides, array $use): void
866
        {
867
                $skipped = [];
1 ✔
868
                foreach ($narrowed as $expanded) {
1 ✔
869
                        [$class, $rules] = [$expanded->rule, $expanded->rules];
1 ✔
870
                        $running = array_filter($rules, fn(string $rule) => isset($active[$rule]) || isset($ofOverrides[$rule]));
1 ✔
871
                        // a name whose rules the project cannot run, too new for its target or for its packages, is skipped, so that
872
                        // one command of `--only` serves projects of several targets
873
                        if (
874
                                !$running
1 ✔
875
                                && $rules !== []
1 ✔
876
                                && array_all($rules, fn(string $rule) => in_array(
1 ✔
877
                                        $inactive[$rule]->inactiveReason ?? null,
1 ✔
878
                                        [InactiveReason::Php, InactiveReason::Package],
1 ✔
879
                                        true,
1 ✔
880
                                ))
1 ✔
881
                        ) {
882
                                $skipped[$expanded->name] = [
1 ✔
883
                                        'Option `--only` names ' . ($class === null ? $expanded->format() : "rule `$class`")
1 ✔
884
                                        . ", which cannot run here: {$inactive[$rules[array_key_first($rules)]]->inactiveMessage}",
1 ✔
885
                                        $rules,
1 ✔
886
                                ];
887
                                continue;
1 ✔
888
                        }
889

890
                        if ($class !== null) {
1 ✔
891
                                if (!$running) {
1 ✔
892
                                        throw new ConfigurationException("Option `--only` names rule `$class`, which cannot run here: {$inactive[$class]->inactiveMessage}.");
1 ✔
893
                                }
894

895
                                continue;
1 ✔
896
                        }
897

898
                        // left out is what the project turned off, and where it does not use the name what the name would bring; a rule
899
                        // its standard turns off or that cannot run here, too new for the target or needing the types, is not
900
                        $collective = PluginRegistry::abbreviate($expanded->name);
1 ✔
901
                        $laid = in_array($collective, array_map(PluginRegistry::abbreviate(...), $use), true);
1 ✔
902
                        $left = array_filter($rules, fn(string $rule) => match ($inactive[$rule]->inactiveReason ?? null) {
1 ✔
903
                                InactiveReason::TurnedOff => $inactive[$rule]->source?->isProject() === true,
1 ✔
904
                                InactiveReason::NotMentioned => !$laid,
1 ✔
905
                                default => false,
1 ✔
906
                        });
1 ✔
907
                        // a preset the configuration does not use is brought by `--use`, a decision it does not make by `--set`
908
                        $hint = match (true) {
1 ✔
909
                                $expanded->preset => "`--use $collective --only $collective` runs all its rules",
1 ✔
910
                                $expanded->package
1 ✔
911
                                        => 'its upgrading files reach no version of a library the project has, or `upgrading.packages` is not `yes`',
1 ✔
912
                                $this->getCatalogue()->find($collective) !== null => "`--set $collective=<value>` decides it for the run",
1 ✔
913
                                default => '`--set <path>=<value>` decides one of them for the run',
1 ✔
914
                        };
915
                        if (!$running) {
1 ✔
916
                                throw new ConfigurationException('Option `--only` names ' . $expanded->format() . ', which has no rule that runs here' . ($laid ? '.' : "; $hint."));
1 ✔
917
                        } elseif ($left) {
1 ✔
918
                                // a rule left out is named by its requirements, which is what the user writes
919
                                $names = array_map(fn(string $path) => "`$path`", array_keys(array_filter(
1 ✔
920
                                        array_merge(...array_map(fn(string $rule) => $this->getCatalogue()->getDecisionsOf($rule), array_values($left))),
1 ✔
921
                                        fn(Decision $decision) => $decision->kind !== DecisionKind::Parameter,
1 ✔
922
                                )));
923
                                $this->warnings["only $expanded->name"] = 'Option `--only` keeps ' . count($running) . ' of the ' . (count(
1 ✔
924
                                        $running,
1 ✔
925
                                ) + count(
926
                                        $left,
1 ✔
927
                                ))
928
                                        . ' rules of ' . $expanded->format() . ' that may run here'
1 ✔
929
                                        . ($laid || !$expanded->preset
1 ✔
930
                                                ? ', without ' . implode(', ', array_slice($names, 0, 5)) . (count($names) > 5
1 ✔
931
                                                        ? ' and ' . (count($names) - 5) . ' more'
1 ✔
932
                                                        : '')
1 ✔
933
                                                        . ($laid ? ', which the configuration turns off.' : ", which the configuration does not decide; $hint.")
1 ✔
934
                                                : "; $hint.");
1 ✔
935
                        }
936
                }
937

938
                if ($skipped !== [] && count($skipped) === count($narrowed)) {
1 ✔
939
                        throw new ConfigurationException(reset($skipped)[0] . '.');
1 ✔
940
                }
941

942
                foreach ($skipped as $name => [$message, $rules]) {
1 ✔
943
                        // a rule whose decision the project wrote is said to be skipped already
944
                        if (!array_all($rules, fn(string $rule) => isset($this->warnings[$rule]))) {
1 ✔
945
                                $this->warnings["only $name"] = "$message; it is skipped.";
1 ✔
946
                        }
947
                }
948
        }
1 ✔
949

950

951
        /**
952
         * A name in fixRisky or reportOnly whose rules run nowhere, not even where an override turns them on, makes the entry
953
         * a line that does nothing, which is worth a warning.
954
         * @param  array<class-string<Rule>, ResolvedRule>  $active
955
         * @param  array<class-string<Rule>, true>  $ofOverrides
956
         * @throws ConfigurationException
957
         */
958
        private function checkFixPolicies(Config $config, array $active, array $ofOverrides): void
959
        {
960
                foreach (self::listProfiles($config, array_keys($config->overrides)) as [$layer, $profile]) {
1 ✔
961
                        foreach (['fixRisky' => $profile->fixRisky, 'reportOnly' => $profile->reportOnly] as $key => $entries) {
1 ✔
962
                                foreach ($entries as $entry) {
1 ✔
963
                                        try {
964
                                                $expanded = $this->expandName($entry);
1 ✔
UNCOV
965
                                        } catch (ConfigurationException $e) {
×
UNCOV
966
                                                throw self::locate($e, $layer);
×
967
                                        }
968

969
                                        if (!array_filter($expanded->rules, fn(string $class) => isset($active[$class]) || isset($ofOverrides[$class]))) {
1 ✔
970
                                                $this->warnings["$key $expanded->name"] = ucfirst($expanded->format())
1 ✔
971
                                                        . " is named in `$key` but runs nowhere; the entry does nothing.";
1 ✔
972
                                        }
973
                                }
974
                        }
975
                }
976
        }
1 ✔
977
}
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