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

eliashaeussler / phpstan-config / 22738980121

05 Mar 2026 10:06PM UTC coverage: 64.364% (-32.3%) from 96.654%
22738980121

Pull #132

github

eliashaeussler
[FEATURE] Add custom rule to compare composer.json and ext_emconf.php
Pull Request #132: [FEATURE] Add custom rule to compare composer.json and ext_emconf.php

153 of 371 new or added lines in 11 files covered. (41.24%)

410 of 637 relevant lines covered (64.36%)

4.55 hits per line

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

0.0
/src/Rule/ExtEmConfVersionConstraintRule.php
1
<?php
2

3
declare(strict_types=1);
4

5
/*
6
 * This file is part of the Composer package "eliashaeussler/phpstan-config".
7
 *
8
 * Copyright (C) 2023-2026 Elias Häußler <elias@haeussler.dev>
9
 *
10
 * This program is free software: you can redistribute it and/or modify
11
 * it under the terms of the GNU General Public License as published by
12
 * the Free Software Foundation, either version 3 of the License, or
13
 * (at your option) any later version.
14
 *
15
 * This program is distributed in the hope that it will be useful,
16
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
17
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
18
 * GNU General Public License for more details.
19
 *
20
 * You should have received a copy of the GNU General Public License
21
 * along with this program. If not, see <https://www.gnu.org/licenses/>.
22
 */
23

24
namespace EliasHaeussler\PHPStanConfig\Rule;
25

26
use EliasHaeussler\PHPStanConfig\Enums;
27
use EliasHaeussler\PHPStanConfig\Resource;
28
use Exception;
29
use PhpParser\Node;
30
use PHPStan\Analyser;
31
use PHPStan\Rules;
32

33
use function array_values;
34

35
/**
36
 * ExtEmConfVersionConstraintRule.
37
 *
38
 * @author Elias Häußler <elias@haeussler.dev>
39
 * @license GPL-3.0-or-later
40
 *
41
 * @implements CustomRule<Node\Expr\Assign>
42
 */
43
final readonly class ExtEmConfVersionConstraintRule implements CustomRule
44
{
45
    private Resource\Local\ComposerJson $composerJson;
46
    private Resource\Local\ExtEmConf $extEmConf;
47

NEW
48
    public function __construct(
×
49
        private string $currentWorkingDirectory,
50
    ) {
NEW
51
        $this->composerJson = new Resource\Local\ComposerJson($this->currentWorkingDirectory);
×
NEW
52
        $this->extEmConf = new Resource\Local\ExtEmConf($this->currentWorkingDirectory);
×
53
    }
54

NEW
55
    public static function getIdentifier(): string
×
56
    {
NEW
57
        return 'extEmConfVersionConstraint';
×
58
    }
59

NEW
60
    public function getNodeType(): string
×
61
    {
NEW
62
        return Node\Expr\Assign::class;
×
63
    }
64

NEW
65
    public function processNode(Node $node, Analyser\Scope $scope): array
×
66
    {
67
        // Early return on other files than ext_emconf.php
NEW
68
        if ($scope->getFile() !== $this->extEmConf->filename) {
×
NEW
69
            return [];
×
70
        }
71

72
        // Left-hand side must be $EM_CONF[$_EXTKEY]
NEW
73
        if (!$this->isEmConfAssignment($node->var)) {
×
NEW
74
            return [];
×
75
        }
76

77
        // Right-hand side must be an array literal
NEW
78
        if (!$node->expr instanceof Node\Expr\Array_) {
×
NEW
79
            return [];
×
80
        }
81

NEW
82
        $emConfDependencies = $this->extEmConf->extractRelations($node->expr, Enums\PackageRelation::Requirement);
×
NEW
83
        $emConfConflicts = $this->extEmConf->extractRelations($node->expr, Enums\PackageRelation::Conflict);
×
NEW
84
        $emConfSuggestions = $this->extEmConf->extractRelations($node->expr, Enums\PackageRelation::Suggestion);
×
85

NEW
86
        $composerRequirements = $this->composerJson->extractPackages(Enums\PackageRelation::Requirement);
×
NEW
87
        $composerConflicts = $this->composerJson->extractPackages(Enums\PackageRelation::Conflict);
×
NEW
88
        $composerSuggestions = $this->composerJson->extractPackages(Enums\PackageRelation::Suggestion);
×
89

NEW
90
        $dependenciesMaps = $this->buildPackageMaps($emConfDependencies, $composerRequirements);
×
NEW
91
        $conflictsMaps = $this->buildPackageMaps($emConfConflicts, $composerConflicts);
×
NEW
92
        $suggestionsMaps = $this->buildPackageMaps($emConfSuggestions, $composerSuggestions);
×
93

NEW
94
        return [
×
NEW
95
            ...$this->collectErrors($dependenciesMaps),
×
NEW
96
            ...$this->collectErrors($conflictsMaps),
×
NEW
97
            ...$this->collectErrors($suggestionsMaps),
×
NEW
98
        ];
×
99
    }
100

NEW
101
    private function isEmConfAssignment(Node\Expr $expr): bool
×
102
    {
NEW
103
        if (!$expr instanceof Node\Expr\ArrayDimFetch) {
×
NEW
104
            return false;
×
105
        }
106

NEW
107
        if (!$expr->var instanceof Node\Expr\Variable) {
×
NEW
108
            return false;
×
109
        }
110

NEW
111
        if ('EM_CONF' !== $expr->var->name) {
×
NEW
112
            return false;
×
113
        }
114

NEW
115
        if (null === $expr->dim) {
×
NEW
116
            return false;
×
117
        }
118

NEW
119
        return $expr->dim instanceof Node\Expr\Variable && '_EXTKEY' === $expr->dim->name;
×
120
    }
121

122
    /**
123
     * @param list<Resource\EmConfRelation>  $emConfRelations
124
     * @param list<Resource\ComposerPackage> $composerPackages
125
     *
126
     * @return list<Resource\PackageMap>
127
     */
NEW
128
    private function buildPackageMaps(array $emConfRelations, array $composerPackages): array
×
129
    {
NEW
130
        $map = [];
×
NEW
131
        $identify = static fn (
×
NEW
132
            ?Resource\EmConfRelation $relation,
×
NEW
133
            ?Resource\ComposerPackage $package,
×
NEW
134
        ) => $relation?->name.'_'.$package?->name;
×
135

NEW
136
        foreach ($emConfRelations as $emConfRelation) {
×
NEW
137
            $resolvedPackage = null;
×
138

NEW
139
            foreach ($composerPackages as $composerPackage) {
×
NEW
140
                $possibleRelationNames = $composerPackage->getPossibleEmConfRelationNames();
×
141

NEW
142
                if (in_array($emConfRelation->name, $possibleRelationNames, true)) {
×
NEW
143
                    $resolvedPackage = $composerPackage;
×
NEW
144
                    break;
×
145
                }
146
            }
147

NEW
148
            $identifier = $identify($emConfRelation, $resolvedPackage);
×
NEW
149
            $map[$identifier] = new Resource\PackageMap($emConfRelation, $resolvedPackage);
×
150
        }
151

NEW
152
        foreach ($composerPackages as $composerPackage) {
×
NEW
153
            $possibleRelationNames = $composerPackage->getPossibleEmConfRelationNames();
×
NEW
154
            $resolvedRelation = null;
×
155

156
            // Skip all non-platform and non-extension packages
NEW
157
            if ('php' !== $composerPackage->name && !$composerPackage->isExtension()) {
×
NEW
158
                continue;
×
159
            }
160

NEW
161
            foreach ($emConfRelations as $emConfRelation) {
×
NEW
162
                if (in_array($emConfRelation->name, $possibleRelationNames, true)) {
×
NEW
163
                    $resolvedRelation = $emConfRelation;
×
NEW
164
                    break;
×
165
                }
166
            }
167

NEW
168
            $identifier = $identify($resolvedRelation, $composerPackage);
×
NEW
169
            $map[$identifier] = new Resource\PackageMap($resolvedRelation, $composerPackage);
×
170
        }
171

NEW
172
        return array_values($map);
×
173
    }
174

175
    /**
176
     * @param list<Resource\PackageMap> $packageMaps
177
     *
178
     * @return list<Rules\IdentifierRuleError>
179
     */
NEW
180
    private function collectErrors(array $packageMaps): array
×
181
    {
NEW
182
        $errors = [];
×
183

NEW
184
        foreach ($packageMaps as $packageMap) {
×
NEW
185
            if (!$packageMap->isComplete()) {
×
NEW
186
                $errors[] = $this->buildIncompletePackageMapError($packageMap);
×
187
            } else {
188
                try {
NEW
189
                    if (!$packageMap->hasEqualConstraints()) {
×
NEW
190
                        $errors[] = $this->buildConstraintMismatchError($packageMap);
×
191
                    }
NEW
192
                } catch (Exception) {
×
193
                    // Ignore invalid constraints to keep analysis stable
194
                }
195
            }
196
        }
197

NEW
198
        return $errors;
×
199
    }
200

201
    /**
202
     * @return Rules\IdentifierRuleError
203
     */
NEW
204
    private function buildIncompletePackageMapError(Resource\PackageMap $packageMap): Rules\RuleError
×
205
    {
NEW
206
        if (null === $packageMap->emConfRelation) {
×
207
            /** @var Resource\ComposerPackage $composerPackage */
NEW
208
            $composerPackage = $packageMap->composerPackage;
×
NEW
209
            $identifier = 'extEmConf.missing.'.$composerPackage->relation->forExtEmConf();
×
NEW
210
            $ruleError = Rules\RuleErrorBuilder::message(
×
NEW
211
                sprintf(
×
NEW
212
                    '%s Composer package "%s" is not reflected in ext_emconf.php file.',
×
NEW
213
                    match ($composerPackage->relation) {
×
NEW
214
                        Enums\PackageRelation::Conflict => 'Conflicting',
×
NEW
215
                        Enums\PackageRelation::Suggestion => 'Suggested',
×
NEW
216
                        Enums\PackageRelation::Requirement => 'Required',
×
NEW
217
                    },
×
NEW
218
                    $composerPackage->name,
×
NEW
219
                ),
×
NEW
220
            );
×
221

NEW
222
            return $ruleError
×
NEW
223
                ->identifier($identifier)
×
NEW
224
                ->file($this->composerJson->manifest->path)
×
NEW
225
                // @todo Determine line from composer.json
×
NEW
226
                // ->line(1)
×
NEW
227
                ->build()
×
NEW
228
            ;
×
229
        }
230

NEW
231
        $identifier = 'composerJson.missing.'.$packageMap->emConfRelation->relation->forComposerJson();
×
NEW
232
        $ruleError = Rules\RuleErrorBuilder::message(
×
NEW
233
            sprintf(
×
NEW
234
                '%s extension "%s" is not reflected in composer.json file.',
×
NEW
235
                match ($packageMap->emConfRelation->relation) {
×
NEW
236
                    Enums\PackageRelation::Conflict => 'Conflicting',
×
NEW
237
                    Enums\PackageRelation::Requirement => 'Required',
×
NEW
238
                    Enums\PackageRelation::Suggestion => 'Suggested',
×
NEW
239
                },
×
NEW
240
                $packageMap->emConfRelation->name,
×
NEW
241
            ),
×
NEW
242
        );
×
243

NEW
244
        if ('typo3' === $packageMap->emConfRelation->name) {
×
NEW
245
            $ruleError->tip('Use "typo3/cms-core" as Composer package name.');
×
246
        }
247

NEW
248
        return $ruleError
×
NEW
249
            ->identifier($identifier)
×
NEW
250
            ->file($this->extEmConf->filename)
×
NEW
251
            ->line($packageMap->emConfRelation->line)
×
NEW
252
            ->build()
×
NEW
253
        ;
×
254
    }
255

256
    /**
257
     * @return Rules\IdentifierRuleError
258
     */
NEW
259
    private function buildConstraintMismatchError(Resource\PackageMap $packageMap): Rules\RuleError
×
260
    {
261
        /** @var Resource\EmConfRelation $emConfRelation */
NEW
262
        $emConfRelation = $packageMap->emConfRelation;
×
263
        /** @var Resource\ComposerPackage $composerPackage */
NEW
264
        $composerPackage = $packageMap->composerPackage;
×
265

NEW
266
        return Rules\RuleErrorBuilder::message(
×
NEW
267
            sprintf(
×
NEW
268
                'Version constraint of %s "%s" in ext_emconf.php (%s) is not equal to version constraint in composer.json (%s).',
×
NEW
269
                match ($emConfRelation->relation) {
×
NEW
270
                    Enums\PackageRelation::Conflict => 'conflict',
×
NEW
271
                    Enums\PackageRelation::Requirement => 'dependency',
×
NEW
272
                    Enums\PackageRelation::Suggestion => 'suggestion',
×
NEW
273
                },
×
NEW
274
                $emConfRelation->name,
×
NEW
275
                $emConfRelation->constraint,
×
NEW
276
                $composerPackage->constraint,
×
NEW
277
            ),
×
NEW
278
        )
×
NEW
279
            ->identifier('extEmConf.constraints.mismatch')
×
NEW
280
            ->file($this->extEmConf->filename)
×
NEW
281
            ->line($emConfRelation->line)
×
NEW
282
            ->build()
×
NEW
283
        ;
×
284
    }
285
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc