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

voku / Simple-PHP-Code-Parser / 24218032206

09 Apr 2026 11:17PM UTC coverage: 83.27% (+0.009%) from 83.261%
24218032206

push

github

web-flow
Merge pull request #77 from voku/copilot/adapt-php-parser-compatibility

Add php-parser v5 compatibility (^4.18 || ^5)

8 of 13 new or added lines in 7 files covered. (61.54%)

42 existing lines in 4 files now uncovered.

1543 of 1853 relevant lines covered (83.27%)

43.97 hits per line

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

92.66
/src/voku/SimplePhpParser/Model/PHPClass.php
1
<?php
2

3
declare(strict_types=1);
4

5
namespace voku\SimplePhpParser\Model;
6

7
use PhpParser\Comment\Doc;
8
use PhpParser\Node\Stmt\Class_;
9
use ReflectionClass;
10
use voku\SimplePhpParser\Parsers\Helper\Utils;
11

12
class PHPClass extends BasePHPClass
13
{
14
    /**
15
     * @phpstan-var class-string
16
     */
17
    public string $name;
18

19
    /**
20
     * @phpstan-var class-string|null
21
     */
22
    public ?string $parentClass = null;
23

24
    /**
25
     * @var string[]
26
     *
27
     * @phpstan-var class-string[]
28
     */
29
    public array $interfaces = [];
30

31
    /**
32
     * @param Class_ $node
33
     * @param null   $dummy
34
     *
35
     * @return $this
36
     */
37
    public function readObjectFromPhpNode($node, $dummy = null): self
38
    {
39
        $this->prepareNode($node);
111✔
40

41
        $this->name = static::getFQN($node);
111✔
42

43
        $this->is_final = $node->isFinal();
111✔
44

45
        $this->is_abstract = $node->isAbstract();
111✔
46

47
        if (method_exists($node, 'isReadOnly')) {
111✔
48
            $this->is_readonly = $node->isReadOnly();
111✔
49
        }
50

51
        $this->is_anonymous = $node->isAnonymous();
111✔
52

53
        // Extract PHP 8.0+ attributes
54
        if (!empty($node->attrGroups)) {
111✔
55
            $this->attributes = Utils::extractAttributesFromAstNode($node->attrGroups);
28✔
56
        }
57

58
        // PHP < 8.2 raises an uncatchable E_COMPILE_ERROR for certain PHP 8.2+ syntax
59
        // (standalone true/false/null types, DNF types, readonly class). Similarly,
60
        // PHP < 8.3 raises an error for PHP 8.3+ syntax (typed class constants).
61
        // Skip autoloading in those cases; AST data is still read from the node below.
62
        $canAutoload = (\PHP_VERSION_ID >= 80200 || !self::nodeUsesPHP82PlusSyntax($node))
111✔
63
            && (\PHP_VERSION_ID >= 80300 || !self::nodeUsesPHP83PlusSyntax($node));
111✔
64
        $classExists = false;
111✔
65
        if ($canAutoload) {
111✔
66
            try {
67
                if (\class_exists($this->name, true)) {
109✔
68
                    $classExists = true;
109✔
69
                }
70
            } catch (\Throwable $e) {
×
71
                // nothing
72
            }
73
        }
74
        if ($classExists) {
111✔
75
            $reflectionClass = Utils::createClassReflectionInstance($this->name);
89✔
76
            $this->readObjectFromReflection($reflectionClass);
89✔
77
        }
78

79
        $this->collectTags($node);
111✔
80

81
        if (!empty($node->extends)) {
111✔
82
            $classExtended = $node->extends->toString();
44✔
83
            /** @noinspection PhpSillyAssignmentInspection - hack for phpstan */
84
            /** @var class-string $classExtended */
85
            $classExtended = $classExtended;
44✔
86
            $this->parentClass = $classExtended;
44✔
87
        }
88

89
        $docComment = $node->getDocComment();
111✔
90
        if ($docComment) {
111✔
91
            $this->readPhpDocProperties($docComment->getText());
103✔
92
        }
93

94
        foreach ($node->getProperties() as $property) {
111✔
95
            $propertyNameTmp = $this->getConstantFQN($property, $property->props[0]->name->name);
77✔
96

97
            if (isset($this->properties[$propertyNameTmp])) {
77✔
98
                $this->properties[$propertyNameTmp] = $this->properties[$propertyNameTmp]->readObjectFromPhpNode($property, $this->name);
71✔
99
            } else {
100
                $this->properties[$propertyNameTmp] = (new PHPProperty($this->parserContainer))->readObjectFromPhpNode($property, $this->name);
8✔
101
            }
102

103
            if ($this->is_readonly) {
77✔
104
                $this->properties[$propertyNameTmp]->is_readonly = true;
7✔
105
            }
106
        }
107

108
        foreach ($node->getMethods() as $method) {
111✔
109
            $methodNameTmp = $method->name->name;
101✔
110

111
            if (isset($this->methods[$methodNameTmp])) {
101✔
112
                $this->methods[$methodNameTmp] = $this->methods[$methodNameTmp]->readObjectFromPhpNode($method, $this->name);
83✔
113
            } else {
114
                $this->methods[$methodNameTmp] = (new PHPMethod($this->parserContainer))->readObjectFromPhpNode($method, $this->name);
26✔
115
            }
116

117
            if (!$this->methods[$methodNameTmp]->file) {
101✔
118
                $this->methods[$methodNameTmp]->file = $this->file;
26✔
119
            }
120
        }
121

122
        if (!empty($node->implements)) {
111✔
123
            foreach ($node->implements as $interfaceObject) {
30✔
124
                $interfaceFQN = $interfaceObject->toString();
30✔
125
                /** @noinspection PhpSillyAssignmentInspection - hack for phpstan */
126
                /** @var class-string $interfaceFQN */
127
                $interfaceFQN = $interfaceFQN;
30✔
128
                $this->interfaces[$interfaceFQN] = $interfaceFQN;
30✔
129
            }
130
        }
131

132
        return $this;
111✔
133
    }
134

135
    /**
136
     * @param ReflectionClass $clazz
137
     *
138
     * @return $this
139
     */
140
    public function readObjectFromReflection($clazz): self
141
    {
142
        $this->name = $clazz->getName();
89✔
143

144
        if (!$this->line) {
89✔
145
            $lineTmp = $clazz->getStartLine();
40✔
146
            if ($lineTmp !== false) {
40✔
147
                $this->line = $lineTmp;
24✔
148
            }
149
        }
150

151
        $file = $clazz->getFileName();
89✔
152
        if ($file) {
89✔
153
            $this->file = $file;
89✔
154
        }
155

156
        $this->is_final = $clazz->isFinal();
89✔
157

158
        $this->is_abstract = $clazz->isAbstract();
89✔
159

160
        if (method_exists($clazz, 'isReadOnly')) {
89✔
161
            $this->is_readonly = $clazz->isReadOnly();
71✔
162
        }
163

164
        $this->is_anonymous = $clazz->isAnonymous();
89✔
165

166
        $this->is_cloneable = $clazz->isCloneable();
89✔
167

168
        $this->is_instantiable = $clazz->isInstantiable();
89✔
169

170
        $this->is_iterable = $clazz->isIterable();
89✔
171

172
        // Extract PHP 8.0+ attributes
173
        $this->attributes = Utils::extractAttributesFromReflection($clazz);
89✔
174

175
        $parent = $clazz->getParentClass();
89✔
176
        if ($parent) {
89✔
177
            $this->parentClass = $parent->getName();
40✔
178

179
            $classExists = false;
40✔
180
            try {
181
                if (
182
                    !$this->parserContainer->getClass($this->parentClass)
40✔
183
                    &&
184
                    \class_exists($this->parentClass, true)
40✔
185
                ) {
186
                    $classExists = true;
40✔
187
                }
188
            } catch (\Throwable $e) {
×
189
                // nothing
190
            }
191
            if ($classExists) {
40✔
192
                $reflectionClass = Utils::createClassReflectionInstance($this->parentClass);
40✔
193
                $class = (new self($this->parserContainer))->readObjectFromReflection($reflectionClass);
40✔
194
                $this->parserContainer->addClass($class);
40✔
195
            }
196
        }
197

198
        foreach ($clazz->getProperties() as $property) {
89✔
199
            $propertyPhp = (new PHPProperty($this->parserContainer))->readObjectFromReflection($property);
83✔
200
            $this->properties[$propertyPhp->name] = $propertyPhp;
83✔
201

202
            if ($this->is_readonly) {
83✔
203
                $this->properties[$propertyPhp->name]->is_readonly = true;
6✔
204
            }
205
        }
206

207
        foreach ($clazz->getInterfaceNames() as $interfaceName) {
89✔
208
            /** @noinspection PhpSillyAssignmentInspection - hack for phpstan */
209
            /** @var class-string $interfaceName */
210
            $interfaceName = $interfaceName;
40✔
211
            $this->interfaces[$interfaceName] = $interfaceName;
40✔
212
        }
213

214
        foreach ($clazz->getMethods() as $method) {
89✔
215
            $methodNameTmp = $method->getName();
87✔
216

217
            $this->methods[$methodNameTmp] = (new PHPMethod($this->parserContainer))->readObjectFromReflection($method);
87✔
218

219
            if (!$this->methods[$methodNameTmp]->file) {
87✔
220
                $this->methods[$methodNameTmp]->file = $this->file;
×
221
            }
222
        }
223

224
        foreach ($clazz->getReflectionConstants() as $constant) {
89✔
225
            $constantNameTmp = $constant->getName();
54✔
226

227
            $this->constants[$constantNameTmp] = (new PHPConst($this->parserContainer))->readObjectFromReflection($constant);
54✔
228

229
            if (!$this->constants[$constantNameTmp]->file) {
54✔
230
                $this->constants[$constantNameTmp]->file = $this->file;
×
231
            }
232
        }
233

234
        return $this;
89✔
235
    }
236

237
    /**
238
     * @param string[] $access
239
     * @param bool     $skipMethodsWithLeadingUnderscore
240
     *
241
     * @return array
242
     *
243
     * @psalm-return array<string, array{
244
     *     type: null|string,
245
     *     typeFromPhpDocMaybeWithComment: null|string,
246
     *     typeFromPhpDoc: null|string,
247
     *     typeFromPhpDocSimple: null|string,
248
     *     typeFromPhpDocExtended: null|string,
249
     *     typeFromDefaultValue: null|string
250
     * }>
251
     */
252
    public function getPropertiesInfo(
253
        array $access = ['public', 'protected', 'private'],
254
        bool $skipMethodsWithLeadingUnderscore = false
255
    ): array {
256
        // init
257
        $allInfo = [];
4✔
258

259
        foreach ($this->properties as $property) {
4✔
260
            if (!\in_array($property->access, $access, true)) {
4✔
261
                continue;
×
262
            }
263

264
            if ($skipMethodsWithLeadingUnderscore && \strpos($property->name, '_') === 0) {
4✔
265
                continue;
×
266
            }
267

268
            $types = [];
4✔
269
            $types['type'] = $property->type;
4✔
270
            $types['typeFromPhpDocMaybeWithComment'] = $property->typeFromPhpDocMaybeWithComment;
4✔
271
            $types['typeFromPhpDoc'] = $property->typeFromPhpDoc;
4✔
272
            $types['typeFromPhpDocSimple'] = $property->typeFromPhpDocSimple;
4✔
273
            $types['typeFromPhpDocExtended'] = $property->typeFromPhpDocExtended;
4✔
274
            $types['typeFromDefaultValue'] = $property->typeFromDefaultValue;
4✔
275

276
            $allInfo[$property->name] = $types;
4✔
277
        }
278

279
        return $allInfo;
4✔
280
    }
281

282
    /**
283
     * @param string[] $access
284
     * @param bool     $skipDeprecatedMethods
285
     * @param bool     $skipMethodsWithLeadingUnderscore
286
     *
287
     * @return array<mixed>
288
     *
289
     * @psalm-return array<string, array{
290
     *     fullDescription: string,
291
     *     line: null|int,
292
     *     file: null|string,
293
     *     error: string,
294
     *     is_deprecated: bool,
295
     *     is_static: null|bool,
296
     *     is_meta: bool,
297
     *     is_internal: bool,
298
     *     is_removed: bool,
299
     *     paramsTypes: array<string,
300
     *         array{
301
     *              type?: null|string,
302
     *              typeFromPhpDoc?: null|string,
303
     *              typeFromPhpDocExtended?: null|string,
304
     *              typeFromPhpDocSimple?: null|string,
305
     *              typeFromPhpDocMaybeWithComment?: null|string,
306
     *              typeFromDefaultValue?: null|string
307
     *         }
308
     *     >,
309
     *     returnTypes: array{
310
     *         type: null|string,
311
     *         typeFromPhpDoc: null|string,
312
     *         typeFromPhpDocExtended: null|string,
313
     *         typeFromPhpDocSimple: null|string,
314
     *         typeFromPhpDocMaybeWithComment: null|string
315
     *     },
316
     *     paramsPhpDocRaw: array<string, null|string>,
317
     *     returnPhpDocRaw: null|string
318
     * }>
319
     */
320
    public function getMethodsInfo(
321
        array $access = ['public', 'protected', 'private'],
322
        bool $skipDeprecatedMethods = false,
323
        bool $skipMethodsWithLeadingUnderscore = false
324
    ): array {
325
        // init
326
        $allInfo = [];
8✔
327

328
        foreach ($this->methods as $method) {
8✔
329
            if (!\in_array($method->access, $access, true)) {
8✔
330
                continue;
×
331
            }
332

333
            if ($skipDeprecatedMethods && $method->hasDeprecatedTag) {
8✔
334
                continue;
×
335
            }
336

337
            if ($skipMethodsWithLeadingUnderscore && \strpos($method->name, '_') === 0) {
8✔
338
                continue;
×
339
            }
340

341
            $paramsTypes = [];
8✔
342
            foreach ($method->parameters as $tagParam) {
8✔
343
                $paramsTypes[$tagParam->name]['type'] = $tagParam->type;
8✔
344
                $paramsTypes[$tagParam->name]['typeFromPhpDocMaybeWithComment'] = $tagParam->typeFromPhpDocMaybeWithComment;
8✔
345
                $paramsTypes[$tagParam->name]['typeFromPhpDoc'] = $tagParam->typeFromPhpDoc;
8✔
346
                $paramsTypes[$tagParam->name]['typeFromPhpDocSimple'] = $tagParam->typeFromPhpDocSimple;
8✔
347
                $paramsTypes[$tagParam->name]['typeFromPhpDocExtended'] = $tagParam->typeFromPhpDocExtended;
8✔
348
                $paramsTypes[$tagParam->name]['typeFromDefaultValue'] = $tagParam->typeFromDefaultValue;
8✔
349
            }
350

351
            $returnTypes = [];
8✔
352
            $returnTypes['type'] = $method->returnType;
8✔
353
            $returnTypes['typeFromPhpDocMaybeWithComment'] = $method->returnTypeFromPhpDocMaybeWithComment;
8✔
354
            $returnTypes['typeFromPhpDoc'] = $method->returnTypeFromPhpDoc;
8✔
355
            $returnTypes['typeFromPhpDocSimple'] = $method->returnTypeFromPhpDocSimple;
8✔
356
            $returnTypes['typeFromPhpDocExtended'] = $method->returnTypeFromPhpDocExtended;
8✔
357

358
            $paramsPhpDocRaw = [];
8✔
359
            foreach ($method->parameters as $tagParam) {
8✔
360
                $paramsPhpDocRaw[$tagParam->name] = $tagParam->phpDocRaw;
8✔
361
            }
362

363
            $infoTmp = [];
8✔
364
            $infoTmp['fullDescription'] = \trim($method->summary . "\n\n" . $method->description);
8✔
365
            $infoTmp['paramsTypes'] = $paramsTypes;
8✔
366
            $infoTmp['returnTypes'] = $returnTypes;
8✔
367
            $infoTmp['paramsPhpDocRaw'] = $paramsPhpDocRaw;
8✔
368
            $infoTmp['returnPhpDocRaw'] = $method->returnPhpDocRaw;
8✔
369
            $infoTmp['line'] = $method->line ?? $this->line;
8✔
370
            $infoTmp['file'] = $method->file ?? $this->file;
8✔
371
            $infoTmp['error'] = \implode("\n", $method->parseError);
8✔
372
            foreach ($method->parameters as $parameter) {
8✔
373
                $infoTmp['error'] .= ($infoTmp['error'] ? "\n" : '') . \implode("\n", $parameter->parseError);
8✔
374
            }
375
            $infoTmp['is_deprecated'] = $method->hasDeprecatedTag;
8✔
376
            $infoTmp['is_static'] = $method->is_static;
8✔
377
            $infoTmp['is_meta'] = $method->hasMetaTag;
8✔
378
            $infoTmp['is_internal'] = $method->hasInternalTag;
8✔
379
            $infoTmp['is_removed'] = $method->hasRemovedTag;
8✔
380

381
            $allInfo[$method->name] = $infoTmp;
8✔
382
        }
383

384
        \asort($allInfo);
8✔
385

386
        return $allInfo;
8✔
387
    }
388

389
    /**
390
     * @param Doc|string $doc
391
     */
392
    private function readPhpDocProperties($doc): void
393
    {
394
        if ($doc instanceof Doc) {
103✔
395
            $docComment = $doc->getText();
×
396
        } else {
397
            $docComment = $doc;
103✔
398
        }
399
        if ($docComment === '') {
103✔
400
            return;
×
401
        }
402

403
        try {
404
            $phpDoc = Utils::createDocBlockInstance()->create($docComment);
103✔
405

406
            $parsedPropertyTags = $phpDoc->getTagsByName('property')
103✔
407
                               + $phpDoc->getTagsByName('property-read')
103✔
408
                               + $phpDoc->getTagsByName('property-write');
103✔
409

410
            if (!empty($parsedPropertyTags)) {
103✔
411
                foreach ($parsedPropertyTags as $parsedPropertyTag) {
103✔
412
                    if (
413
                        $parsedPropertyTag instanceof \phpDocumentor\Reflection\DocBlock\Tags\PropertyRead
28✔
414
                        ||
415
                        $parsedPropertyTag instanceof \phpDocumentor\Reflection\DocBlock\Tags\PropertyWrite
28✔
416
                        ||
417
                        $parsedPropertyTag instanceof \phpDocumentor\Reflection\DocBlock\Tags\Property
28✔
418
                    ) {
419
                        $propertyPhp = new PHPProperty($this->parserContainer);
28✔
420

421
                        $nameTmp = $parsedPropertyTag->getVariableName();
28✔
422
                        if (!$nameTmp) {
28✔
423
                            continue;
×
424
                        }
425

426
                        $propertyPhp->name = $nameTmp;
28✔
427

428
                        $propertyPhp->access = 'public';
28✔
429

430
                        $type = $parsedPropertyTag->getType();
28✔
431

432
                        $propertyPhp->typeFromPhpDoc = Utils::normalizePhpType($type . '');
28✔
433

434
                        $typeFromPhpDocMaybeWithCommentTmp = \trim((string) $parsedPropertyTag);
28✔
435
                        if (
436
                            $typeFromPhpDocMaybeWithCommentTmp
28✔
437
                            &&
438
                            \strpos($typeFromPhpDocMaybeWithCommentTmp, '$') !== 0
28✔
439
                        ) {
440
                            $propertyPhp->typeFromPhpDocMaybeWithComment = $typeFromPhpDocMaybeWithCommentTmp;
28✔
441
                        }
442

443
                        $typeTmp = Utils::parseDocTypeObject($type);
28✔
444
                        if ($typeTmp !== '') {
28✔
445
                            $propertyPhp->typeFromPhpDocSimple = $typeTmp;
28✔
446
                        }
447

448
                        if ($propertyPhp->typeFromPhpDoc) {
28✔
449
                            $propertyPhp->typeFromPhpDocExtended = Utils::modernPhpdoc($propertyPhp->typeFromPhpDoc);
28✔
450
                        }
451

452
                        $this->properties[$propertyPhp->name] = $propertyPhp;
28✔
453
                    }
454
                }
455
            }
456
        } catch (\Exception $e) {
×
457
            $tmpErrorMessage = ($this->name ?: '?') . ':' . ($this->line ?? '?') . ' | ' . \print_r($e->getMessage(), true);
×
458
            $this->parseError[\md5($tmpErrorMessage)] = $tmpErrorMessage;
×
459
        }
460
    }
461

462
    /**
463
     * Returns true if the class node uses syntax that requires PHP 8.2+ and would
464
     * cause an uncatchable E_COMPILE_ERROR when autoloaded on PHP < 8.2.
465
     *
466
     * @param Class_ $node
467
     *
468
     * @return bool
469
     */
470
    private static function nodeUsesPHP82PlusSyntax(Class_ $node): bool
471
    {
472
        // readonly class is PHP 8.2+
UNCOV
473
        if (\method_exists($node, 'isReadOnly') && $node->isReadOnly()) {
25✔
UNCOV
474
            return true;
1✔
475
        }
476

UNCOV
477
        foreach ($node->stmts as $stmt) {
25✔
UNCOV
478
            if ($stmt instanceof \PhpParser\Node\Stmt\ClassMethod) {
24✔
UNCOV
479
                if (self::containsPHP82PlusType($stmt->returnType)) {
23✔
UNCOV
480
                    return true;
4✔
481
                }
UNCOV
482
                foreach ($stmt->params as $param) {
23✔
UNCOV
483
                    if (self::containsPHP82PlusType($param->type)) {
21✔
484
                        return true;
×
485
                    }
486
                }
UNCOV
487
            } elseif ($stmt instanceof \PhpParser\Node\Stmt\Property) {
18✔
UNCOV
488
                if (self::containsPHP82PlusType($stmt->type)) {
17✔
UNCOV
489
                    return true;
2✔
490
                }
491
            }
492
        }
493

UNCOV
494
        return false;
23✔
495
    }
496

497
    /**
498
     * Returns true if the class node uses syntax that requires PHP 8.3+ and would
499
     * cause an uncatchable E_COMPILE_ERROR when autoloaded on PHP < 8.3.
500
     *
501
     * Covers: typed class constants (Stmt\ClassConst with a non-null type).
502
     *
503
     * @param Class_ $node
504
     *
505
     * @return bool
506
     */
507
    private static function nodeUsesPHP83PlusSyntax(Class_ $node): bool
508
    {
509
        foreach ($node->stmts as $stmt) {
51✔
510
            // Typed class constants are PHP 8.3+
511
            if ($stmt instanceof \PhpParser\Node\Stmt\ClassConst && $stmt->type !== null) {
49✔
512
                return true;
4✔
513
            }
514
        }
515

516
        return false;
51✔
517
    }
518

519
    /**
520
     * Returns true if the given type node is a PHP 8.2+ type that causes an
521
     * uncatchable E_COMPILE_ERROR when loaded on PHP < 8.2.
522
     *
523
     * Covers: standalone true/false/null types and DNF types (union of intersections).
524
     *
525
     * @param \PhpParser\Node|null $typeNode
526
     *
527
     * @return bool
528
     */
529
    private static function containsPHP82PlusType($typeNode): bool
530
    {
UNCOV
531
        if ($typeNode === null) {
24✔
UNCOV
532
            return false;
20✔
533
        }
534

535
        // Standalone true, false, null as the *sole* type (not in a nullable like ?string)
536
        // are PHP 8.2+ only. PHP-Parser represents these as Identifier nodes (not Name).
537
        // Nullable null (?null) is syntactically invalid; NullableType wraps the inner type.
UNCOV
538
        if ($typeNode instanceof \PhpParser\Node\Identifier) {
21✔
UNCOV
539
            $name = \strtolower($typeNode->name);
21✔
UNCOV
540
            return $name === 'true' || $name === 'false' || $name === 'null';
21✔
541
        }
542

543
        // DNF types: union type containing an intersection type (PHP 8.2+)
UNCOV
544
        if ($typeNode instanceof \PhpParser\Node\UnionType) {
13✔
UNCOV
545
            foreach ($typeNode->types as $t) {
4✔
UNCOV
546
                if ($t instanceof \PhpParser\Node\IntersectionType || self::containsPHP82PlusType($t)) {
4✔
UNCOV
547
                    return true;
2✔
548
                }
549
            }
550
        }
551

552
        // Recurse into nullable type
UNCOV
553
        if ($typeNode instanceof \PhpParser\Node\NullableType) {
13✔
UNCOV
554
            return self::containsPHP82PlusType($typeNode->type);
5✔
555
        }
556

UNCOV
557
        return false;
13✔
558
    }
559
}
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