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

keradus / PHP-CS-Fixer / 17007068993

16 Aug 2025 09:50AM UTC coverage: 94.74% (-0.01%) from 94.75%
17007068993

push

github

web-flow
Merge branch 'master' into chore_

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

3 existing lines in 1 file now uncovered.

28260 of 29829 relevant lines covered (94.74%)

45.88 hits per line

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

68.54
/src/Runner/Runner.php
1
<?php
2

3
declare(strict_types=1);
4

5
/*
6
 * This file is part of PHP CS Fixer.
7
 *
8
 * (c) Fabien Potencier <fabien@symfony.com>
9
 *     Dariusz Rumiński <dariusz.ruminski@gmail.com>
10
 *
11
 * This source file is subject to the MIT license that is bundled
12
 * with this source code in the file LICENSE.
13
 */
14

15
namespace PhpCsFixer\Runner;
16

17
use Clue\React\NDJson\Decoder;
18
use Clue\React\NDJson\Encoder;
19
use PhpCsFixer\AbstractFixer;
20
use PhpCsFixer\Cache\CacheManagerInterface;
21
use PhpCsFixer\Cache\Directory;
22
use PhpCsFixer\Cache\DirectoryInterface;
23
use PhpCsFixer\Console\Command\WorkerCommand;
24
use PhpCsFixer\Differ\DifferInterface;
25
use PhpCsFixer\Error\Error;
26
use PhpCsFixer\Error\ErrorsManager;
27
use PhpCsFixer\Error\SourceExceptionFactory;
28
use PhpCsFixer\FileReader;
29
use PhpCsFixer\Fixer\FixerInterface;
30
use PhpCsFixer\Linter\LinterInterface;
31
use PhpCsFixer\Linter\LintingException;
32
use PhpCsFixer\Linter\LintingResultInterface;
33
use PhpCsFixer\Preg;
34
use PhpCsFixer\Runner\Event\AnalysisStarted;
35
use PhpCsFixer\Runner\Event\FileProcessed;
36
use PhpCsFixer\Runner\Parallel\ParallelAction;
37
use PhpCsFixer\Runner\Parallel\ParallelConfig;
38
use PhpCsFixer\Runner\Parallel\ParallelConfigFactory;
39
use PhpCsFixer\Runner\Parallel\ParallelisationException;
40
use PhpCsFixer\Runner\Parallel\ProcessFactory;
41
use PhpCsFixer\Runner\Parallel\ProcessIdentifier;
42
use PhpCsFixer\Runner\Parallel\ProcessPool;
43
use PhpCsFixer\Runner\Parallel\WorkerException;
44
use PhpCsFixer\Tokenizer\Tokens;
45
use PhpCsFixer\Utils;
46
use React\EventLoop\StreamSelectLoop;
47
use React\Socket\ConnectionInterface;
48
use React\Socket\TcpServer;
49
use Symfony\Component\Console\Input\InputInterface;
50
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
51
use Symfony\Component\Filesystem\Exception\IOException;
52
use Symfony\Contracts\EventDispatcher\Event;
53

54
/**
55
 * @phpstan-type _RunResult array<string, array{appliedFixers: list<string>, diff: string}>
56
 *
57
 * @author Dariusz Rumiński <dariusz.ruminski@gmail.com>
58
 * @author Greg Korba <greg@codito.dev>
59
 */
60
final class Runner
61
{
62
    /**
63
     * Buffer size used in the NDJSON decoder for communication between main process and workers.
64
     *
65
     * @see https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/pull/8068
66
     */
67
    private const PARALLEL_BUFFER_SIZE = 16 * (1_024 * 1_024 /* 1MB */);
68

69
    private DifferInterface $differ;
70

71
    private DirectoryInterface $directory;
72

73
    private ?EventDispatcherInterface $eventDispatcher;
74

75
    private ErrorsManager $errorsManager;
76

77
    private CacheManagerInterface $cacheManager;
78

79
    private bool $isDryRun;
80

81
    private LinterInterface $linter;
82

83
    /**
84
     * @var null|\Traversable<array-key, \SplFileInfo>
85
     */
86
    private ?\Traversable $fileIterator = null;
87

88
    private int $fileCount;
89

90
    /**
91
     * @var list<FixerInterface>
92
     */
93
    private array $fixers;
94

95
    private bool $stopOnViolation;
96

97
    private ParallelConfig $parallelConfig;
98

99
    private ?InputInterface $input;
100

101
    private ?string $configFile;
102

103
    /**
104
     * @param null|\Traversable<array-key, \SplFileInfo> $fileIterator
105
     * @param list<FixerInterface>                       $fixers
106
     */
107
    public function __construct(
108
        ?\Traversable $fileIterator,
109
        array $fixers,
110
        DifferInterface $differ,
111
        ?EventDispatcherInterface $eventDispatcher,
112
        ErrorsManager $errorsManager,
113
        LinterInterface $linter,
114
        bool $isDryRun,
115
        CacheManagerInterface $cacheManager,
116
        ?DirectoryInterface $directory = null,
117
        bool $stopOnViolation = false,
118
        // @TODO Make these arguments required in 4.0
119
        ?ParallelConfig $parallelConfig = null,
120
        ?InputInterface $input = null,
121
        ?string $configFile = null
122
    ) {
123
        // Required only for main process (calculating workers count)
124
        $this->fileCount = null !== $fileIterator ? \count(iterator_to_array($fileIterator)) : 0;
7✔
125

126
        $this->fileIterator = $fileIterator;
7✔
127
        $this->fixers = $fixers;
7✔
128
        $this->differ = $differ;
7✔
129
        $this->eventDispatcher = $eventDispatcher;
7✔
130
        $this->errorsManager = $errorsManager;
7✔
131
        $this->linter = $linter;
7✔
132
        $this->isDryRun = $isDryRun;
7✔
133
        $this->cacheManager = $cacheManager;
7✔
134
        $this->directory = $directory ?? new Directory('');
7✔
135
        $this->stopOnViolation = $stopOnViolation;
7✔
136
        $this->parallelConfig = $parallelConfig ?? ParallelConfigFactory::sequential();
7✔
137
        $this->input = $input;
7✔
138
        $this->configFile = $configFile;
7✔
139
    }
140

141
    /**
142
     * @TODO consider to drop this method and make iterator parameter obligatory in constructor,
143
     * more in https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/pull/7777/files#r1590447581
144
     *
145
     * @param \Traversable<array-key, \SplFileInfo> $fileIterator
146
     */
147
    public function setFileIterator(iterable $fileIterator): void
148
    {
149
        $this->fileIterator = $fileIterator;
×
150

151
        // Required only for main process (calculating workers count)
152
        $this->fileCount = \count(iterator_to_array($fileIterator));
×
153
    }
154

155
    /**
156
     * @return _RunResult
157
     */
158
    public function fix(): array
159
    {
160
        if (0 === $this->fileCount) {
7✔
161
            return [];
×
162
        }
163

164
        // @TODO 4.0: Remove condition and its body, as no longer needed when param will be required in the constructor.
165
        // This is a fallback only in case someone calls `new Runner()` in a custom repo and does not provide v4-ready params in v3-codebase.
166
        if (null === $this->input) {
7✔
167
            return $this->fixSequential();
3✔
168
        }
169

170
        if (
171
            1 === $this->parallelConfig->getMaxProcesses()
4✔
172
            || $this->fileCount <= $this->parallelConfig->getFilesPerProcess()
4✔
173
        ) {
174
            return $this->fixSequential();
3✔
175
        }
176

177
        return $this->fixParallel();
1✔
178
    }
179

180
    /**
181
     * Heavily inspired by {@see https://github.com/phpstan/phpstan-src/blob/9ce425bca5337039fb52c0acf96a20a2b8ace490/src/Parallel/ParallelAnalyser.php}.
182
     *
183
     * @return _RunResult
184
     */
185
    private function fixParallel(): array
186
    {
187
        $this->dispatchEvent(AnalysisStarted::NAME, new AnalysisStarted(AnalysisStarted::MODE_PARALLEL, $this->isDryRun));
2✔
188

189
        $changed = [];
2✔
190
        $streamSelectLoop = new StreamSelectLoop();
2✔
191
        $server = new TcpServer('127.0.0.1:0', $streamSelectLoop);
2✔
192
        $serverPort = parse_url($server->getAddress() ?? '', \PHP_URL_PORT);
2✔
193

194
        if (!is_numeric($serverPort)) {
2✔
195
            throw new ParallelisationException(\sprintf(
×
196
                'Unable to parse server port from "%s"',
×
197
                $server->getAddress() ?? ''
×
198
            ));
×
199
        }
200

201
        $processPool = new ProcessPool($server);
2✔
202
        $maxFilesPerProcess = $this->parallelConfig->getFilesPerProcess();
2✔
203
        $fileIterator = $this->getFilteringFileIterator();
2✔
204
        $fileIterator->rewind();
2✔
205

206
        $getFileChunk = static function () use ($fileIterator, $maxFilesPerProcess): array {
2✔
207
            $files = [];
2✔
208

209
            while (\count($files) < $maxFilesPerProcess) {
2✔
210
                $current = $fileIterator->current();
2✔
211

212
                if (null === $current) {
2✔
213
                    break;
2✔
214
                }
215

216
                $files[] = $current->getPathname();
2✔
217

218
                $fileIterator->next();
2✔
219
            }
220

221
            return $files;
2✔
222
        };
2✔
223

224
        // [REACT] Handle worker's handshake (init connection)
225
        $server->on('connection', static function (ConnectionInterface $connection) use ($processPool, $getFileChunk): void {
2✔
226
            $decoder = new Decoder(
2✔
227
                $connection,
2✔
228
                true,
2✔
229
                512,
2✔
230
                \JSON_INVALID_UTF8_IGNORE,
2✔
231
                self::PARALLEL_BUFFER_SIZE
2✔
232
            );
2✔
233
            $encoder = new Encoder($connection, \JSON_INVALID_UTF8_IGNORE);
2✔
234

235
            // [REACT] Bind connection when worker's process requests "hello" action (enables 2-way communication)
236
            $decoder->on('data', static function (array $data) use ($processPool, $getFileChunk, $decoder, $encoder): void {
2✔
237
                if (ParallelAction::WORKER_HELLO !== $data['action']) {
2✔
238
                    return;
2✔
239
                }
240

241
                $identifier = ProcessIdentifier::fromRaw($data['identifier']);
2✔
242
                $process = $processPool->getProcess($identifier);
2✔
243
                $process->bindConnection($decoder, $encoder);
2✔
244
                $fileChunk = $getFileChunk();
2✔
245

246
                if (0 === \count($fileChunk)) {
2✔
UNCOV
247
                    $process->request(['action' => ParallelAction::RUNNER_THANK_YOU]);
×
UNCOV
248
                    $processPool->endProcessIfKnown($identifier);
×
249

UNCOV
250
                    return;
×
251
                }
252

253
                $process->request(['action' => ParallelAction::RUNNER_REQUEST_ANALYSIS, 'files' => $fileChunk]);
2✔
254
            });
2✔
255
        });
2✔
256

257
        $processesToSpawn = min(
2✔
258
            $this->parallelConfig->getMaxProcesses(),
2✔
259
            max(
2✔
260
                1,
2✔
261
                (int) ceil($this->fileCount / $this->parallelConfig->getFilesPerProcess()),
2✔
262
            )
2✔
263
        );
2✔
264
        $processFactory = new ProcessFactory($this->input);
2✔
265

266
        for ($i = 0; $i < $processesToSpawn; ++$i) {
2✔
267
            $identifier = ProcessIdentifier::create();
2✔
268
            $process = $processFactory->create(
2✔
269
                $streamSelectLoop,
2✔
270
                new RunnerConfig(
2✔
271
                    $this->isDryRun,
2✔
272
                    $this->stopOnViolation,
2✔
273
                    $this->parallelConfig,
2✔
274
                    $this->configFile
2✔
275
                ),
2✔
276
                $identifier,
2✔
277
                $serverPort,
2✔
278
            );
2✔
279
            $processPool->addProcess($identifier, $process);
2✔
280
            $process->start(
2✔
281
                // [REACT] Handle workers' responses (multiple actions possible)
282
                function (array $workerResponse) use ($processPool, $process, $identifier, $getFileChunk, &$changed): void {
2✔
283
                    // File analysis result (we want close-to-realtime progress with frequent cache savings)
284
                    if (ParallelAction::WORKER_RESULT === $workerResponse['action']) {
2✔
285
                        // Dispatch an event for each file processed and dispatch its status (required for progress output)
286
                        $this->dispatchEvent(FileProcessed::NAME, new FileProcessed($workerResponse['status']));
2✔
287

288
                        if (isset($workerResponse['fileHash'])) {
2✔
289
                            $this->cacheManager->setFileHash($workerResponse['file'], $workerResponse['fileHash']);
2✔
290
                        }
291

292
                        foreach ($workerResponse['errors'] ?? [] as $error) {
2✔
293
                            $this->errorsManager->report(new Error(
×
294
                                $error['type'],
×
295
                                $error['filePath'],
×
296
                                null !== $error['source']
×
297
                                    ? SourceExceptionFactory::fromArray($error['source'])
×
298
                                    : null,
×
299
                                $error['appliedFixers'],
×
300
                                $error['diff']
×
301
                            ));
×
302
                        }
303

304
                        // Pass-back information about applied changes (only if there are any)
305
                        if (isset($workerResponse['fixInfo'])) {
2✔
306
                            $relativePath = $this->directory->getRelativePathTo($workerResponse['file']);
2✔
307
                            $changed[$relativePath] = $workerResponse['fixInfo'];
2✔
308

309
                            if ($this->stopOnViolation) {
2✔
310
                                $processPool->endAll();
×
311

312
                                return;
×
313
                            }
314
                        }
315

316
                        return;
2✔
317
                    }
318

319
                    if (ParallelAction::WORKER_GET_FILE_CHUNK === $workerResponse['action']) {
2✔
320
                        // Request another chunk of files, if still available
321
                        $fileChunk = $getFileChunk();
2✔
322

323
                        if (0 === \count($fileChunk)) {
2✔
324
                            $process->request(['action' => ParallelAction::RUNNER_THANK_YOU]);
2✔
325
                            $processPool->endProcessIfKnown($identifier);
2✔
326

327
                            return;
2✔
328
                        }
329

330
                        $process->request(['action' => ParallelAction::RUNNER_REQUEST_ANALYSIS, 'files' => $fileChunk]);
1✔
331

332
                        return;
1✔
333
                    }
334

335
                    if (ParallelAction::WORKER_ERROR_REPORT === $workerResponse['action']) {
×
336
                        throw WorkerException::fromRaw($workerResponse); // @phpstan-ignore-line
×
337
                    }
338

339
                    throw new ParallelisationException('Unsupported action: '.($workerResponse['action'] ?? 'n/a'));
×
340
                },
2✔
341

342
                // [REACT] Handle errors encountered during worker's execution
343
                static function (\Throwable $error) use ($processPool): void {
2✔
344
                    $processPool->endAll();
×
345

346
                    throw new ParallelisationException($error->getMessage(), $error->getCode(), $error);
×
347
                },
2✔
348

349
                // [REACT] Handle worker's shutdown
350
                static function ($exitCode, string $output) use ($processPool, $identifier): void {
2✔
351
                    $processPool->endProcessIfKnown($identifier);
2✔
352

353
                    if (0 === $exitCode || null === $exitCode) {
2✔
354
                        return;
2✔
355
                    }
356

357
                    $errorsReported = Preg::matchAll(
×
358
                        \sprintf('/^(?:%s)([^\n]+)+/m', WorkerCommand::ERROR_PREFIX),
×
359
                        $output,
×
360
                        $matches
×
361
                    );
×
362

363
                    if ($errorsReported > 0) {
×
364
                        throw WorkerException::fromRaw(json_decode($matches[1][0], true));
×
365
                    }
366
                }
2✔
367
            );
2✔
368
        }
369

370
        $streamSelectLoop->run();
2✔
371

372
        return $changed;
2✔
373
    }
374

375
    /**
376
     * @return _RunResult
377
     */
378
    private function fixSequential(): array
379
    {
380
        $this->dispatchEvent(AnalysisStarted::NAME, new AnalysisStarted(AnalysisStarted::MODE_SEQUENTIAL, $this->isDryRun));
7✔
381

382
        $changed = [];
7✔
383
        $collection = $this->getLintingFileIterator();
7✔
384

385
        foreach ($collection as $file) {
7✔
386
            $fixInfo = $this->fixFile($file, $collection->currentLintingResult());
7✔
387

388
            // we do not need Tokens to still caching just fixed file - so clear the cache
389
            Tokens::clearCache();
7✔
390

391
            if (null !== $fixInfo) {
7✔
392
                $relativePath = $this->directory->getRelativePathTo($file->__toString());
5✔
393
                $changed[$relativePath] = $fixInfo;
5✔
394

395
                if ($this->stopOnViolation) {
5✔
396
                    break;
2✔
397
                }
398
            }
399
        }
400

401
        return $changed;
7✔
402
    }
403

404
    /**
405
     * @return null|array{appliedFixers: list<string>, diff: string}
406
     */
407
    private function fixFile(\SplFileInfo $file, LintingResultInterface $lintingResult): ?array
408
    {
409
        $filePathname = $file->getPathname();
6✔
410

411
        try {
412
            $lintingResult->check();
6✔
413
        } catch (LintingException $e) {
2✔
414
            $this->dispatchEvent(
2✔
415
                FileProcessed::NAME,
2✔
416
                new FileProcessed(FileProcessed::STATUS_INVALID)
2✔
417
            );
2✔
418

419
            $this->errorsManager->report(new Error(Error::TYPE_INVALID, $filePathname, $e));
2✔
420

421
            return null;
2✔
422
        }
423

424
        $old = FileReader::createSingleton()->read($file->getRealPath());
4✔
425

426
        $tokens = Tokens::fromCode($old);
4✔
427

428
        if (
429
            Utils::isFutureModeEnabled() // @TODO 4.0 drop this line
4✔
430
            && !filter_var(getenv('PHP_CS_FIXER_NON_MONOLITHIC'), \FILTER_VALIDATE_BOOL)
4✔
431
            && !$tokens->isMonolithicPhp()
4✔
432
        ) {
433
            $this->dispatchEvent(
×
434
                FileProcessed::NAME,
×
435
                new FileProcessed(FileProcessed::STATUS_NON_MONOLITHIC)
×
436
            );
×
437

438
            return null;
×
439
        }
440

441
        $oldHash = $tokens->getCodeHash();
4✔
442

443
        $new = $old;
4✔
444
        $newHash = $oldHash;
4✔
445

446
        $appliedFixers = [];
4✔
447

448
        try {
449
            foreach ($this->fixers as $fixer) {
4✔
450
                // for custom fixers we don't know is it safe to run `->fix()` without checking `->supports()` and `->isCandidate()`,
451
                // thus we need to check it and conditionally skip fixing
452
                if (
453
                    !$fixer instanceof AbstractFixer
4✔
454
                    && (!$fixer->supports($file) || !$fixer->isCandidate($tokens))
4✔
455
                ) {
456
                    continue;
×
457
                }
458

459
                $fixer->fix($file, $tokens);
4✔
460

461
                if ($tokens->isChanged()) {
4✔
462
                    $tokens->clearEmptyTokens();
4✔
463
                    $tokens->clearChanged();
4✔
464
                    $appliedFixers[] = $fixer->getName();
4✔
465
                }
466
            }
467
        } catch (\ParseError $e) {
×
468
            $this->dispatchEvent(FileProcessed::NAME, new FileProcessed(FileProcessed::STATUS_LINT));
×
469

470
            $this->errorsManager->report(new Error(Error::TYPE_LINT, $filePathname, $e));
×
471

472
            return null;
×
473
        } catch (\Throwable $e) {
×
474
            $this->processException($filePathname, $e);
×
475

476
            return null;
×
477
        }
478

479
        $fixInfo = null;
4✔
480

481
        if ([] !== $appliedFixers) {
4✔
482
            $new = $tokens->generateCode();
4✔
483
            $newHash = $tokens->getCodeHash();
4✔
484
        }
485

486
        // We need to check if content was changed and then applied changes.
487
        // But we can't simply check $appliedFixers, because one fixer may revert
488
        // work of other and both of them will mark collection as changed.
489
        // Therefore we need to check if code hashes changed.
490
        if ($oldHash !== $newHash) {
4✔
491
            $fixInfo = [
4✔
492
                'appliedFixers' => $appliedFixers,
4✔
493
                'diff' => $this->differ->diff($old, $new, $file),
4✔
494
            ];
4✔
495

496
            try {
497
                $this->linter->lintSource($new)->check();
4✔
498
            } catch (LintingException $e) {
×
499
                $this->dispatchEvent(FileProcessed::NAME, new FileProcessed(FileProcessed::STATUS_LINT));
×
500

501
                $this->errorsManager->report(new Error(Error::TYPE_LINT, $filePathname, $e, $fixInfo['appliedFixers'], $fixInfo['diff']));
×
502

503
                return null;
×
504
            }
505

506
            if (!$this->isDryRun) {
4✔
507
                $fileRealPath = $file->getRealPath();
×
508

509
                if (!file_exists($fileRealPath)) {
×
510
                    throw new IOException(
×
511
                        \sprintf('Failed to write file "%s" (no longer) exists.', $file->getPathname()),
×
512
                        0,
×
513
                        null,
×
514
                        $file->getPathname()
×
515
                    );
×
516
                }
517

518
                if (is_dir($fileRealPath)) {
×
519
                    throw new IOException(
×
520
                        \sprintf('Cannot write file "%s" as the location exists as directory.', $fileRealPath),
×
521
                        0,
×
522
                        null,
×
523
                        $fileRealPath
×
524
                    );
×
525
                }
526

527
                if (!is_writable($fileRealPath)) {
×
528
                    throw new IOException(
×
529
                        \sprintf('Cannot write to file "%s" as it is not writable.', $fileRealPath),
×
530
                        0,
×
531
                        null,
×
532
                        $fileRealPath
×
533
                    );
×
534
                }
535

536
                if (false === @file_put_contents($fileRealPath, $new)) {
×
537
                    $error = error_get_last();
×
538

539
                    throw new IOException(
×
540
                        \sprintf('Failed to write file "%s", "%s".', $fileRealPath, null !== $error ? $error['message'] : 'no reason available'),
×
541
                        0,
×
542
                        null,
×
543
                        $fileRealPath
×
544
                    );
×
545
                }
546
            }
547
        }
548

549
        $this->cacheManager->setFileHash($filePathname, $newHash);
4✔
550

551
        $this->dispatchEvent(
4✔
552
            FileProcessed::NAME,
4✔
553
            new FileProcessed(null !== $fixInfo ? FileProcessed::STATUS_FIXED : FileProcessed::STATUS_NO_CHANGES, $newHash)
4✔
554
        );
4✔
555

556
        return $fixInfo;
4✔
557
    }
558

559
    /**
560
     * Process an exception that occurred.
561
     */
562
    private function processException(string $name, \Throwable $e): void
563
    {
564
        $this->dispatchEvent(FileProcessed::NAME, new FileProcessed(FileProcessed::STATUS_EXCEPTION));
×
565

566
        $this->errorsManager->report(new Error(Error::TYPE_EXCEPTION, $name, $e));
×
567
    }
568

569
    private function dispatchEvent(string $name, Event $event): void
570
    {
571
        if (null === $this->eventDispatcher) {
7✔
572
            return;
4✔
573
        }
574

575
        $this->eventDispatcher->dispatch($event, $name);
3✔
576
    }
577

578
    private function getLintingFileIterator(): LintingResultAwareFileIteratorInterface
579
    {
580
        $fileFilterIterator = $this->getFilteringFileIterator();
6✔
581

582
        return $this->linter->isAsync()
6✔
583
            ? new FileCachingLintingFileIterator($fileFilterIterator, $this->linter)
×
584
            : new LintingFileIterator($fileFilterIterator, $this->linter);
6✔
585
    }
586

587
    private function getFilteringFileIterator(): FileFilterIterator
588
    {
589
        if (null === $this->fileIterator) {
7✔
590
            throw new \RuntimeException('File iterator is not configured. Pass paths during Runner initialisation or set them after with `setFileIterator()`.');
×
591
        }
592

593
        return new FileFilterIterator(
7✔
594
            $this->fileIterator instanceof \IteratorAggregate
7✔
595
                ? $this->fileIterator->getIterator()
7✔
596
                : $this->fileIterator,
7✔
597
            $this->eventDispatcher,
7✔
598
            $this->cacheManager
7✔
599
        );
7✔
600
    }
601
}
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