Skip to content

Commit f396ff6

Browse files
committed
[FEATURE] PHPStan: list the granted rule-violation exemptions
Exemptions expire with the next ILIAS major, so somebody has to walk through them at every version bump. list_exemptions.sh prints them with rule, granted version, the declaration they sit on and the reason, grouped by component, and covers both the attributes and the inline ignores in resource scripts. --version=13 shows what a bump would invalidate, and the script exits non-zero on an expired or unversioned exemption so it can serve as a check of its own.
1 parent 7604b04 commit f396ff6

3 files changed

Lines changed: 314 additions & 0 deletions

File tree

scripts/PHPStan/README.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,19 @@ current major is read from `ilias_version.php` (see
118118
Renewing an exemption is a one-character edit (`12``13`), but it is an edit
119119
somebody has to make and a reviewer gets to see.
120120

121+
### Listing the exemptions
122+
123+
```bash
124+
scripts/PHPStan/list_exemptions.sh # everything, with reasons
125+
scripts/PHPStan/list_exemptions.sh components/ILIAS/Form # one component
126+
scripts/PHPStan/list_exemptions.sh --version=13 # what expires in ILIAS 13
127+
```
128+
129+
Prints every exemption with its rule, the version it was granted for, the
130+
declaration it sits on and the reason, grouped by component. It exits non-zero when
131+
an exemption has expired or carries no version at all, so it can be used as a check
132+
of its own after a version bump.
133+
121134
There is no baseline file: the gate must stay green through in-code exemptions, not
122135
by grandfathering. (If a mass migration ever needs one, `--generate-baseline` can
123136
create it and add its `includes:` entry back to `code_rules.neon`.)
Lines changed: 285 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,285 @@
1+
<?php
2+
3+
/**
4+
* This file is part of ILIAS, a powerful learning management system
5+
* published by ILIAS open source e-Learning e.V.
6+
*
7+
* ILIAS is licensed with the GPL-3.0,
8+
* see https://www.gnu.org/licenses/gpl-3.0.en.html
9+
* You should have received a copy of said license along with the
10+
* source code, too.
11+
*
12+
* If this is not the case or you just want to try ILIAS, you'll find
13+
* us at:
14+
* https://www.ilias.de
15+
* https://github.com/ILIAS-eLearning
16+
*
17+
*********************************************************************/
18+
19+
declare(strict_types=1);
20+
21+
/**
22+
* Lists every rule-violation exemption granted in the code base.
23+
*
24+
* Exemptions are granted per ILIAS major version and expire with the next one, so
25+
* this is the list somebody has to walk through when the version is bumped. Run it
26+
* through scripts/PHPStan/list_exemptions.sh.
27+
*/
28+
29+
namespace ILIAS\Scripts\PHPStan;
30+
31+
use PhpParser\Node;
32+
use PhpParser\NodeTraverser;
33+
use PhpParser\NodeVisitorAbstract;
34+
use PhpParser\ParserFactory;
35+
use PhpParser\PrettyPrinter\Standard as PrettyPrinter;
36+
37+
require_once __DIR__ . '/../../vendor/composer/vendor/autoload.php';
38+
require_once __DIR__ . '/IliasVersion.php';
39+
40+
/** Attribute short names that grant an exemption. */
41+
const EXEMPTION_ATTRIBUTES = ['AllowRuleViolation', 'AllowSuperglobalWrite'];
42+
43+
$target = 'components/ILIAS';
44+
$against = IliasVersion::major();
45+
foreach (array_slice($argv, 1) as $argument) {
46+
if (str_starts_with($argument, '--version=')) {
47+
$against = (int) substr($argument, strlen('--version='));
48+
continue;
49+
}
50+
$target = rtrim($argument, '/');
51+
}
52+
53+
if (!is_dir($target)) {
54+
fwrite(STDERR, "Not a directory: {$target}\n");
55+
exit(2);
56+
}
57+
58+
$parser = (new ParserFactory())->createForNewestSupportedVersion();
59+
$printer = new PrettyPrinter();
60+
61+
/**
62+
* Collects the exemption attributes of one file together with the declaration they
63+
* sit on.
64+
*/
65+
final class ExemptionVisitor extends NodeVisitorAbstract
66+
{
67+
/** @var list<array{line:int, on:string, rules:string, version:?int, reason:string}> */
68+
public array $found = [];
69+
70+
/** @var list<string> */
71+
private array $scope = [];
72+
73+
public function __construct(private readonly PrettyPrinter $printer)
74+
{
75+
}
76+
77+
public function enterNode(Node $node): null
78+
{
79+
if ($node instanceof Node\Stmt\ClassLike && $node->name !== null) {
80+
$this->scope[] = $node->name->toString();
81+
}
82+
if ($node instanceof Node\Stmt\ClassMethod || $node instanceof Node\Stmt\Function_) {
83+
$this->scope[] = $node->name->toString() . '()';
84+
}
85+
86+
if ($node instanceof Node\Stmt\ClassLike
87+
|| $node instanceof Node\Stmt\ClassMethod
88+
|| $node instanceof Node\Stmt\Function_) {
89+
$this->collect($node);
90+
}
91+
92+
return null;
93+
}
94+
95+
public function leaveNode(Node $node): null
96+
{
97+
if ($node instanceof Node\Stmt\ClassLike && $node->name !== null) {
98+
array_pop($this->scope);
99+
}
100+
if ($node instanceof Node\Stmt\ClassMethod || $node instanceof Node\Stmt\Function_) {
101+
array_pop($this->scope);
102+
}
103+
104+
return null;
105+
}
106+
107+
private function collect(Node\Stmt\ClassLike|Node\Stmt\ClassMethod|Node\Stmt\Function_ $node): void
108+
{
109+
foreach ($node->attrGroups as $group) {
110+
foreach ($group->attrs as $attribute) {
111+
$short = $attribute->name->getLast();
112+
if (!in_array($short, EXEMPTION_ATTRIBUTES, true)) {
113+
continue;
114+
}
115+
116+
$reason = null;
117+
$version = null;
118+
$rules = [];
119+
foreach ($attribute->args as $argument) {
120+
$value = $argument->value;
121+
if ($value instanceof Node\Scalar\Int_) {
122+
$version ??= $value->value;
123+
continue;
124+
}
125+
if ($value instanceof Node\Scalar\String_) {
126+
if ($reason === null) {
127+
$reason = $value->value;
128+
} else {
129+
$rules[] = $value->value;
130+
}
131+
continue;
132+
}
133+
// concatenations of strings and ::class constants
134+
$reason ??= $this->text($value);
135+
}
136+
137+
if ($rules === []) {
138+
$rules = [$short === 'AllowSuperglobalWrite' ? 'ilias.superglobalWrite' : '(unspecified)'];
139+
}
140+
141+
$this->found[] = [
142+
'line' => $attribute->getStartLine(),
143+
'on' => implode('::', $this->scope),
144+
'rules' => implode(', ', $rules),
145+
'version' => $version,
146+
'reason' => self::flatten($reason ?? ''),
147+
];
148+
}
149+
}
150+
}
151+
152+
/**
153+
* Best-effort rendering of an argument that is not a plain string literal,
154+
* so a reason built by concatenation still reads like a sentence.
155+
*/
156+
private function text(Node\Expr $expr): string
157+
{
158+
if ($expr instanceof Node\Scalar\String_) {
159+
return $expr->value;
160+
}
161+
if ($expr instanceof Node\Expr\BinaryOp\Concat) {
162+
return $this->text($expr->left) . $this->text($expr->right);
163+
}
164+
if ($expr instanceof Node\Expr\ClassConstFetch
165+
&& $expr->name instanceof Node\Identifier
166+
&& $expr->name->toString() === 'class'
167+
&& $expr->class instanceof Node\Name) {
168+
return $expr->class->getLast();
169+
}
170+
171+
return $this->printer->prettyPrintExpr($expr);
172+
}
173+
174+
private static function flatten(string $text): string
175+
{
176+
return trim((string) preg_replace('/\s+/', ' ', $text));
177+
}
178+
}
179+
180+
$rows = [];
181+
182+
$iterator = new \RecursiveIteratorIterator(
183+
new \RecursiveCallbackFilterIterator(
184+
new \RecursiveDirectoryIterator($target, \FilesystemIterator::SKIP_DOTS),
185+
static fn(\SplFileInfo $file): bool =>
186+
!in_array($file->getFilename(), ['node_modules', 'vendor', 'libs', 'lib'], true)
187+
)
188+
);
189+
190+
foreach ($iterator as $file) {
191+
if (!$file->isFile() || $file->getExtension() !== 'php') {
192+
continue;
193+
}
194+
195+
$path = $file->getPathname();
196+
$code = (string) file_get_contents($path);
197+
198+
// attribute-based exemptions
199+
if (str_contains($code, 'AllowRuleViolation') || str_contains($code, 'AllowSuperglobalWrite')) {
200+
try {
201+
$ast = $parser->parse($code);
202+
} catch (\Throwable $e) {
203+
fwrite(STDERR, "Could not parse {$path}: {$e->getMessage()}\n");
204+
$ast = null;
205+
}
206+
if ($ast !== null) {
207+
$visitor = new ExemptionVisitor($printer);
208+
$traverser = new NodeTraverser();
209+
$traverser->addVisitor($visitor);
210+
$traverser->traverse($ast);
211+
foreach ($visitor->found as $entry) {
212+
$rows[] = $entry + ['file' => $path, 'kind' => 'attribute'];
213+
}
214+
}
215+
}
216+
217+
// inline ignores
218+
if (str_contains($code, '@phpstan-ignore')) {
219+
foreach (explode("\n", $code) as $index => $line) {
220+
if (!preg_match('/@phpstan-ignore\s+(ilias\.[A-Za-z0-9_.]+)\s*(?:\((.*)\))?/', $line, $match)) {
221+
continue;
222+
}
223+
$rule = $match[1];
224+
$version = null;
225+
if (preg_match('/^(.*)\.v(\d+)$/', $rule, $version_match)) {
226+
$rule = $version_match[1];
227+
$version = (int) $version_match[2];
228+
}
229+
$rows[] = [
230+
'file' => $path,
231+
'line' => $index + 1,
232+
'on' => '(statement)',
233+
'rules' => $rule,
234+
'version' => $version,
235+
'reason' => trim($match[2] ?? ''),
236+
'kind' => 'inline',
237+
];
238+
}
239+
}
240+
}
241+
242+
usort($rows, static fn(array $a, array $b): int => [$a['file'], $a['line']] <=> [$b['file'], $b['line']]);
243+
244+
$expired = 0;
245+
$unversioned = 0;
246+
$grouped = [];
247+
foreach ($rows as $row) {
248+
$parts = explode('/', $row['file']);
249+
$component = ($parts[0] === 'components' && isset($parts[2])) ? $parts[2] : $parts[0];
250+
$grouped[$component][] = $row;
251+
}
252+
253+
echo "Rule-violation exemptions, checked against ILIAS {$against}\n";
254+
echo str_repeat('=', 72), "\n";
255+
256+
foreach ($grouped as $component => $entries) {
257+
echo "\n", $component, "\n";
258+
foreach ($entries as $row) {
259+
if ($row['version'] === null) {
260+
$status = 'NO VERSION';
261+
$unversioned++;
262+
} elseif ($row['version'] < $against) {
263+
$status = 'EXPIRED';
264+
$expired++;
265+
} else {
266+
$status = 'valid for ' . $row['version'];
267+
}
268+
269+
printf(
270+
" [%-12s] %s:%d\n %s on %s\n %s\n",
271+
$status,
272+
$row['file'],
273+
$row['line'],
274+
$row['rules'],
275+
$row['on'],
276+
$row['reason'] === '' ? '(no reason given)' : $row['reason']
277+
);
278+
}
279+
}
280+
281+
$total = count($rows);
282+
echo "\n", str_repeat('-', 72), "\n";
283+
printf("%d exemption(s); %d expired, %d without a version\n", $total, $expired, $unversioned);
284+
285+
exit(($expired + $unversioned) > 0 ? 1 : 0);

scripts/PHPStan/list_exemptions.sh

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
#!/bin/bash
2+
3+
# Lists every rule-violation exemption granted in the code base, with the reason,
4+
# the ILIAS major it was granted for, and whether it still counts.
5+
#
6+
# Exemptions expire with the next major (see scripts/PHPStan/README.md), so this is
7+
# the list to walk through when the version is bumped.
8+
#
9+
# Usage:
10+
# scripts/PHPStan/list_exemptions.sh # all components
11+
# scripts/PHPStan/list_exemptions.sh components/ILIAS/Form
12+
# scripts/PHPStan/list_exemptions.sh --version=13 # what expires in ILIAS 13
13+
#
14+
# Exits non-zero when an exemption has expired or carries no version.
15+
16+
php -dxdebug.mode=off scripts/PHPStan/list_exemptions.php "$@"

0 commit comments

Comments
 (0)