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

PHPCSStandards / PHPCSUtils / 19429059395

17 Nov 2025 12:08PM UTC coverage: 99.564%. First build
19429059395

Pull #733

github

web-flow
Merge 5c88db649 into fa82d14ad
Pull Request #733: PHP 8.5 | AbstractArrayDeclarationSniff::getActualArrayKey(): prevent notices about deprecated casts

7 of 16 new or added lines in 1 file covered. (43.75%)

3879 of 3896 relevant lines covered (99.56%)

288.41 hits per line

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

92.31
/PHPCSUtils/AbstractSniffs/AbstractArrayDeclarationSniff.php
1
<?php
2
/**
3
 * PHPCSUtils, utility functions and classes for PHP_CodeSniffer sniff developers.
4
 *
5
 * @package   PHPCSUtils
6
 * @copyright 2019-2020 PHPCSUtils Contributors
7
 * @license   https://opensource.org/licenses/LGPL-3.0 LGPL3
8
 * @link      https://github.com/PHPCSStandards/PHPCSUtils
9
 */
10

11
namespace PHPCSUtils\AbstractSniffs;
12

13
use PHP_CodeSniffer\Files\File;
14
use PHP_CodeSniffer\Sniffs\Sniff;
15
use PHP_CodeSniffer\Util\Tokens;
16
use PHPCSUtils\Exceptions\LogicException;
17
use PHPCSUtils\Exceptions\UnexpectedTokenType;
18
use PHPCSUtils\Tokens\Collections;
19
use PHPCSUtils\Utils\Arrays;
20
use PHPCSUtils\Utils\Numbers;
21
use PHPCSUtils\Utils\PassedParameters;
22
use PHPCSUtils\Utils\TextStrings;
23

24
/**
25
 * Abstract sniff to easily examine all parts of an array declaration.
26
 *
27
 * @since 1.0.0
28
 */
29
abstract class AbstractArrayDeclarationSniff implements Sniff
30
{
31

32
    /**
33
     * The stack pointer to the array keyword or the short array open token.
34
     *
35
     * @since 1.0.0
36
     *
37
     * @var int
38
     */
39
    protected $stackPtr;
40

41
    /**
42
     * The token stack for the current file being examined.
43
     *
44
     * @since 1.0.0
45
     *
46
     * @var array<int, array<string, mixed>>
47
     */
48
    protected $tokens;
49

50
    /**
51
     * The stack pointer to the array opener.
52
     *
53
     * @since 1.0.0
54
     *
55
     * @var int
56
     */
57
    protected $arrayOpener;
58

59
    /**
60
     * The stack pointer to the array closer.
61
     *
62
     * @since 1.0.0
63
     *
64
     * @var int
65
     */
66
    protected $arrayCloser;
67

68
    /**
69
     * A multi-dimentional array with information on each array item.
70
     *
71
     * The array index is 1-based and contains the following information on each array item:
72
     * ```php
73
     * 1 => array(
74
     *   'start' => int,    // The stack pointer to the first token in the array item.
75
     *   'end'   => int,    // The stack pointer to the last token in the array item.
76
     *   'raw'   => string, // A string with the contents of all tokens between `start` and `end`.
77
     *   'clean' => string, // Same as `raw`, but all comment tokens have been stripped out.
78
     * )
79
     * ```
80
     *
81
     * @since 1.0.0
82
     *
83
     * @var array<int, array<string, int|string>>
84
     */
85
    protected $arrayItems;
86

87
    /**
88
     * How many items are in the array.
89
     *
90
     * @since 1.0.0
91
     *
92
     * @var int
93
     */
94
    protected $itemCount = 0;
95

96
    /**
97
     * Whether or not the array is single line.
98
     *
99
     * @since 1.0.0
100
     *
101
     * @var bool
102
     */
103
    protected $singleLine;
104

105
    /**
106
     * List of tokens which can safely be used with an eval() expression.
107
     *
108
     * This list gets enhanced with additional token groups in the constructor.
109
     *
110
     * @since 1.0.0
111
     *
112
     * @var array<int|string, int|string>
113
     */
114
    private $acceptedTokens = [
115
        \T_NULL                     => \T_NULL,
116
        \T_TRUE                     => \T_TRUE,
117
        \T_FALSE                    => \T_FALSE,
118
        \T_LNUMBER                  => \T_LNUMBER,
119
        \T_DNUMBER                  => \T_DNUMBER,
120
        \T_CONSTANT_ENCAPSED_STRING => \T_CONSTANT_ENCAPSED_STRING,
121
        \T_STRING_CONCAT            => \T_STRING_CONCAT,
122
        \T_BOOLEAN_NOT              => \T_BOOLEAN_NOT,
123
    ];
124

125
    /**
126
     * Set up this class.
127
     *
128
     * @since 1.0.0
129
     *
130
     * @codeCoverageIgnore
131
     *
132
     * @return void
133
     */
134
    final public function __construct()
135
    {
136
        // Enhance the list of accepted tokens.
137
        $this->acceptedTokens += Tokens::$assignmentTokens;
138
        $this->acceptedTokens += Tokens::$comparisonTokens;
139
        $this->acceptedTokens += Tokens::$arithmeticTokens;
140
        $this->acceptedTokens += Tokens::$operators;
141
        $this->acceptedTokens += Tokens::$booleanOperators;
142
        $this->acceptedTokens += Tokens::$castTokens;
143
        $this->acceptedTokens += Tokens::$bracketTokens;
144
        $this->acceptedTokens += Tokens::$heredocTokens;
145
        $this->acceptedTokens += Collections::ternaryOperators();
146
    }
147

148
    /**
149
     * Returns an array of tokens this test wants to listen for.
150
     *
151
     * @since 1.0.0
152
     *
153
     * @codeCoverageIgnore
154
     *
155
     * @return array<int|string>
156
     */
157
    public function register()
158
    {
159
        return Collections::arrayOpenTokensBC();
160
    }
161

162
    /**
163
     * Processes this test when one of its tokens is encountered.
164
     *
165
     * This method fills the properties with relevant information for examining the array
166
     * and then passes off to the {@see AbstractArrayDeclarationSniff::processArray()} method.
167
     *
168
     * @since 1.0.0
169
     *
170
     * @param \PHP_CodeSniffer\Files\File $phpcsFile The PHP_CodeSniffer file where the
171
     *                                               token was found.
172
     * @param int                         $stackPtr  The position in the PHP_CodeSniffer
173
     *                                               file's token stack where the token
174
     *                                               was found.
175
     *
176
     * @return void
177
     */
178
    final public function process(File $phpcsFile, $stackPtr)
120✔
179
    {
180
        try {
181
            $this->arrayItems = PassedParameters::getParameters($phpcsFile, $stackPtr);
120✔
182
        } catch (UnexpectedTokenType $e) {
48✔
183
            // Parse error, short list, real square open bracket or incorrectly tokenized short array token.
184
            return;
8✔
185
        }
186

187
        $openClose = Arrays::getOpenClose($phpcsFile, $stackPtr, true);
96✔
188
        if ($openClose === false) {
96✔
189
            // Parse error or live coding.
190
            return;
8✔
191
        }
192

193
        $this->stackPtr    = $stackPtr;
88✔
194
        $this->tokens      = $phpcsFile->getTokens();
88✔
195
        $this->arrayOpener = $openClose['opener'];
88✔
196
        $this->arrayCloser = $openClose['closer'];
88✔
197
        $this->itemCount   = \count($this->arrayItems);
88✔
198

199
        $this->singleLine = true;
88✔
200
        if ($this->tokens[$openClose['opener']]['line'] !== $this->tokens[$openClose['closer']]['line']) {
88✔
201
            $this->singleLine = false;
16✔
202
        }
4✔
203

204
        $this->processArray($phpcsFile);
88✔
205

206
        // Reset select properties between calls to this sniff to lower memory usage.
207
        $this->tokens     = [];
88✔
208
        $this->arrayItems = [];
88✔
209
    }
44✔
210

211
    /**
212
     * Process every part of the array declaration.
213
     *
214
     * Controller which calls the individual `process...()` methods for each part of the array.
215
     *
216
     * The method starts by calling the {@see AbstractArrayDeclarationSniff::processOpenClose()} method
217
     * and subsequently calls the following methods for each array item:
218
     *
219
     * Unkeyed arrays | Keyed arrays
220
     * -------------- | ------------
221
     * processNoKey() | processKey()
222
     * -              | processArrow()
223
     * processValue() | processValue()
224
     * processComma() | processComma()
225
     *
226
     * This is the default logic for the sniff, but can be overloaded in a concrete child class
227
     * if needed.
228
     *
229
     * @since 1.0.0
230
     *
231
     * @param \PHP_CodeSniffer\Files\File $phpcsFile The PHP_CodeSniffer file where the
232
     *                                               token was found.
233
     *
234
     * @return void
235
     */
236
    public function processArray(File $phpcsFile)
88✔
237
    {
238
        if ($this->processOpenClose($phpcsFile, $this->arrayOpener, $this->arrayCloser) === true) {
88✔
239
            return;
8✔
240
        }
241

242
        if ($this->itemCount === 0) {
80✔
243
            return;
8✔
244
        }
245

246
        foreach ($this->arrayItems as $itemNr => $arrayItem) {
72✔
247
            try {
248
                $arrowPtr = Arrays::getDoubleArrowPtr($phpcsFile, $arrayItem['start'], $arrayItem['end']);
72✔
249
            } catch (LogicException $e) {
24✔
250
                // Parse error: empty array item. Ignore.
251
                continue;
8✔
252
            }
253

254
            if ($arrowPtr !== false) {
72✔
255
                if ($this->processKey($phpcsFile, $arrayItem['start'], ($arrowPtr - 1), $itemNr) === true) {
40✔
256
                    return;
8✔
257
                }
258

259
                if ($this->processArrow($phpcsFile, $arrowPtr, $itemNr) === true) {
32✔
260
                    return;
8✔
261
                }
262

263
                if ($this->processValue($phpcsFile, ($arrowPtr + 1), $arrayItem['end'], $itemNr) === true) {
24✔
264
                    return;
12✔
265
                }
266
            } else {
6✔
267
                if ($this->processNoKey($phpcsFile, $arrayItem['start'], $itemNr) === true) {
64✔
268
                    return;
8✔
269
                }
270

271
                if ($this->processValue($phpcsFile, $arrayItem['start'], $arrayItem['end'], $itemNr) === true) {
56✔
272
                    return;
8✔
273
                }
274
            }
275

276
            $commaPtr = ($arrayItem['end'] + 1);
56✔
277
            if ($itemNr < $this->itemCount || $this->tokens[$commaPtr]['code'] === \T_COMMA) {
56✔
278
                if ($this->processComma($phpcsFile, $commaPtr, $itemNr) === true) {
56✔
279
                    return;
8✔
280
                }
281
            }
12✔
282
        }
12✔
283
    }
12✔
284

285
    /**
286
     * Process the array opener and closer.
287
     *
288
     * Optional method to be implemented in concrete child classes. By default, this method does nothing.
289
     *
290
     * @since 1.0.0
291
     *
292
     * @codeCoverageIgnore
293
     *
294
     * @param \PHP_CodeSniffer\Files\File $phpcsFile The PHP_CodeSniffer file where the
295
     *                                               token was found.
296
     * @param int                         $openPtr   The position of the array opener token in the token stack.
297
     * @param int                         $closePtr  The position of the array closer token in the token stack.
298
     *
299
     * @return true|void Returning `TRUE` will short-circuit the sniff and stop processing.
300
     *                   In effect, this means that the sniff will not examine the individual
301
     *                   array items if `TRUE` is returned.
302
     */
303
    public function processOpenClose(File $phpcsFile, $openPtr, $closePtr)
304
    {
305
    }
306

307
    /**
308
     * Process the tokens in an array key.
309
     *
310
     * Optional method to be implemented in concrete child classes. By default, this method does nothing.
311
     *
312
     * Note: The `$startPtr` and `$endPtr` do not discount whitespace or comments, but are all inclusive
313
     * to allow for examining all tokens in an array key.
314
     *
315
     * @since 1.0.0
316
     *
317
     * @codeCoverageIgnore
318
     *
319
     * @see \PHPCSUtils\AbstractSniffs\AbstractArrayDeclarationSniff::getActualArrayKey() Optional helper function.
320
     *
321
     * @param \PHP_CodeSniffer\Files\File $phpcsFile The PHP_CodeSniffer file where the
322
     *                                               token was found.
323
     * @param int                         $startPtr  The stack pointer to the first token in the "key" part of
324
     *                                               an array item.
325
     * @param int                         $endPtr    The stack pointer to the last token in the "key" part of
326
     *                                               an array item.
327
     * @param int                         $itemNr    Which item in the array is being handled.
328
     *                                               1-based, i.e. the first item is item 1, the second 2 etc.
329
     *
330
     * @return true|void Returning `TRUE` will short-circuit the array item loop and stop processing.
331
     *                   In effect, this means that the sniff will not examine the double arrow, the array
332
     *                   value or comma for this array item and will not process any array items after this one.
333
     */
334
    public function processKey(File $phpcsFile, $startPtr, $endPtr, $itemNr)
335
    {
336
    }
337

338
    /**
339
     * Process an array item without an array key.
340
     *
341
     * Optional method to be implemented in concrete child classes. By default, this method does nothing.
342
     *
343
     * Note: This method is _not_ intended for processing the array _value_. Use the
344
     * {@see AbstractArrayDeclarationSniff::processValue()} method to implement processing of the array value.
345
     *
346
     * @since 1.0.0
347
     *
348
     * @codeCoverageIgnore
349
     *
350
     * @see \PHPCSUtils\AbstractSniffs\AbstractArrayDeclarationSniff::processValue() Method to process the array value.
351
     *
352
     * @param \PHP_CodeSniffer\Files\File $phpcsFile The PHP_CodeSniffer file where the
353
     *                                               token was found.
354
     * @param int                         $startPtr  The stack pointer to the first token in the array item,
355
     *                                               which in this case will be the first token of the array
356
     *                                               value part of the array item.
357
     * @param int                         $itemNr    Which item in the array is being handled.
358
     *                                               1-based, i.e. the first item is item 1, the second 2 etc.
359
     *
360
     * @return true|void Returning `TRUE` will short-circuit the array item loop and stop processing.
361
     *                   In effect, this means that the sniff will not examine the array value or
362
     *                   comma for this array item and will not process any array items after this one.
363
     */
364
    public function processNoKey(File $phpcsFile, $startPtr, $itemNr)
365
    {
366
    }
367

368
    /**
369
     * Process the double arrow.
370
     *
371
     * Optional method to be implemented in concrete child classes. By default, this method does nothing.
372
     *
373
     * @since 1.0.0
374
     *
375
     * @codeCoverageIgnore
376
     *
377
     * @param \PHP_CodeSniffer\Files\File $phpcsFile The PHP_CodeSniffer file where the
378
     *                                               token was found.
379
     * @param int                         $arrowPtr  The stack pointer to the double arrow for the array item.
380
     * @param int                         $itemNr    Which item in the array is being handled.
381
     *                                               1-based, i.e. the first item is item 1, the second 2 etc.
382
     *
383
     * @return true|void Returning `TRUE` will short-circuit the array item loop and stop processing.
384
     *                   In effect, this means that the sniff will not examine the array value or
385
     *                   comma for this array item and will not process any array items after this one.
386
     */
387
    public function processArrow(File $phpcsFile, $arrowPtr, $itemNr)
388
    {
389
    }
390

391
    /**
392
     * Process the tokens in an array value.
393
     *
394
     * Optional method to be implemented in concrete child classes. By default, this method does nothing.
395
     *
396
     * Note: The `$startPtr` and `$endPtr` do not discount whitespace or comments, but are all inclusive
397
     * to allow for examining all tokens in an array value.
398
     *
399
     * @since 1.0.0
400
     *
401
     * @codeCoverageIgnore
402
     *
403
     * @param \PHP_CodeSniffer\Files\File $phpcsFile The PHP_CodeSniffer file where the
404
     *                                               token was found.
405
     * @param int                         $startPtr  The stack pointer to the first token in the "value" part of
406
     *                                               an array item.
407
     * @param int                         $endPtr    The stack pointer to the last token in the "value" part of
408
     *                                               an array item.
409
     * @param int                         $itemNr    Which item in the array is being handled.
410
     *                                               1-based, i.e. the first item is item 1, the second 2 etc.
411
     *
412
     * @return true|void Returning `TRUE` will short-circuit the array item loop and stop processing.
413
     *                   In effect, this means that the sniff will not examine the comma for this
414
     *                   array item and will not process any array items after this one.
415
     */
416
    public function processValue(File $phpcsFile, $startPtr, $endPtr, $itemNr)
417
    {
418
    }
419

420
    /**
421
     * Process the comma after an array item.
422
     *
423
     * Optional method to be implemented in concrete child classes. By default, this method does nothing.
424
     *
425
     * @since 1.0.0
426
     *
427
     * @codeCoverageIgnore
428
     *
429
     * @param \PHP_CodeSniffer\Files\File $phpcsFile The PHP_CodeSniffer file where the
430
     *                                               token was found.
431
     * @param int                         $commaPtr  The stack pointer to the comma.
432
     * @param int                         $itemNr    Which item in the array is being handled.
433
     *                                               1-based, i.e. the first item is item 1, the second 2 etc.
434
     *
435
     * @return true|void Returning `TRUE` will short-circuit the array item loop and stop processing.
436
     *                   In effect, this means that the sniff will not process any array items
437
     *                   after this one.
438
     */
439
    public function processComma(File $phpcsFile, $commaPtr, $itemNr)
440
    {
441
    }
442

443
    /**
444
     * Determine what the actual array key would be.
445
     *
446
     * Helper function for processsing array keys in the processKey() function.
447
     * Using this method is up to the sniff implementation in the child class.
448
     *
449
     * @since 1.0.0
450
     *
451
     * @param \PHP_CodeSniffer\Files\File $phpcsFile The PHP_CodeSniffer file where the
452
     *                                               token was found.
453
     * @param int                         $startPtr  The stack pointer to the first token in the "key" part of
454
     *                                               an array item.
455
     * @param int                         $endPtr    The stack pointer to the last token in the "key" part of
456
     *                                               an array item.
457
     *
458
     * @return string|int|void The string or integer array key or void if the array key could not
459
     *                         reliably be determined.
460
     */
461
    public function getActualArrayKey(File $phpcsFile, $startPtr, $endPtr)
72✔
462
    {
463
        /*
464
         * Determine the value of the key.
465
         */
466
        $firstNonEmpty = $phpcsFile->findNext(Tokens::$emptyTokens, $startPtr, null, true);
72✔
467
        $lastNonEmpty  = $phpcsFile->findPrevious(Tokens::$emptyTokens, $endPtr, null, true);
72✔
468

469
        $content = '';
72✔
470

471
        for ($i = $firstNonEmpty; $i <= $lastNonEmpty; $i++) {
72✔
472
            if (isset(Tokens::$commentTokens[$this->tokens[$i]['code']]) === true) {
72✔
473
                continue;
8✔
474
            }
475

476
            if ($this->tokens[$i]['code'] === \T_WHITESPACE) {
72✔
477
                $content .= ' ';
40✔
478
                continue;
40✔
479
            }
480

481
            // Handle FQN true/false/null for PHPCS 3.x.
482
            if ($this->tokens[$i]['code'] === \T_NS_SEPARATOR) {
72✔
483
                $nextNonEmpty   = $phpcsFile->findNext(Tokens::$emptyTokens, ($i + 1), null, true);
16✔
484
                $nextNonEmptyLC = \strtolower($this->tokens[$nextNonEmpty]['content']);
16✔
485
                if ($nextNonEmpty !== false
8✔
486
                    && ($this->tokens[$nextNonEmpty]['code'] === \T_TRUE
16✔
487
                    || $this->tokens[$nextNonEmpty]['code'] === \T_FALSE
16✔
488
                    || $this->tokens[$nextNonEmpty]['code'] === \T_NULL)
14✔
489
                ) {
8✔
490
                    $content .= $this->tokens[$nextNonEmpty]['content'];
12✔
491
                    $i        = $nextNonEmpty;
12✔
492
                    continue;
12✔
493
                }
494
            }
2✔
495

496
            if (isset($this->acceptedTokens[$this->tokens[$i]['code']]) === false
72✔
497
                || \T_UNSET_CAST === $this->tokens[$i]['code']
72✔
498
            ) {
18✔
499
                // This is not a key we can evaluate. Might be a variable or constant.
500
                return;
8✔
501
            }
502

503
            // Take PHP 7.4 numeric literal separators into account.
504
            if ($this->tokens[$i]['code'] === \T_LNUMBER || $this->tokens[$i]['code'] === \T_DNUMBER) {
72✔
505
                $number   = Numbers::getCompleteNumber($phpcsFile, $i);
24✔
506
                $content .= $number['content'];
24✔
507
                $i        = $number['last_token'];
24✔
508
                continue;
24✔
509
            }
510

511
            /*
512
             * Make sure that when new/deprecated/removed casts are used in the code under scan and the sniff is run
513
             * on a PHP version which doesn't support the cast, the eval() won't cause a deprecation notice,
514
             * borking the scan of the file.
515
             *
516
             * - (unset) was deprecated in PHP 7.2 and removed in PHP 8.0;
517
             * - (real) was deprecated in PHP 7.4 and removed in PHP 8.0;
518
             * - (boolean) was deprecated in PHP 8.5 and will be removed in PHP 9.0;
519
             * - (integer) was deprecated in PHP 8.5 and will be removed in PHP 9.0;
520
             * - (double) was deprecated in PHP 8.5 and will be removed in PHP 9.0;
521
             * - (string) was deprecated in PHP 8.5 and will be removed in PHP 9.0;
522
             */
523
            if (\T_DOUBLE_CAST === $this->tokens[$i]['code']) {
72✔
524
                $content .= '(float)';
16✔
525
                continue;
16✔
526
            }
527

528
            if (\PHP_VERSION_ID >= 80500) {
72✔
NEW
529
                if (\T_INT_CAST === $this->tokens[$i]['code']) {
×
NEW
530
                    $content .= '(int)';
×
NEW
531
                    continue;
×
532
                }
533

NEW
534
                if (\T_BOOL_CAST === $this->tokens[$i]['code']) {
×
NEW
535
                    $content .= '(bool)';
×
NEW
536
                    continue;
×
537
                }
538

NEW
539
                if (\T_BINARY_CAST === $this->tokens[$i]['code']) {
×
NEW
540
                    $content .= '(string)';
×
NEW
541
                    continue;
×
542
                }
543
            }
544

545
            // Account for heredoc with vars.
546
            if ($this->tokens[$i]['code'] === \T_START_HEREDOC) {
72✔
547
                $text = TextStrings::getCompleteTextString($phpcsFile, $i);
24✔
548

549
                // Check if there's a variable in the heredoc.
550
                if ($text !== TextStrings::stripEmbeds($text)) {
24✔
551
                    return;
8✔
552
                }
553

554
                for ($j = $i; $j <= $this->tokens[$i]['scope_closer']; $j++) {
16✔
555
                    $content .= $this->tokens[$j]['content'];
16✔
556
                }
4✔
557

558
                $i = $this->tokens[$i]['scope_closer'];
16✔
559
                continue;
16✔
560
            }
561

562
            $content .= $this->tokens[$i]['content'];
56✔
563
        }
14✔
564

565
        // The PHP_EOL is to prevent getting parse errors when the key is a heredoc/nowdoc.
566
        $key = eval('return ' . $content . ';' . \PHP_EOL);
64✔
567

568
        /*
569
         * Ok, so now we know the base value of the key, let's determine whether it is
570
         * an acceptable index key for an array and if not, what it would turn into.
571
         */
572

573
        switch (\gettype($key)) {
64✔
574
            case 'NULL':
64✔
575
                // An array key of `null` will become an empty string.
576
                return '';
8✔
577

578
            case 'boolean':
64✔
579
                return ($key === true) ? 1 : 0;
16✔
580

581
            case 'integer':
64✔
582
                return $key;
24✔
583

584
            case 'double':
64✔
585
                return (int) $key; // Will automatically cut off the decimal part.
24✔
586

587
            case 'string':
64✔
588
                if (Numbers::isDecimalInt($key) === true) {
64✔
589
                    return (int) $key;
24✔
590
                }
591

592
                return $key;
40✔
593

594
            default:
595
                /*
596
                 * Shouldn't be possible. Either way, if it's not one of the above types,
597
                 * this is not a key we can handle.
598
                 */
599
                return; // @codeCoverageIgnore
600
        }
601
    }
602
}
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