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

dg / dresscode / 39680309856

19 Oct 2026 08:16AM UTC coverage: 97.606% (+0.005%) from 97.601%
39680309856

push

github

dg
uses the Runner of nette/command-line

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

58 existing lines in 10 files now uncovered.

24869 of 25479 relevant lines covered (97.61%)

0.98 hits per line

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

94.07
/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 ✔
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 ✔
99
                } catch (\InvalidArgumentException $e) {
×
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
         * Every section as it is written, the oldest first: its maps, the entries without the version `from` says their fix
109
         * is right from, and the requirements it turns on.
110
         * @return array<string, array{array<string, array<string, mixed>>, array<string, string>}>  version => maps, requirements
111
         */
112
        public function getSections(): array
113
        {
114
                $versions = array_unique(array_map(strval(...), [...array_keys($this->sections), ...array_keys($this->requirements)]));
1 ✔
115
                usort($versions, fn(string $a, string $b) => version_compare($a, $b));
1 ✔
116
                $sections = [];
1 ✔
117
                foreach ($versions as $version) {
1 ✔
118
                        $sections[$version] = [
1 ✔
119
                                array_map(fn(array $entries) => array_map(self::removeFrom(...), $entries), $this->sections[$version] ?? []),
1 ✔
120
                                $this->requirements[$version] ?? [],
1 ✔
121
                        ];
122
                }
123

124
                return $sections;
1 ✔
125
        }
126

127

128
        /**
129
         * The data for code that must work with the versions of the constraint, null where any version does. An entry is
130
         * rewritten where the lowest version reaches its section, or the version `from` says its fix is right from, a later
131
         * section having the last word on it: every entry where any version does, none where there is no lower bound.
132
         * What a section above the lowest version retires, where a version the constraint allows reaches it, is reported
133
         * without a fix as the entry of a forbidden map (`retire()`), unless a section reached rewrites or forbids the same
134
         * thing already. A requirement is turned on where the lowest version reaches its section, and says nothing above it.
135
         */
136
        public function selectFor(?string $constraint, Layer $layer): UpgradingData
137
        {
138
                $lowest = $constraint === null ? null : Versions::findLowestVersion($constraint);
1 ✔
139
                $reaches = fn(string $version) => $constraint === null || ($lowest !== null && version_compare($lowest, $version, '>='));
1 ✔
140
                $maps = $retirements = $retired = $unreached = $reported = [];
1 ✔
141
                foreach ($this->sections as $since => $section) {
1 ✔
142
                        $since = (string) $since;
1 ✔
143
                        $left = [];
1 ✔
144
                        foreach ($section as $map => $entries) {
1 ✔
145
                                foreach ($entries as $key => $value) {
1 ✔
146
                                        $from = is_array($value) ? $value['from'] ?? null : null;
1 ✔
147
                                        if (!$reaches($since) && ($from === null || !$reaches($from))) {
1 ✔
148
                                                $left[$map][$key] = $value;
1 ✔
149
                                                continue;
1 ✔
150
                                        }
151

152
                                        $maps[$map][$key] = self::removeFrom($value);
1 ✔
153
                                        unset($retirements[$map][$key]);
1 ✔
154
                                        if (in_array($map, self::ForbiddenMaps, true) && $value !== MemberMapGrammar::Keep) {
1 ✔
155
                                                $retirements[$map][$key] = $since;
1 ✔
156
                                        }
157
                                }
158
                        }
159

160
                        if ($left === [] || $constraint === null) {
1 ✔
161
                                continue;
1 ✔
162
                        }
163

164
                        $unreached[] = $since;
1 ✔
165
                        if (!Versions::isSubset($constraint, "<$since")) {
1 ✔
166
                                $reported[] = $since;
1 ✔
167
                                foreach (self::retire($left) as [$map, $key, $sentence, $version]) {
1 ✔
168
                                        $retired[$map][$key] = [$sentence, $version ?? $since];
1 ✔
169
                                }
170
                        }
171
                }
172

173
                $written = [];
1 ✔
174
                foreach ($maps as $map => $entries) {
1 ✔
175
                        foreach (array_keys($entries) as $key) {
1 ✔
176
                                $target = self::findForbidden($map, (string) $key);
1 ✔
177
                                if ($target !== null) {
1 ✔
178
                                        $written[$target[0]][self::identify(...$target)] = true;
1 ✔
179
                                }
180
                        }
181
                }
182

183
                foreach ($retired as $map => $entries) {
1 ✔
184
                        foreach ($entries as $key => [$sentence, $version]) {
1 ✔
185
                                if (!isset($written[$map][self::identify($map, $key)])) {
1 ✔
186
                                        $maps[$map][$key] = $sentence;
1 ✔
187
                                        $retirements[$map][$key] = $version;
1 ✔
188
                                }
189
                        }
190
                }
191

192
                $requirements = [];
1 ✔
193
                foreach ($this->requirements as $since => $values) {
1 ✔
194
                        if ($reaches((string) $since)) {
1 ✔
195
                                $requirements = array_replace($requirements, $values);
1 ✔
196
                        }
197
                }
198

199
                return new UpgradingData($layer, $this->package, $this->namespaces, $maps, $retirements, $unreached, $reported, $requirements);
1 ✔
200
        }
201

202

203
        /**
204
         * What entries left above the lowest version say the code still meets on a version it may run on, as entries of the
205
         * forbidden maps (`findForbidden()`), since what they write instead is not there in the lowest one: what they forbid,
206
         * and what they replace, each with its sentence and the version it says, where that is not the one of its section.
207
         * @param  array<string, array<string, mixed>>  $section  map => its entries
208
         * @return list<array{string, string, ?string, ?string}>  forbidden map, key, sentence, version
209
         */
210
        private static function retire(array $section): array
211
        {
212
                $retired = [];
1 ✔
213
                foreach ($section as $map => $entries) {
1 ✔
214
                        foreach ($entries as $key => $value) {
1 ✔
215
                                [$write, $from] = is_array($value) ? [$value['write'] ?? null, $value['from'] ?? null] : [$value, null];
1 ✔
216
                                $target = self::findForbidden($map, (string) $key);
1 ✔
217
                                if ($target === null || $write === MemberMapGrammar::Keep) {
1 ✔
218
                                        continue;
1 ✔
219
                                }
220

221
                                [$forbidden, $name] = $target;
1 ✔
222
                                $retired[] = match (true) {
1 ✔
223
                                        $map === $forbidden => [$forbidden, $name, $write, null],
1 ✔
224
                                        $write === MemberMapGrammar::Removed => [$forbidden, $name, 'does nothing', $from],
1 ✔
225
                                        is_string($write) => [$forbidden, $name, 'replaced by `' . ltrim($write, '\\') . '`', null],
1 ✔
226
                                        default => [$forbidden, $name, 'replaced', null],
1 ✔
227
                                };
228
                        }
229
                }
230

231
                return $retired;
1 ✔
232
        }
233

234

235
        /**
236
         * The forbidden map an entry of the map is reported by where it cannot be rewritten, and its key there: a call of
237
         * a function as the function, a call of a member by the shape of its arguments as `forbiddenMembers` reads it; null
238
         * for a map that forbids nothing by itself, an annotation and the maps of plugins, and for a key that is no name.
239
         * @return ?array{string, string}
240
         */
241
        private static function findForbidden(string $map, string $key): ?array
242
        {
243
                try {
244
                        return match (true) {
245
                                in_array($map, self::ForbiddenMaps, true) => [$map, $key],
1 ✔
246
                                $map === 'replacedClasses' => ['forbiddenClasses', $key],
1 ✔
247
                                $map === 'replacedFunctions' => ['forbiddenFunctions', $key],
1 ✔
248
                                $map === 'replacedMembers' => ['forbiddenMembers', $key],
1 ✔
249
                                $map === 'replacedCalls' && MemberMapGrammar::isFunctionKey($key)
1 ✔
250
                                        => ['forbiddenFunctions', FunctionPattern::fromKey($key)->name],
1 ✔
251
                                $map === 'replacedCalls' => ['forbiddenMembers', $key],
1 ✔
252
                                default => null,
1 ✔
253
                        };
UNCOV
254
                } catch (\InvalidArgumentException) {
×
UNCOV
255
                        return null; // the grammar of the map says what is wrong with the key
×
256
                }
257
        }
258

259

260
        /**
261
         * What the key of a forbidden map names whatever way it is written, so that one rewritten and one forbidden meet: a
262
         * member by its class and name, a class, a function and a constant by its name.
263
         */
264
        private static function identify(string $forbidden, string $key): string
265
        {
266
                try {
267
                        $member = $forbidden === 'forbiddenMembers' ? MemberPattern::fromKey($key) : null;
1 ✔
UNCOV
268
                } catch (\InvalidArgumentException) {
×
UNCOV
269
                        $member = null;
×
270
                }
271

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

275

276
        /** The entry as the map reads it, without the version it is right from; `{write: X}` is X. */
277
        private static function removeFrom(mixed $value): mixed
278
        {
279
                if (!is_array($value) || !array_key_exists('from', $value)) {
1 ✔
280
                        return $value;
1 ✔
281
                }
282

283
                unset($value['from']);
1 ✔
284
                return array_keys($value) === ['write'] ? $value['write'] : $value;
1 ✔
285
        }
286
}
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