• 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

93.64
/src/Config/UpgradingFile.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\{ConfigurationException, Profile};
11
use DressCode\Upgrading\{FunctionPattern, MemberMapGrammar, MemberPattern};
12
use Nette\Neon\{Exception as NeonException, Neon};
13
use function array_key_exists, is_array, is_string;
14

15

16
/**
17
 * An upgrading file as it is written: the package whose versions its sections are of, what the namespaces of the
18
 * package declare, and the maps every section brings with the requirements of the rules of the plugin of the package it
19
 * turns on, which `selectFor()` lays out for the versions of the package the code must work with.
20
 * @internal
21
 */
22
final readonly class UpgradingFile
23
{
24
        /** the maps of what the code must not have, by which what a version above the lowest one retires is reported */
25
        private const ForbiddenMaps = ['forbiddenClasses', 'forbiddenFunctions', 'forbiddenConstants', 'forbiddenMembers'];
26

27
        private const SectionPattern = '~^since (\d+(?:\.\d+)*)$~D';
28
        private const PackagePattern = '~^(?:php|[a-z0-9_.-]+/[a-z0-9_.-]+)$~D';
29
        private const VersionPattern = '~^\d+(?:\.\d+)*$~D';
30

31

32
        private function __construct(
33
                /** the package the sections are versions of, `php` for PHP itself */
34
                public string $package,
1 ✔
35
                /** @var array{functions: list<string>, constants: list<string>}  what the package declares in its namespaces, fully qualified */
36
                public array $namespaces,
1 ✔
37
                /** @var array<string, array<string, array<string, mixed>>>  version => map => its entries, the oldest first */
38
                private array $sections,
1 ✔
39
                /** @var array<string, array<string, string>>  version => a requirement of the plugin of the package => its value */
40
                private array $requirements = [],
1 ✔
41
        ) {
42
        }
1 ✔
43

44

45
        /**
46
         * Reads the file, a value as NEON gives it, an entity too, for the grammar of the map to read.
47
         * @throws ConfigurationException
48
         */
49
        public static function fromFile(string $file, string $label): self
50
        {
51
                if (!is_file($file)) {
1 ✔
UNCOV
52
                        throw new ConfigurationException("Upgrading file $label does not exist.");
×
53
                }
54

55
                try {
56
                        $data = Neon::decodeFile($file);
1 ✔
57
                } catch (NeonException $e) {
1 ✔
58
                        throw new ConfigurationException("Upgrading file $label is not valid NEON: {$e->getMessage()}", previous: $e);
1 ✔
59
                }
60

61
                $package = is_array($data) ? $data['package'] ?? null : null;
1 ✔
62
                if (!is_string($package) || !preg_match(self::PackagePattern, $package)) {
1 ✔
63
                        throw new ConfigurationException("Upgrading file $label: The key `package` must name the package the sections are versions of, as `vendor/name`, or `php`.");
1 ✔
64
                }
65

66
                $sections = $requirements = [];
1 ✔
67
                foreach ($data as $key => $section) {
1 ✔
68
                        if ($key === 'package' || $key === 'namespaces') {
1 ✔
69
                                continue;
1 ✔
70
                        } elseif (!is_string($key) || !preg_match(self::SectionPattern, $key, $m)) {
1 ✔
71
                                throw new ConfigurationException("Upgrading file $label: Unexpected key `$key`; the file holds `package`, `namespaces` and sections `since <version>`.");
1 ✔
72
                        } elseif (!is_array($section) || (array_is_list($section) && $section !== [])) {
1 ✔
73
                                throw new ConfigurationException("Upgrading file $label: The section `$key` must be a map of maps to their entries.");
1 ✔
74
                        }
75

76
                        foreach ($section as $map => $entries) {
1 ✔
77
                                if (is_string($map) && str_contains($map, '.') && is_string($entries)) {
1 ✔
78
                                        $requirements[$m[1]][$map] = $entries;
1 ✔
79
                                        continue;
1 ✔
80
                                } elseif (!is_string($map) || !is_array($entries)) {
1 ✔
81
                                        throw new ConfigurationException("Upgrading file $label: The map `$map` in `$key` must be a map of entries; a package turns on only a rule of its own plugin, by the path of its decision and a word.");
1 ✔
82
                                }
83

84
                                foreach ($entries as $entry => $value) {
1 ✔
85
                                        $from = is_array($value) ? $value['from'] ?? null : null;
1 ✔
86
                                        if ($from !== null && (!is_string($from) || !preg_match(self::VersionPattern, $from))) {
1 ✔
87
                                                throw new ConfigurationException("Upgrading file $label: `from` of the entry `$entry` of `$map` in `$key` must be a version, as `'8.0'`.");
1 ✔
88
                                        }
89
                                }
90

91
                                $sections[$m[1]][$map] = $entries;
1 ✔
92
                        }
93
                }
94

95
                uksort($sections, fn(string $a, string $b) => version_compare($a, $b));
1 ✔
96
                uksort($requirements, fn(string $a, string $b) => version_compare($a, $b));
1 ✔
97
                try {
98
                        $namespaces = new Profile(namespaces: is_array($data['namespaces'] ?? null) ? $data['namespaces'] : [])->namespaces;
1 ✔
UNCOV
99
                } catch (\InvalidArgumentException $e) {
×
UNCOV
100
                        throw new ConfigurationException("Upgrading file $label: {$e->getMessage()}", previous: $e);
×
101
                }
102

103
                return new self($package, $namespaces, $sections, $requirements);
1 ✔
104
        }
105

106

107
        /**
108
         * The data for code that must work with the versions of the constraint, null where any version does. An entry is
109
         * rewritten where the lowest version reaches its section, or the version `from` says its fix is right from, a later
110
         * section having the last word on it: every entry where any version does, none where there is no lower bound.
111
         * What a section above the lowest version retires, where a version the constraint allows reaches it, is reported
112
         * without a fix as the entry of a forbidden map (`retire()`), unless a section reached rewrites or forbids the same
113
         * thing already. A requirement is turned on where the lowest version reaches its section, and says nothing above it.
114
         */
115
        public function selectFor(?string $constraint, Layer $layer): UpgradingData
116
        {
117
                $lowest = $constraint === null ? null : Versions::findLowestVersion($constraint);
1 ✔
118
                $reaches = fn(string $version) => $constraint === null || ($lowest !== null && version_compare($lowest, $version, '>='));
1 ✔
119
                $maps = $retirements = $retired = $unreached = $reported = [];
1 ✔
120
                foreach ($this->sections as $since => $section) {
1 ✔
121
                        $since = (string) $since;
1 ✔
122
                        $left = [];
1 ✔
123
                        foreach ($section as $map => $entries) {
1 ✔
124
                                foreach ($entries as $key => $value) {
1 ✔
125
                                        $from = is_array($value) ? $value['from'] ?? null : null;
1 ✔
126
                                        if (!$reaches($since) && ($from === null || !$reaches($from))) {
1 ✔
127
                                                $left[$map][$key] = $value;
1 ✔
128
                                                continue;
1 ✔
129
                                        }
130

131
                                        $maps[$map][$key] = self::removeFrom($value);
1 ✔
132
                                        unset($retirements[$map][$key]);
1 ✔
133
                                        if (in_array($map, self::ForbiddenMaps, true) && $value !== MemberMapGrammar::Keep) {
1 ✔
134
                                                $retirements[$map][$key] = $since;
1 ✔
135
                                        }
136
                                }
137
                        }
138

139
                        if ($left === [] || $constraint === null) {
1 ✔
140
                                continue;
1 ✔
141
                        }
142

143
                        $unreached[] = $since;
1 ✔
144
                        if (!Versions::isSubset($constraint, "<$since")) {
1 ✔
145
                                $reported[] = $since;
1 ✔
146
                                foreach (self::retire($left) as [$map, $key, $sentence, $version]) {
1 ✔
147
                                        $retired[$map][$key] = [$sentence, $version ?? $since];
1 ✔
148
                                }
149
                        }
150
                }
151

152
                $written = [];
1 ✔
153
                foreach ($maps as $map => $entries) {
1 ✔
154
                        foreach (array_keys($entries) as $key) {
1 ✔
155
                                $target = self::findForbidden($map, (string) $key);
1 ✔
156
                                if ($target !== null) {
1 ✔
157
                                        $written[$target[0]][self::identify(...$target)] = true;
1 ✔
158
                                }
159
                        }
160
                }
161

162
                foreach ($retired as $map => $entries) {
1 ✔
163
                        foreach ($entries as $key => [$sentence, $version]) {
1 ✔
164
                                if (!isset($written[$map][self::identify($map, $key)])) {
1 ✔
165
                                        $maps[$map][$key] = $sentence;
1 ✔
166
                                        $retirements[$map][$key] = $version;
1 ✔
167
                                }
168
                        }
169
                }
170

171
                $requirements = [];
1 ✔
172
                foreach ($this->requirements as $since => $values) {
1 ✔
173
                        if ($reaches((string) $since)) {
1 ✔
174
                                $requirements = array_replace($requirements, $values);
1 ✔
175
                        }
176
                }
177

178
                return new UpgradingData($layer, $this->package, $this->namespaces, $maps, $retirements, $unreached, $reported, $requirements);
1 ✔
179
        }
180

181

182
        /**
183
         * What entries left above the lowest version say the code still meets on a version it may run on, as entries of the
184
         * forbidden maps (`findForbidden()`), since what they write instead is not there in the lowest one: what they forbid,
185
         * and what they replace, each with its sentence and the version it says, where that is not the one of its section.
186
         * @param  array<string, array<string, mixed>>  $section  map => its entries
187
         * @return list<array{string, string, ?string, ?string}>  forbidden map, key, sentence, version
188
         */
189
        private static function retire(array $section): array
190
        {
191
                $retired = [];
1 ✔
192
                foreach ($section as $map => $entries) {
1 ✔
193
                        foreach ($entries as $key => $value) {
1 ✔
194
                                [$write, $from] = is_array($value) ? [$value['write'] ?? null, $value['from'] ?? null] : [$value, null];
1 ✔
195
                                $target = self::findForbidden($map, (string) $key);
1 ✔
196
                                if ($target === null || $write === MemberMapGrammar::Keep) {
1 ✔
197
                                        continue;
1 ✔
198
                                }
199

200
                                [$forbidden, $name] = $target;
1 ✔
201
                                $retired[] = match (true) {
1 ✔
202
                                        $map === $forbidden => [$forbidden, $name, $write, null],
1 ✔
203
                                        $write === MemberMapGrammar::Removed => [$forbidden, $name, 'does nothing', $from],
1 ✔
204
                                        is_string($write) => [$forbidden, $name, 'replaced by `' . ltrim($write, '\\') . '`', null],
1 ✔
205
                                        default => [$forbidden, $name, 'replaced', null],
1 ✔
206
                                };
207
                        }
208
                }
209

210
                return $retired;
1 ✔
211
        }
212

213

214
        /**
215
         * The forbidden map an entry of the map is reported by where it cannot be rewritten, and its key there: a call of
216
         * a function as the function, a call of a member by the shape of its arguments as `forbiddenMembers` reads it; null
217
         * for a map that forbids nothing by itself, an annotation and the maps of plugins, and for a key that is no name.
218
         * @return ?array{string, string}
219
         */
220
        private static function findForbidden(string $map, string $key): ?array
221
        {
222
                try {
223
                        return match (true) {
224
                                in_array($map, self::ForbiddenMaps, true) => [$map, $key],
1 ✔
225
                                $map === 'replacedClasses' => ['forbiddenClasses', $key],
1 ✔
226
                                $map === 'replacedFunctions' => ['forbiddenFunctions', $key],
1 ✔
227
                                $map === 'replacedMembers' => ['forbiddenMembers', $key],
1 ✔
228
                                $map === 'replacedCalls' && MemberMapGrammar::isFunctionKey($key)
1 ✔
229
                                        => ['forbiddenFunctions', FunctionPattern::fromKey($key)->name],
1 ✔
230
                                $map === 'replacedCalls' => ['forbiddenMembers', $key],
1 ✔
231
                                default => null,
1 ✔
232
                        };
UNCOV
233
                } catch (\InvalidArgumentException) {
×
UNCOV
234
                        return null; // the grammar of the map says what is wrong with the key
×
235
                }
236
        }
237

238

239
        /**
240
         * What the key of a forbidden map names whatever way it is written, so that one rewritten and one forbidden meet: a
241
         * member by its class and name, a class, a function and a constant by its name.
242
         */
243
        private static function identify(string $forbidden, string $key): string
244
        {
245
                try {
246
                        $member = $forbidden === 'forbiddenMembers' ? MemberPattern::fromKey($key) : null;
1 ✔
UNCOV
247
                } catch (\InvalidArgumentException) {
×
UNCOV
248
                        $member = null;
×
249
                }
250

251
                return strtolower($member === null ? ltrim($key, '\\') : "$member->class::$member->name");
1 ✔
252
        }
253

254

255
        /** The entry as the map reads it, without the version it is right from; `{write: X}` is X. */
256
        private static function removeFrom(mixed $value): mixed
257
        {
258
                if (!is_array($value) || !array_key_exists('from', $value)) {
1 ✔
259
                        return $value;
1 ✔
260
                }
261

262
                unset($value['from']);
1 ✔
263
                return array_keys($value) === ['write'] ? $value['write'] : $value;
1 ✔
264
        }
265
}
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