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

dg / dresscode / 39542709967

21 Oct 2026 04:10AM UTC coverage: 97.601% (+0.04%) from 97.558%
39542709967

push

github

dg
upgrading: the data of PHP are those of the package php, and an upgrading file is a class of its own

What PHP retired is src/Rules/Upgrading/php.neon read as the upgrading data of the platform
package `php`, cut by the target of PHP as a package is by its constraint and laid where
`upgrading.api.phpData` is `yes`, which the standard nette and the set deprecations say. A
call doing nothing since a version earlier than its deprecation stands in the section of
the deprecation with `from`, so no name stands in two maps. NoDeprecatedPhpCallsRule,
NoDeprecatedPhpConstantsRule, PhpUpgradingData and the decisions php.deprecatedCall,
deprecatedCallExcept, deprecatedConstant and deprecatedMember are gone; the values of
arguments PHP deprecated, which no map can name, are NoDeprecatedPhpArgumentsRule under
upgrading.php.deprecatedArgument.

An upgrading file is read and checked by Config\UpgradingFile and laid out for a constraint
by selectFor(). The data keep the words of the file and the version that retired an entry
beside them (UpgradingData::$retirements), which ends its sentence only where the data are
laid (buildDecisions(), shared by ConfigResolver and RuleTester), so UpgradingTester checks
the sentence its author wrote. The versions of PHP are a property of ProjectPackages of its
own (withPhp()), a section counts as unreached only where something in it is not rewritten,
Rewrite::write() spells the global functions of a template for both rules, FunctionPattern
keeps the name as the key writes it, and a call standing as a statement is taken with the
statement, which removing it takes out.

218 of 222 new or added lines in 16 files covered. (98.2%)

56 existing lines in 11 files now uncovered.

24741 of 25349 relevant lines covered (97.6%)

0.98 hits per line

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

95.65
/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};
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, which `selectFor()` lays out for the versions of the package the
19
 * code must work with.
20
 * @internal
21
 */
22
final readonly class UpgradingFile
23
{
24
        /** a map of a section above the lowest version => the forbidden map its entries are reported by */
25
        private const Retired = [
26
                'forbiddenClasses' => 'forbiddenClasses',
27
                'forbiddenFunctions' => 'forbiddenFunctions',
28
                'forbiddenConstants' => 'forbiddenConstants',
29
                'forbiddenMembers' => 'forbiddenMembers',
30
                'replacedClasses' => 'forbiddenClasses',
31
                'replacedFunctions' => 'forbiddenFunctions',
32
                'replacedMembers' => 'forbiddenMembers',
33
                'replacedCalls' => 'forbiddenMembers',
34
        ];
35

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

40

41
        private function __construct(
42
                /** the package the sections are versions of, `php` for PHP itself */
43
                public string $package,
1 ✔
44
                /** @var array{functions: list<string>, constants: list<string>}  what the package declares in its namespaces, fully qualified */
45
                public array $namespaces,
1 ✔
46
                /** @var array<string, array<string, array<string, mixed>>>  version => map => its entries, the oldest first */
47
                private array $sections,
1 ✔
48
        ) {
49
        }
1 ✔
50

51

52
        /**
53
         * Reads the file, a value as NEON gives it, an entity too, for the grammar of the map to read.
54
         * @throws ConfigurationException
55
         */
56
        public static function fromFile(string $file, string $label): self
57
        {
58
                if (!is_file($file)) {
1 ✔
NEW
59
                        throw new ConfigurationException("Upgrading file $label does not exist.");
×
60
                }
61

62
                try {
63
                        $data = Neon::decodeFile($file);
1 ✔
64
                } catch (NeonException $e) {
1 ✔
65
                        throw new ConfigurationException("Upgrading file $label is not valid NEON: {$e->getMessage()}", previous: $e);
1 ✔
66
                }
67

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

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

83
                        foreach ($section as $map => $entries) {
1 ✔
84
                                if (!is_string($map) || !is_array($entries)) {
1 ✔
85
                                        throw new ConfigurationException("Upgrading file $label: The map `$map` in `$key` must be a map of entries; a package turns no rule on.");
1 ✔
86
                                }
87

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

95
                                $sections[$m[1]][$map] = $entries;
1 ✔
96
                        }
97
                }
98

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

106
                return new self($package, $namespaces, $sections);
1 ✔
107
        }
108

109

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

134
                                        $maps[$map][$key] = self::removeFrom($value);
1 ✔
135
                                        unset($retirements[$map][$key]);
1 ✔
136
                                        if (in_array($map, self::Retired, true) && $value !== MemberMapGrammar::Keep) {
1 ✔
137
                                                $retirements[$map][$key] = "$named $since";
1 ✔
138
                                        }
139
                                }
140
                        }
141

142
                        if ($left === [] || $constraint === null) {
1 ✔
143
                                continue;
1 ✔
144
                        }
145

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

155
                foreach ($retired as $map => $entries) {
1 ✔
156
                        $written = self::collectNames($maps, $map);
1 ✔
157
                        foreach ($entries as $key => [$sentence, $version]) {
1 ✔
158
                                if (!isset($written[strtolower(ltrim($key, '\\'))])) {
1 ✔
159
                                        $maps[$map][$key] = $sentence;
1 ✔
160
                                        $retirements[$map][$key] = $version;
1 ✔
161
                                }
162
                        }
163
                }
164

165
                return new UpgradingData($layer, $this->package, $this->namespaces, $maps, $retirements, $unreached, $reported);
1 ✔
166
        }
167

168

169
        /**
170
         * What entries left above the lowest version say the code still meets on a version it may run on, as entries of the
171
         * forbidden maps, since what they write instead is not there in the lowest one: what they forbid, and what they
172
         * replace, a call by the shape of its arguments as `forbiddenMembers` reads it, a call of a function as the
173
         * function, each with its sentence and the version it says, where that is not the one of its section. An annotation
174
         * forbids nothing by itself, so its map and those of plugins say nothing.
175
         * @param  array<string, array<string, mixed>>  $section  map => its entries
176
         * @return list<array{string, string, ?string, ?string}>  forbidden map, key, sentence, version
177
         */
178
        private static function retire(array $section): array
179
        {
180
                $retired = [];
1 ✔
181
                foreach ($section as $map => $entries) {
1 ✔
182
                        $forbidden = self::Retired[$map] ?? null;
1 ✔
183
                        if ($forbidden === null) {
1 ✔
NEW
184
                                continue;
×
185
                        }
186

187
                        foreach ($entries as $key => $value) {
1 ✔
188
                                [$write, $from] = is_array($value) ? [$value['write'] ?? null, $value['from'] ?? null] : [$value, null];
1 ✔
189
                                if ($write === MemberMapGrammar::Keep) {
1 ✔
190
                                        continue;
1 ✔
191
                                }
192

193
                                [$into, $key] = $map === 'replacedCalls' && MemberMapGrammar::isFunctionKey((string) $key)
1 ✔
194
                                        ? ['forbiddenFunctions', FunctionPattern::fromKey((string) $key)->name]
1 ✔
195
                                        : [$forbidden, (string) $key];
1 ✔
196
                                $retired[] = match (true) {
1 ✔
197
                                        $map === $forbidden => [$into, $key, $write, null],
1 ✔
198
                                        $write === MemberMapGrammar::Removed => [$into, $key, 'does nothing', $from],
1 ✔
199
                                        is_string($write) => [$into, $key, 'replaced by `' . ltrim($write, '\\') . '`', null],
1 ✔
200
                                        default => [$into, $key, 'replaced', null],
1 ✔
201
                                };
202
                        }
203
                }
204

205
                return $retired;
1 ✔
206
        }
207

208

209
        /**
210
         * The keys the maps reached already have that the forbidden map would report, in lower case and without the
211
         * leading backslash.
212
         * @param  array<string, array<string, mixed>>  $maps  map => its entries
213
         * @return array<string, true>
214
         */
215
        private static function collectNames(array $maps, string $forbidden): array
216
        {
217
                $named = [];
1 ✔
218
                foreach (array_keys(self::Retired, $forbidden, strict: true) as $map) {
1 ✔
219
                        foreach (array_keys($maps[$map] ?? []) as $key) {
1 ✔
220
                                $named[strtolower(ltrim((string) $key, '\\'))] = true;
1 ✔
221
                        }
222
                }
223

224
                return $named;
1 ✔
225
        }
226

227

228
        /** The entry as the map reads it, without the version it is right from; `{write: X}` is X. */
229
        private static function removeFrom(mixed $value): mixed
230
        {
231
                if (!is_array($value) || !array_key_exists('from', $value)) {
1 ✔
232
                        return $value;
1 ✔
233
                }
234

235
                unset($value['from']);
1 ✔
236
                return array_keys($value) === ['write'] ? $value['write'] : $value;
1 ✔
237
        }
238
}
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