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

dg / dresscode / 39713065381

03 Sep 2026 05:06AM UTC coverage: 97.877% (+0.3%) from 97.606%
39713065381

push

github

dg
released version 1.0.0

17195 of 17568 relevant lines covered (97.88%)

0.98 hits per line

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

98.03
/src/Engine/PassLoop.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\Engine;
9

10
use DressCode\{
11
        Analyses, ConfigurationException, ConvergenceException, NodeRule, Rule,
12
        RuleContext, RuleException, RuleInfo, Severity, Stage, Style, Violation,
13
};
14
use DressCode\Engine\Gaps\{Fixer, Resolver};
15
use PhpSyntax\{Node, Printer, Token, Traverser, Trivia};
16
use PhpSyntax\Nodes\FileNode;
17
use function strlen;
18

19

20
/**
21
 * Runs the rules over a file in passes until nothing mutates: each pass is one pre-order traversal per stage,
22
 * rules are dispatched by the type of the node or token, every mutation is paired with a report.
23
 * @internal
24
 */
25
final class PassLoop
26
{
27
        private const MaxPasses = 10;
28

29
        /**
30
         * @var array<string, array<class-string, list<array{NodeRule, RuleContext, string}>>>  stage => node class =>
31
         * rules entering it, with their contexts and names
32
         */
33
        private array $entering = [];
34

35
        /** @var array<string, array<class-string, list<array{NodeRule, RuleContext, string}>>>  the same for `leave()` */
36
        private array $leaving = [];
37

38
        /** @var array<string, RuleContext> */
39
        private array $contexts = [];
40

41
        /** the gap rules, applied together along the traversal of the formatting stage */
42
        private readonly Resolver $gaps;
43

44
        /** @var array<string, true>  rules that mutated the file in the current pass */
45
        private array $mutatedRules = [];
46

47
        /** @var array<string, Violation>  by fingerprint, so that a pass repeating a report adds nothing */
48
        private array $violations = [];
49

50
        /**
51
         * @var \WeakMap<Token, string>  a token whose line the fixer opened or closed => the fingerprint of the violation
52
         * it did it for, the first of its chain
53
         */
54
        private \WeakMap $opened;
55

56
        /** @var \WeakMap<Token, string>  a token whose line a rule moved => the fingerprint of the violation the move follows from */
57
        private \WeakMap $moved;
58

59
        /** @var list<array{string, Report, Severity}>  the reports of violations of the current pass: the rule, the report, its severity */
60
        private array $reported = [];
61

62
        /** @var list<string> */
63
        private array $warnings = [];
64

65
        private Fingerprints $fingerprints;
66

67
        /** @var list<int>  byte offset of the start of each line of the original code */
68
        private array $lineOffsets = [];
69
        private string $code = '';
70

71
        private FileNode $file;
72
        private string $path = '';
73

74

75
        public function __construct(
76
                private readonly RulePlan $plan,
1 ✔
77
                private readonly Analyses\Registry $analyses,
1 ✔
78
                private readonly ReportPolicy $policy = new ReportPolicy,
1 ✔
79
        ) {
80
                $this->gaps = new Resolver($plan->claims);
1 ✔
81
        }
1 ✔
82

83

84
        /** @throws RuleException|ConvergenceException|ConfigurationException */
85
        public function run(FileNode $file, string $code, string $path, Style $style, string $phpVersion): PassResult
86
        {
87
                $this->file = $file;
1 ✔
88
                $this->path = $path;
1 ✔
89
                $this->code = $code;
1 ✔
90
                $split = (array) preg_split('~\r\n|\r|\n~', $code, -1, PREG_SPLIT_OFFSET_CAPTURE);
1 ✔
91
                $lines = array_column($split, 0);
1 ✔
92
                $this->lineOffsets = array_column($split, 1);
1 ✔
93
                $this->violations = $this->warnings = $this->contexts = $this->entering = $this->leaving = [];
1 ✔
94
                $this->opened = new \WeakMap;
1 ✔
95
                $this->moved = new \WeakMap;
1 ✔
96
                $this->fingerprints = new Fingerprints($lines, $path, $this->policy->baseline);
1 ✔
97
                $suppression = Suppression::fromFile($file, $this->policy->expandName, $code, $this->policy->suppressionComments);
1 ✔
98
                foreach ($suppression->getUnknownNames() as $name => $comment) {
1 ✔
99
                        $this->warnings[] = 'Comment '
×
100
                                . Violation::formatCode($comment)
×
101
                                . " names `$name`, which is no decision, section or rule, so it silences nothing.";
×
102
                }
103

104
                $spans = new Spans($code);
1 ✔
105
                foreach ($this->plan->rules as $rule) {
1 ✔
106
                        $ruleClass = $rule::class;
1 ✔
107
                        $this->contexts[$ruleClass] = new RuleContext(
1 ✔
108
                                $file,
1 ✔
109
                                $path,
110
                                $style,
111
                                $phpVersion,
112
                                $this->analyses,
1 ✔
113
                                $suppression,
114
                                $this->fingerprints,
1 ✔
115
                                $this->policy,
1 ✔
116
                                $this->plan->gates[$ruleClass],
1 ✔
117
                                RuleInfo::fromRule($rule),
1 ✔
118
                                $spans,
119
                        );
120
                }
121

122
                $seen = [hash('xxh3', $code) => true];
1 ✔
123
                $last = $code;
1 ✔
124
                $passes = 0;
1 ✔
125
                $mutated = false;
1 ✔
126
                $mutatedRules = [];
1 ✔
127
                while (true) {
1 ✔
128
                        if ($passes++ === self::MaxPasses) {
1 ✔
129
                                throw new ConvergenceException($path, array_keys($this->mutatedRules), '');
1 ✔
130
                        }
131

132
                        $this->mutatedRules = $this->reported = [];
1 ✔
133
                        $this->fingerprints->beginPass();
1 ✔
134
                        foreach ($this->contexts as $context) {
1 ✔
135
                                $context->passStorage = [];
1 ✔
136
                        }
137

138
                        $this->analyses->beginPass($file);
1 ✔
139
                        $revision = $file->revision;
1 ✔
140
                        foreach ($this->plan->stages as $stage => $rules) {
1 ✔
141
                                $this->runStage($stage, $rules, $style);
1 ✔
142
                        }
143

144
                        if ($file->revision === $revision) {
1 ✔
145
                                $this->checkUnfixed();
1 ✔
146
                                break;
1 ✔
147
                        }
148

149
                        $mutated = true;
1 ✔
150
                        $mutatedRules += $this->mutatedRules;
1 ✔
151
                        $output = Printer::print($file);
1 ✔
152
                        $hash = hash('xxh3', $output);
1 ✔
153
                        if (isset($seen[$hash])) { // a state seen before: the rules cycle
1 ✔
154
                                throw new ConvergenceException($path, array_keys($this->mutatedRules), Diff::unified($last, $output, $path));
1 ✔
155
                        }
156

157
                        $seen[$hash] = true;
1 ✔
158
                        $last = $output;
1 ✔
159
                }
160

161
                $this->analyses->endFile($file);
1 ✔
162
                return new PassResult(
1 ✔
163
                        self::sortByPosition(array_values($this->violations)),
1 ✔
164
                        $this->warnings,
1 ✔
165
                        $passes,
166
                        $mutated,
167
                        $this->fingerprints->getBaselined(),
1 ✔
168
                        array_keys($mutatedRules),
1 ✔
169
                        // what the baseline holds is decided by the lines of the fixed text, which only another run has
170
                        remaining: $mutated && $this->policy->baseline === null ? $this->collectRemaining($last) : null,
1 ✔
171
                        output: $mutated ? $last : null,
1 ✔
172
                );
173
        }
174

175

176
        /**
177
         * The violations of the fixed text: what the last pass, which changed nothing, reported, identified by the lines
178
         * of that text as a run over it would identify them.
179
         * @return list<Violation>
180
         */
181
        private function collectRemaining(string $text): array
182
        {
183
                $fingerprints = new Fingerprints(preg_split('~\r\n|\r|\n~', $text) ?: [], $this->path);
1 ✔
184
                /** @var \WeakMap<Token, string> $opened */
185
                $opened = new \WeakMap;
1 ✔
186
                /** @var \WeakMap<Token, string> $moved */
187
                $moved = new \WeakMap;
1 ✔
188
                $violations = [];
1 ✔
189
                $spans = new Spans;
1 ✔
190
                foreach ($this->reported as [, $report, $severity]) {
1 ✔
191
                        $token = $report->at->getFirstToken();
1 ✔
192
                        $line = self::findCurrentLine($token, $report->trivia) + $report->lineInTrivia;
1 ✔
193
                        $fingerprint = $report->construct === null
1 ✔
194
                                ? $fingerprints->create($report->decision, $report->message, $line)
1 ✔
195
                                : $fingerprints->createFor($report->construct, $report->decision, $report->message, $line);
1 ✔
196
                        $violation = self::createViolation(
1 ✔
197
                                $report,
198
                                $fingerprint,
199
                                $line,
200
                                $token?->getCurrentColumn(),
1 ✔
201
                                $spans->find($report->at, $report->trivia, $report->lineInTrivia, $report->gap, $report->follows),
1 ✔
202
                                $severity,
203
                                $opened,
204
                                $moved,
205
                        );
206
                        $violations[$fingerprint] ??= $violation;
1 ✔
207
                }
208

209
                return self::sortByPosition(array_values($violations));
1 ✔
210
        }
211

212

213
        /**
214
         * The violation of the report, derived from what opened, closed or moved its line, and the line its fix opens
215
         * or closes marked for what follows.
216
         * @param ?array{int, int} $span  the offset and the length of what was reported
217
         * @param \WeakMap<Token, string> $opened
218
         * @param \WeakMap<Token, string> $moved
219
         */
220
        private static function createViolation(
221
                Report $report,
222
                string $fingerprint,
223
                int $line,
224
                ?int $column,
225
                ?array $span,
226
                Severity $severity,
227
                \WeakMap $opened,
228
                \WeakMap $moved,
229
        ): Violation
230
        {
231
                $derivedFrom = self::findAncestor($report, $opened, $moved);
1 ✔
232
                self::markLine($report, $derivedFrom ?? $fingerprint, $opened, $moved);
1 ✔
233
                return new Violation(
1 ✔
234
                        $report->decision,
1 ✔
235
                        $report->message,
1 ✔
236
                        $line,
237
                        $report->trivia === null ? $column : null,
1 ✔
238
                        $severity,
239
                        fingerprint: $fingerprint,
240
                        fixable: $report->fixable,
1 ✔
241
                        risk: $report->risk,
1 ✔
242
                        refused: $report->refused,
1 ✔
243
                        because: $report->because,
1 ✔
244
                        derivedFrom: $derivedFrom === $fingerprint ? null : $derivedFrom,
1 ✔
245
                        offset: $span[0] ?? null,
1 ✔
246
                        length: $span[1] ?? null,
1 ✔
247
                );
248
        }
249

250

251
        /**
252
         * The line of the token or of its trivia in the current text; the trivia is counted back from the token, whose
253
         * line the index follows.
254
         */
255
        private static function findCurrentLine(?Token $token, ?Trivia $trivia): int
256
        {
257
                $line = $token?->getCurrentLine() ?? 1;
1 ✔
258
                if ($token === null || $trivia === null) {
1 ✔
259
                        return $line;
1 ✔
260
                }
261

262
                $leading = array_search($trivia, $token->leadingTrivia, strict: true);
1 ✔
263
                if ($leading !== false) {
1 ✔
264
                        foreach (array_slice($token->leadingTrivia, $leading) as $item) {
1 ✔
265
                                $line -= substr_count($item->text, "\n");
1 ✔
266
                        }
267

268
                        return $line;
1 ✔
269
                }
270

271
                $line += substr_count($token->text, "\n");
1 ✔
272
                foreach ($token->trailingTrivia as $item) {
1 ✔
273
                        if ($item === $trivia) {
1 ✔
274
                                break;
1 ✔
275
                        }
276

277
                        $line += substr_count($item->text, "\n");
1 ✔
278
                }
279

280
                return $line;
1 ✔
281
        }
282

283

284
        /**
285
         * The passes report by rule, the reader reads by position.
286
         * @param list<Violation> $violations
287
         * @return list<Violation>
288
         */
289
        private static function sortByPosition(array $violations): array
290
        {
291
                usort($violations, fn(Violation $a, Violation $b) => [$a->line, $a->column ?? 0] <=> [$b->line, $b->column ?? 0]);
1 ✔
292
                return $violations;
1 ✔
293
        }
294

295

296
        /** @param list<NodeRule> $rules */
297
        private function runStage(string $stage, array $rules, Style $style): void
298
        {
299
                // the gap rules ride along the traversal of the formatting stage and report under their decisions;
300
                // a rule is accounted for when it reported, the revision before the traversal standing for the whole of it
301
                $gaps = $stage === Stage::Formatting->name && !$this->plan->claims->isEmpty() ? $this->gaps : null;
1 ✔
302
                if ($rules === [] && $gaps === null) {
1 ✔
303
                        return;
1 ✔
304
                }
305

306
                foreach ($rules as $rule) {
1 ✔
307
                        $this->invoke($rule, fn(RuleContext $context) => $rule->beforePass($context));
1 ✔
308
                }
309

310
                // every gap holds the closure, which therefore must not hold the loop: through it a gap would reach and keep in
311
                // cycles everything the pass made
312
                [$analyses, $file, $path, $strict] = [$this->analyses, $this->file, $this->path, $this->policy->strict];
1 ✔
313
                $gaps?->beginPass(
1 ✔
314
                        $style,
315
                        new Fixer($this->contexts, stripos($this->code, '__LINE__') !== false),
1 ✔
316
                        static function (string $class, Rule $rule) use ($analyses, $file, $path, $strict): ?object {
1 ✔
317
                                if ($strict) {
1 ✔
318
                                        try {
319
                                                RuleInfo::fromRule($rule)->checkAnalysis($class);
1 ✔
320
                                        } catch (\LogicException $e) {
1 ✔
321
                                                throw new RuleException($rule::class, $path, $e);
1 ✔
322
                                        }
323
                                }
324

325
                                return $analyses->find($file, $class, $path);
1 ✔
326
                        },
1 ✔
327
                );
328
                $before = $this->file->revision;
1 ✔
329
                $enter = function (Node|Token $node) use ($stage, $gaps): void {
1 ✔
330
                        if (($this->entering[$stage][$node::class] ?? null) !== []) {
1 ✔
331
                                $this->visit($stage, $node, enter: true);
1 ✔
332
                        }
333

334
                        // a node a rule has just taken out of the tree has no gaps to decide
335
                        if ($gaps === null || ($node->parent === null && !$node instanceof FileNode)) {
1 ✔
336
                                return;
1 ✔
337
                        }
338

339
                        try {
340
                                $node instanceof Token ? $gaps->enterToken($node) : $gaps->enterNode($node);
1 ✔
341
                        } catch (ConfigurationException|RuleException $e) { // two claims deciding one gap, or a claim of the rule named failing
1 ✔
342
                                throw $e;
1 ✔
343
                        } catch (\Throwable $e) {
×
344
                                throw new RuleException(null, $this->path, $e);
×
345
                        }
346
                };
1 ✔
347
                // the traverser leaves every node it entered, a replaced one included; a rule sees only what is still in the tree
348
                $leave = fn(Node|Token $node) => ($this->leaving[$stage][$node::class] ?? null) === []
1 ✔
349
                        || ($node->parent === null && $node !== $this->file)
1 ✔
350
                        ? null
1 ✔
351
                        : $this->visit($stage, $node, enter: false);
1 ✔
352
                Traverser::traverse($this->file, $enter, $this->plan->leaves[$stage] ? $leave : null);
1 ✔
353

354
                if ($gaps !== null) {
1 ✔
355
                        foreach ($this->contexts as $ruleClass => $context) {
1 ✔
356
                                if ($context->hasReports()) {
1 ✔
357
                                        // the whitespace of a gap is written by the engine, always after a report of its own, so a mutation
358
                                        // without a report or after a denied one belongs to another rule of the same traversal
359
                                        $this->account($ruleClass, $context, $before, checkRevisions: false);
1 ✔
360
                                }
361
                        }
362
                }
363

364
                foreach ($rules as $rule) {
1 ✔
365
                        $this->invoke($rule, fn(RuleContext $context) => $rule->afterPass($context));
1 ✔
366
                }
367
        }
1 ✔
368

369

370
        private function visit(string $stage, Node|Token $node, bool $enter): void
371
        {
372
                $class = $node::class;
1 ✔
373
                $rules = ($enter ? $this->entering : $this->leaving)[$stage][$class] ?? $this->resolveRules($stage, $node, $enter);
1 ✔
374
                if ($rules === []) {
1 ✔
375
                        return;
1 ✔
376
                }
377

378
                $parent = $node->parent;
1 ✔
379
                $file = $this->file;
1 ✔
380
                foreach ($rules as [$rule, $context, $ruleClass]) {
1 ✔
381
                        $before = $file->revision;
1 ✔
382
                        try {
383
                                $enter ? $rule->enter($node, $context) : $rule->leave($node, $context);
1 ✔
384
                        } catch (\Throwable $e) {
1 ✔
385
                                throw new RuleException($ruleClass, $this->path, $e);
1 ✔
386
                        }
387

388
                        if ($file->revision !== $before || $context->hasReports()) {
1 ✔
389
                                $this->account($ruleClass, $context, $before);
1 ✔
390
                        }
391

392
                        if ($node->parent !== $parent) { // replaced or removed: the rest of the chain never sees it
1 ✔
393
                                return;
1 ✔
394
                        }
395
                }
396
        }
1 ✔
397

398

399
        /**
400
         * The rules the plan dispatches the class of the node to, with their contexts in this file.
401
         * @return list<array{NodeRule, RuleContext, string}>
402
         */
403
        private function resolveRules(string $stage, Node|Token $node, bool $enter): array
404
        {
405
                $rules = [];
1 ✔
406
                foreach ($this->plan->getRulesVisiting($stage, $node::class, $enter) as $rule) {
1 ✔
407
                        $ruleClass = $rule::class;
1 ✔
408
                        $rules[] = [$rule, $this->contexts[$ruleClass], $ruleClass];
1 ✔
409
                }
410

411
                if ($enter) {
1 ✔
412
                        $this->entering[$stage][$node::class] = $rules;
1 ✔
413
                } else {
414
                        $this->leaving[$stage][$node::class] = $rules;
1 ✔
415
                }
416

417
                return $rules;
1 ✔
418
        }
419

420

421
        /**
422
         * Calls a per-file callback of a rule and accounts for what it did.
423
         * @param \Closure(RuleContext): void $callback
424
         */
425
        private function invoke(NodeRule $rule, \Closure $callback): void
426
        {
427
                $ruleClass = $rule::class;
1 ✔
428
                $context = $this->contexts[$ruleClass];
1 ✔
429
                $before = $this->file->revision;
1 ✔
430
                try {
431
                        $callback($context);
1 ✔
432
                } catch (\Throwable $e) {
1 ✔
433
                        throw new RuleException($ruleClass, $this->path, $e);
1 ✔
434
                }
435

436
                $this->account($ruleClass, $context, $before);
1 ✔
437
        }
1 ✔
438

439

440
        /**
441
         * Turns the reports of a callback into violations and checks that every mutation follows a report that returned
442
         * true; what the rule wrote after a denied report, up to its next one, it wrote for that report. After the last
443
         * report the rule may be fixing any of those it was allowed earlier, so that one is judged only where none was. A
444
         * report the baseline holds is not denied, only not recorded.
445
         * @param int $before  the revision of the file before the callback
446
         * @param bool $checkRevisions  whether the revisions are the rule's doing, which along the gap traversal they need
447
         *                              not be, because they then cover every rule
448
         */
449
        private function account(string $ruleClass, RuleContext $context, int $before, bool $checkRevisions = true): void
450
        {
451
                $after = $this->file->revision;
1 ✔
452
                $reported = $allowed = false;
1 ✔
453
                $reports = $context->takeReports();
1 ✔
454
                foreach ($reports as $i => $report) {
1 ✔
455
                        $fingerprint = $report->fingerprint;
1 ✔
456
                        $denied = !$report->fixable || $report->silenced || $report->refused;
1 ✔
457
                        $end = $reports[$i + 1]->revision ?? ($allowed ? null : $after);
1 ✔
458
                        if ($checkRevisions && $denied && $end !== null && $end > $report->revision) {
1 ✔
459
                                $this->violateContract($ruleClass, $report->fixable
1 ✔
460
                                        ? 'it changed the file although `report()` returned `false`'
1 ✔
461
                                        : 'it changed the file after a report with `fixable: false`');
1 ✔
462
                        }
463

464
                        $allowed = $allowed || !$denied;
1 ✔
465

466
                        if (!str_ends_with($report->message, '.')) {
1 ✔
467
                                $this->violateContract(
1 ✔
468
                                        $ruleClass,
469
                                        'it reported a message not ending with a period, ' . Violation::formatCode($report->message),
1 ✔
470
                                );
471
                        }
472

473
                        if ($report->silenced || $fingerprint === null) { // and then there is no violation to record
1 ✔
474
                                continue;
1 ✔
475
                        }
476

477
                        $reported = true;
1 ✔
478
                        $severity = isset($this->policy->warningDecisions[$report->decision]) ? Severity::Warning : Severity::Error;
1 ✔
479
                        $this->reported[] = [$ruleClass, $report, $severity];
1 ✔
480
                        $violation = self::createViolation(
1 ✔
481
                                $report,
1 ✔
482
                                $fingerprint,
1 ✔
483
                                $report->line,
1 ✔
484
                                $this->findOriginalColumn($report->at),
1 ✔
485
                                $report->span,
1 ✔
486
                                $severity,
1 ✔
487
                                $this->opened,
1 ✔
488
                                $this->moved,
1 ✔
489
                        );
490
                        if (!$report->baselined) { // what the baseline holds is not recorded, though the rule may have fixed it
1 ✔
491
                                $this->violations[$fingerprint] ??= $violation;
1 ✔
492
                        }
493
                }
494

495
                // the lines of code a fix wrote anew are placed by it, as the lines of a gap the fixer broke are
496
                foreach ($context->takeRewritten() as [$fingerprint, $node]) {
1 ✔
497
                        $origin = $this->violations[$fingerprint]->derivedFrom ?? $fingerprint;
1 ✔
498
                        foreach ($node->getTokens() as $i => $token) {
1 ✔
499
                                if ($i === 0 || $token->startsLine()) {
1 ✔
500
                                        $this->moved[$token] ??= $origin;
1 ✔
501
                                }
502
                        }
503
                }
504

505
                if ($after > $before) {
1 ✔
506
                        $this->mutatedRules[$ruleClass] = true;
1 ✔
507
                        if (!$reported && $checkRevisions) {
1 ✔
508
                                $this->violateContract($ruleClass, 'it changed the file without reporting a violation');
1 ✔
509
                        }
510
                }
511
        }
1 ✔
512

513

514
        /**
515
         * The violation the report follows from: the one the fixer opened or closed the line of the reported gap
516
         * for, else the one that opened, closed or moved the line the reported whitespace is counted from. A report
517
         * about the shape of the line it stands on asks who opened or closed that line, and nothing about whitespace.
518
         * @param \WeakMap<Token, string> $opened
519
         * @param \WeakMap<Token, string> $moved
520
         */
521
        private static function findAncestor(Report $report, \WeakMap $opened, \WeakMap $moved): ?string
522
        {
523
                if ($report->byLine) {
1 ✔
524
                        $token = $report->at->getFirstToken();
1 ✔
525
                        return $token === null ? null : $opened[$token] ?? null;
1 ✔
526
                }
527

528
                if ($report->gap !== null && isset($opened[$report->gap])) {
1 ✔
529
                        return $opened[$report->gap];
1 ✔
530
                }
531

532
                $follows = $report->follows;
1 ✔
533
                return $follows === null ? null : $opened[$follows] ?? $moved[$follows] ?? null;
1 ✔
534
        }
535

536

537
        /**
538
         * What is placed by the line of the reported gap follows from what opened, closed or moved it, the first of the chain.
539
         * @param \WeakMap<Token, string> $opened
540
         * @param \WeakMap<Token, string> $moved
541
         */
542
        private static function markLine(Report $report, string $origin, \WeakMap $opened, \WeakMap $moved): void
543
        {
544
                if ($report->gap === null || $report->refused) {
1 ✔
545
                        return;
1 ✔
546
                } elseif ($report->breaks) {
1 ✔
547
                        $opened[$report->gap] ??= $origin;
1 ✔
548
                } elseif ($report->follows !== null) {
1 ✔
549
                        $moved[$report->gap] ??= $origin;
1 ✔
550
                }
551
        }
1 ✔
552

553

554
        /**
555
         * A pass that changed nothing left every occurrence it reported as it was, so the rule had no fix for one it did not
556
         * report with `fixable: false`, or refused as risky.
557
         */
558
        private function checkUnfixed(): void
559
        {
560
                $rules = [];
1 ✔
561
                foreach ($this->reported as [$ruleClass, $report]) {
1 ✔
562
                        if ($report->fixable && !$report->refused && $report->gap === null) { // a gap is the engine's to fix, and a comment may keep it from that
1 ✔
563
                                $rules[$ruleClass] = true;
1 ✔
564
                        }
565
                }
566

567
                foreach (array_keys($rules) as $ruleClass) {
1 ✔
568
                        $this->violateContract($ruleClass, 'it reported an occurrence it then left unfixed without saying `fixable: false`');
1 ✔
569
                }
570
        }
1 ✔
571

572

573
        /** @param  string  $breach  what the rule did, as a clause */
574
        private function violateContract(string $ruleClass, string $breach): void
575
        {
576
                if ($this->policy->strict) {
1 ✔
577
                        throw new RuleException($ruleClass, $this->path, new \LogicException(ucfirst($breach) . '.'));
1 ✔
578
                }
579

580
                $this->warnings[] = "Rule `$ruleClass` is faulty: $breach.";
1 ✔
581
        }
1 ✔
582

583

584
        /**
585
         * Column in characters of the token in the original file, from its original offset and the start of its line.
586
         */
587
        private function findOriginalColumn(Node|Token $at): ?int
588
        {
589
                $token = $at->getFirstToken();
1 ✔
590
                if ($token === null || $token->pos < 0 || $token->line < 0) {
1 ✔
591
                        return null;
1 ✔
592
                }
593

594
                $lineStart = $this->lineOffsets[$token->line - 1] ?? 0;
1 ✔
595
                $before = substr($this->code, $lineStart, $token->pos - $lineStart);
1 ✔
596
                return strlen($before) - preg_match_all('~[\x80-\xBF]~', $before) + 1;
1 ✔
597
        }
598
}
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