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

wol-soft / php-json-schema-model-generator / 28856540538

07 Jul 2026 09:31AM UTC coverage: 98.076%. First build
28856540538

Pull #157

github

wol-soft
Warn and skip on unevaluatedProperties dead cells + edge-case tests

UnevaluatedPropertiesValidatorFactory now consolidates all four
sibling shapes that leave the unevaluatedProperties bucket
permanently empty into a single deadCodeReason() helper:

- additionalProperties: true — every extra flows to the model but
  the accumulator does not credit it, so the validator would still
  fire and defeat the intent of `additionalProperties: true`.
- additionalProperties: {schema} — every extra is claimed and
  validated by additionalProperties; the unevaluated set is empty.
- additionalProperties: false — every extra is rejected before the
  post-composition phase, so the unevaluated validator never runs.
- denyAdditionalProperties() generator flag with additionalProperties
  absent — synthesises the same false shape at configuration time.

Each cell emits a distinct warning via the existing echo channel
so build-output greps can identify the specific dead shape, and
skips validator emission entirely.

Tests added:

- Object-side dead-code data provider covering all four shapes plus
  the denyAdditionalProperties() variant. Rows pass the
  GeneratorConfiguration directly (setOutputEnabled(true) on the
  base, chained with setDenyAdditionalProperties(true) on the deny
  row) so the test signature drops the closure/fallback boilerplate.
- testAdditionalFalseRejectsExtrasEvenWhenUnevaluatedSchemaWouldAccept
  proves the factory suppressed the validator: an extra that would
  satisfy the unevaluated integer schema is still rejected because
  additionalProperties: false runs first.
- testContradictoryInnerSchemaThrowsSchemaExceptionPointingAtFile
  pins that an unevaluatedProperties schema with contradictory allOf
  types surfaces via the existing "conflicting types" SchemaException
  path, with the file identifier preserved.
- testPropertyNamesRejectionPrecedesUnevaluated data-provider variant
  covering both direct-exception and error-collection modes. The
  re... (continued)
Pull Request #157: Unevaluated properties

712 of 774 new or added lines in 38 files covered. (91.99%)

7237 of 7379 relevant lines covered (98.08%)

563.49 hits per line

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

85.02
/src/SchemaProcessor/PostProcessor/UnevaluatedPropertiesAccessorPostProcessor.php
1
<?php
2

3
declare(strict_types=1);
4

5
namespace PHPModelGenerator\SchemaProcessor\PostProcessor;
6

7
use PHPModelGenerator\Accessor\ImmutableUnevaluatedPropertiesAccessor;
8
use PHPModelGenerator\Accessor\UnevaluatedPropertiesAccessor;
9
use PHPModelGenerator\Exception\FileSystemException;
10
use PHPModelGenerator\Exception\Object\MinPropertiesException;
11
use PHPModelGenerator\Exception\Object\RegularPropertyAsUnevaluatedPropertyException;
12
use PHPModelGenerator\Exception\SchemaException;
13
use PHPModelGenerator\Model\GeneratorConfiguration;
14
use PHPModelGenerator\Model\Property\Property;
15
use PHPModelGenerator\Model\Property\PropertyInterface;
16
use PHPModelGenerator\Model\Property\PropertyType;
17
use PHPModelGenerator\Model\Schema;
18
use PHPModelGenerator\Model\SchemaDefinition\JsonSchema;
19
use PHPModelGenerator\Model\Validator\AbstractComposedPropertyValidator;
20
use PHPModelGenerator\Model\Validator\PropertyValidator;
21
use PHPModelGenerator\Model\Validator\UnevaluatedPropertiesValidator;
22
use PHPModelGenerator\PropertyProcessor\Decorator\TypeHint\ArrayTypeHintDecorator;
23
use PHPModelGenerator\SchemaProcessor\Hook\SchemaHookResolver;
24
use PHPModelGenerator\Utils\JsonSchema as JsonSchemaUtil;
25
use PHPModelGenerator\Utils\RenderHelper;
26
use ReflectionClass;
27

28
/**
29
 * Renders the `unevaluatedProperties()` accessor method on schemas declaring
30
 * `unevaluatedProperties: <true | schema>` where `additionalProperties` does not already claim
31
 * every extra key at the same level.
32
 *
33
 * Mirrors AdditionalPropertiesAccessorPostProcessor: emits a public getter that returns a
34
 * cached accessor instance (either the bare production-library class for untyped schemas, or a
35
 * generated companion class with narrowed types for typed schemas), plus private mutator shims
36
 * `_setUnevaluatedProperty` / `_removeUnevaluatedProperty` invoked by the accessor's closures.
37
 *
38
 * Emission policy:
39
 *   - additionalProperties absent (denyAdditionalProperties off) / additionalProperties: true → emit
40
 *   - additionalProperties: false                                                                → skip
41
 *   - additionalProperties: {schema}                                                             → skip
42
 * In the skip cases, additionalProperties already either rejects every extra key or claims and
43
 * validates it, so _unevaluatedProperties would always be empty — exposing an accessor would
44
 * be misleading. Skipping is total: no backing field, no accessor method, no shims, no
45
 * companion, no setCollectUnevaluatedProperties(true) call. The validator continues to run as
46
 * a pure assertion.
47
 */
48
class UnevaluatedPropertiesAccessorPostProcessor extends PostProcessor
49
{
50
    use CompanionGeneratorTrait;
51

52
    /**
53
     * Add the unevaluatedProperties() accessor method to the provided schema.
54
     *
55
     * @throws SchemaException
56
     */
57
    public function process(Schema $schema, GeneratorConfiguration $generatorConfiguration): void
20✔
58
    {
59
        if (!$this->shouldEmitAccessor($schema, $generatorConfiguration)) {
20✔
60
            return;
7✔
61
        }
62

63
        $validator = $this->locateUnevaluatedValidator($schema);
15✔
64
        if ($validator === null) {
15✔
65
            return;
3✔
66
        }
67

68
        $validator->setCollectUnevaluatedProperties(true);
12✔
69
        $validationProperty = $validator->getValidationProperty();
12✔
70

71
        $this->addBackingField($schema, $validationProperty);
12✔
72
        $this->addAccessorCacheField($schema);
12✔
73

74
        $hasCompanion = $validationProperty->getType() !== null;
12✔
75
        $isImmutable = $generatorConfiguration->isImmutable();
12✔
76

77
        $this->addAccessorMethod($schema, $generatorConfiguration, $hasCompanion);
12✔
78

79
        if (!$isImmutable) {
12✔
80
            $this->addSetUnevaluatedPropertyMethod($schema, $generatorConfiguration, $validationProperty);
10✔
81
            $this->addRemoveUnevaluatedPropertyMethod($schema, $generatorConfiguration);
10✔
82
        }
83

84
        if ($hasCompanion) {
12✔
85
            $this->pendingCompanions[] = [
12✔
86
                'schema' => $schema,
12✔
87
                'generatorConfiguration' => $generatorConfiguration,
12✔
88
                'validationProperty' => $validationProperty,
12✔
89
            ];
12✔
90
        }
91
    }
92

93
    /**
94
     * Render the accessor iff unevaluatedProperties is declared as a non-false value AND
95
     * additionalProperties at the same level does not already claim every extra.
96
     */
97
    private function shouldEmitAccessor(Schema $schema, GeneratorConfiguration $generatorConfiguration): bool
20✔
98
    {
99
        $json = $schema->getJsonSchema()->getJson();
20✔
100

101
        // unevaluatedProperties: false produces a NoUnevaluatedPropertiesValidator with no
102
        // backing field — there is nothing to expose.
103
        if (!array_key_exists('unevaluatedProperties', $json) || $json['unevaluatedProperties'] === false) {
20✔
104
            return false;
4✔
105
        }
106

107
        // additionalProperties: false / {schema} both leave _unevaluatedProperties permanently
108
        // empty (the first rejects every extra, the second validates and claims every extra) —
109
        // exposing an accessor with no possible contents would be misleading.
110
        if (array_key_exists('additionalProperties', $json) && $json['additionalProperties'] !== true) {
18✔
111
            return false;
3✔
112
        }
113

114
        // denyAdditionalProperties() flips a missing additionalProperties to false → still dead.
115
        if (!array_key_exists('additionalProperties', $json) && $generatorConfiguration->denyAdditionalProperties()) {
15✔
NEW
116
            return false;
×
117
        }
118

119
        return true;
15✔
120
    }
121

122
    /**
123
     * The factory attaches the UnevaluatedPropertiesValidator via
124
     * Schema::addPostCompositionValidator() — not addBaseValidator() — because spec ordering
125
     * requires the unevaluated check to run after every adjacent composition has had a chance
126
     * to claim keys. The accessor post processor must look in the same bucket.
127
     */
128
    private function locateUnevaluatedValidator(Schema $schema): ?UnevaluatedPropertiesValidator
15✔
129
    {
130
        foreach ($schema->getPostCompositionValidators() as $validator) {
15✔
131
            if ($validator instanceof UnevaluatedPropertiesValidator) {
12✔
132
                return $validator;
12✔
133
            }
134
        }
135

136
        return null;
3✔
137
    }
138

139
    /**
140
     * Recursively walks every composition branch on the schema and harvests the property
141
     * names and pattern regexes those branches declare. A key matching either set must not be
142
     * routed through the unevaluated accessor because it belongs to a composition contract
143
     * (a typed inline branch member or a pattern-matched branch property), not to the
144
     * unevaluated bucket.
145
     *
146
     * Walks composition validators registered on the schema's base validators and recurses
147
     * through nested allOf/anyOf/oneOf/if-then-else by inspecting each branch's raw JSON.
148
     *
149
     * Each branch's schema contributes its own JSON pointer root; a `properties.foo` declaration
150
     * inside `/allOf/0` therefore reports the pointer `/allOf/0/properties/foo`. When the same
151
     * name (or pattern) appears in multiple branches, the first branch-order encounter wins so
152
     * the emitted map has stable content across regenerations.
153
     *
154
     * @return array{0: array<string, string>, 1: array<string, string>}
155
     *   [propertyName => pointer, patternRegex => pointer]
156
     */
157
    private function harvestCompositionPropertyNames(Schema $schema): array
10✔
158
    {
159
        $propertyPointers = [];
10✔
160
        $patternPointers = [];
10✔
161

162
        foreach ($schema->getBaseValidators() as $baseValidator) {
10✔
163
            if ($baseValidator instanceof AbstractComposedPropertyValidator) {
3✔
164
                foreach ($baseValidator->getComposedProperties() as $branch) {
2✔
165
                    $branchSchema = $branch->getBranchSchema();
2✔
166
                    $this->collectBranchPropertyNames(
2✔
167
                        $branchSchema->getJson(),
2✔
168
                        $branchSchema->getPointer(),
2✔
169
                        $propertyPointers,
2✔
170
                        $patternPointers,
2✔
171
                    );
2✔
172
                }
173
            }
174
        }
175

176
        return [$propertyPointers, $patternPointers];
10✔
177
    }
178

179
    /**
180
     * Walks a single branch's raw JSON schema and appends its `properties` keys and
181
     * `patternProperties` regexes to the accumulators, along with the pointer at which each
182
     * declaration lives. Recurses into nested composition keywords because a branch may itself
183
     * contain allOf/anyOf/oneOf whose sub-branches declare further properties.
184
     *
185
     * @param array<string, string> $propertyPointers accumulator: name => JSON pointer
186
     * @param array<string, string> $patternPointers  accumulator: regex => JSON pointer
187
     */
188
    private function collectBranchPropertyNames(
2✔
189
        array $branchJson,
190
        string $branchPointer,
191
        array &$propertyPointers,
192
        array &$patternPointers,
193
    ): void {
194
        foreach (array_keys($branchJson['properties'] ?? []) as $name) {
2✔
195
            $name = (string) $name;
2✔
196
            $propertyPointers[$name] ??= $branchPointer . '/properties/' . JsonSchemaUtil::encodePointer($name);
2✔
197
        }
198

199
        foreach (array_keys($branchJson['patternProperties'] ?? []) as $pattern) {
2✔
NEW
200
            $pattern = (string) $pattern;
×
NEW
201
            $patternPointers[$pattern] ??=
×
NEW
202
                $branchPointer . '/patternProperties/' . JsonSchemaUtil::encodePointer($pattern);
×
203
        }
204

205
        foreach (['allOf', 'anyOf', 'oneOf'] as $compositionKey) {
2✔
206
            foreach ($branchJson[$compositionKey] ?? [] as $index => $nestedBranchJson) {
2✔
NEW
207
                if (is_array($nestedBranchJson)) {
×
NEW
208
                    $this->collectBranchPropertyNames(
×
NEW
209
                        $nestedBranchJson,
×
NEW
210
                        $branchPointer . '/' . $compositionKey . '/' . $index,
×
NEW
211
                        $propertyPointers,
×
NEW
212
                        $patternPointers,
×
NEW
213
                    );
×
214
                }
215
            }
216
        }
217

218
        foreach (['if', 'then', 'else'] as $conditionalKey) {
2✔
219
            if (isset($branchJson[$conditionalKey]) && is_array($branchJson[$conditionalKey])) {
2✔
NEW
220
                $this->collectBranchPropertyNames(
×
NEW
221
                    $branchJson[$conditionalKey],
×
NEW
222
                    $branchPointer . '/' . $conditionalKey,
×
NEW
223
                    $propertyPointers,
×
NEW
224
                    $patternPointers,
×
NEW
225
                );
×
226
            }
227
        }
228
    }
229

230
    /**
231
     * @throws SchemaException
232
     */
233
    private function addBackingField(Schema $schema, PropertyInterface $validationProperty): void
12✔
234
    {
235
        $backingField = (new Property(
12✔
236
            'unevaluatedProperties',
12✔
237
            new PropertyType('array'),
12✔
238
            new JsonSchema(__FILE__, []),
12✔
239
            'Collect all unevaluated properties provided to the schema',
12✔
240
        ))
12✔
241
            ->setDefaultValue([])
12✔
242
            ->setInternal(true);
12✔
243

244
        if ($validationProperty->getType()) {
12✔
245
            $backingField->addTypeHintDecorator(new ArrayTypeHintDecorator($validationProperty));
12✔
246
        }
247

248
        $schema->addProperty($backingField);
12✔
249
        $schema->addRollbackProperty('_unevaluatedProperties');
12✔
250
    }
251

252
    /**
253
     * @throws SchemaException
254
     */
255
    private function addAccessorCacheField(Schema $schema): void
12✔
256
    {
257
        $schema->addProperty(
12✔
258
            (new Property(
12✔
259
                'unevaluatedPropertiesAccessor',
12✔
260
                null,
12✔
261
                $schema->getJsonSchema(),
12✔
262
                'Cached accessor instance for unevaluated properties',
12✔
263
            ))
12✔
264
                ->setDefaultValue('null', true)
12✔
265
                ->setInternal(true),
12✔
266
        );
12✔
267
        $schema->addAccessorCacheProperty('_unevaluatedPropertiesAccessor');
12✔
268
    }
269

270
    private function addAccessorMethod(
12✔
271
        Schema $schema,
272
        GeneratorConfiguration $generatorConfiguration,
273
        bool $hasCompanion,
274
    ): void {
275
        $isImmutable = $generatorConfiguration->isImmutable();
12✔
276

277
        if (!$hasCompanion) {
12✔
NEW
278
            $schema->addUsedClass($isImmutable
×
NEW
279
                ? ImmutableUnevaluatedPropertiesAccessor::class
×
NEW
280
                : UnevaluatedPropertiesAccessor::class);
×
281
        }
282

283
        $accessorType = $hasCompanion
12✔
284
            ? $schema->getClassName() . 'UnevaluatedProperties'
12✔
NEW
285
            : (new ReflectionClass($isImmutable
×
NEW
286
                ? ImmutableUnevaluatedPropertiesAccessor::class
×
NEW
287
                : UnevaluatedPropertiesAccessor::class))->getShortName();
×
288

289
        $schema->addMethod(
12✔
290
            'unevaluatedProperties',
12✔
291
            new RenderedMethod(
12✔
292
                $schema,
12✔
293
                $generatorConfiguration,
12✔
294
                'UnevaluatedProperties/UnevaluatedPropertiesAccessorMethod.phptpl',
12✔
295
                [
12✔
296
                    'accessorType' => $accessorType,
12✔
297
                    'immutable' => $isImmutable,
12✔
298
                ],
12✔
299
            ),
12✔
300
        );
12✔
301
    }
302

303
    private function addSetUnevaluatedPropertyMethod(
10✔
304
        Schema $schema,
305
        GeneratorConfiguration $generatorConfiguration,
306
        PropertyInterface $validationProperty,
307
    ): void {
308
        $nonInternalProperties = array_filter(
10✔
309
            $schema->getProperties(),
10✔
310
            static fn(PropertyInterface $property): bool => !$property->isInternal(),
10✔
311
        );
10✔
312

313
        $directPropertyPointers = [];
10✔
314
        foreach ($nonInternalProperties as $property) {
10✔
315
            $directPropertyPointers[$property->getName()] = JsonSchemaUtil::resolvePrimaryJsonPointer($property);
10✔
316
        }
317

318
        $schemaRootPointer = $schema->getJsonSchema()->getPointer();
10✔
319
        $directPatternPointers = [];
10✔
320
        foreach (array_keys($schema->getJsonSchema()->getJson()['patternProperties'] ?? []) as $pattern) {
10✔
321
            $pattern = (string) $pattern;
1✔
322
            $directPatternPointers[$pattern] =
1✔
323
                $schemaRootPointer . '/patternProperties/' . JsonSchemaUtil::encodePointer($pattern);
1✔
324
        }
325

326
        // A composition branch's `properties` / `patternProperties` declarations contribute keys
327
        // to the evaluated set at runtime (when the branch succeeds). Setting such a key via
328
        // unevaluatedProperties()->set() would bypass the branch's own type/constraint
329
        // validation, so the shim must reject those keys with the same exception used for
330
        // directly-declared properties. Root-declared entries win over branch-declared entries
331
        // with the same name/pattern, so the reported pointer for a name that lives at both
332
        // levels points at the root.
333
        [$compositionPropertyPointers, $compositionPatternPointers] = $this->harvestCompositionPropertyNames($schema);
10✔
334

335
        $objectPropertyPointers = $directPropertyPointers + $compositionPropertyPointers;
10✔
336
        $patternPropertyPointers = $directPatternPointers + $compositionPatternPointers;
10✔
337

338
        $hasObjectProperties = $objectPropertyPointers !== [];
10✔
339
        $hasPatternProperties = $patternPropertyPointers !== [];
10✔
340

341
        if ($hasObjectProperties || $hasPatternProperties) {
10✔
342
            $schema->addUsedClass(RegularPropertyAsUnevaluatedPropertyException::class);
10✔
343
        }
344

345
        $schema->addMethod(
10✔
346
            '_setUnevaluatedProperty',
10✔
347
            new RenderedMethod(
10✔
348
                $schema,
10✔
349
                $generatorConfiguration,
10✔
350
                'UnevaluatedProperties/SetUnevaluatedProperty.phptpl',
10✔
351
                [
10✔
352
                    'validationProperty' => $validationProperty,
10✔
353
                    'hasObjectProperties' => $hasObjectProperties,
10✔
354
                    'objectProperties' => RenderHelper::varExportArray(array_keys($objectPropertyPointers)),
10✔
355
                    'objectPropertyPointers' => RenderHelper::varExportArray($objectPropertyPointers),
10✔
356
                    'hasPatternProperties' => $hasPatternProperties,
10✔
357
                    'patternProperties' => $hasPatternProperties
10✔
358
                        ? RenderHelper::varExportPcrePatternMap($patternPropertyPointers)
1✔
NEW
359
                        : null,
×
360
                    'schemaHookResolver' => new SchemaHookResolver($schema),
10✔
361
                ],
10✔
362
            ),
10✔
363
        );
10✔
364
    }
365

366
    /**
367
     * @throws SchemaException
368
     */
369
    private function addRemoveUnevaluatedPropertyMethod(
10✔
370
        Schema $schema,
371
        GeneratorConfiguration $generatorConfiguration,
372
    ): void {
373
        $minPropertyValidator = null;
10✔
374
        $json = $schema->getJsonSchema()->getJson();
10✔
375
        if (isset($json['minProperties'])) {
10✔
NEW
376
            $minPropertyValidator = new PropertyValidator(
×
NEW
377
                new Property($schema->getClassName(), null, $schema->getJsonSchema()),
×
NEW
378
                sprintf(
×
NEW
379
                    '($updatedPropertiesCount = count($this->_rawModelDataInput) - 1) < %d',
×
NEW
380
                    $json['minProperties'],
×
NEW
381
                ),
×
NEW
382
                MinPropertiesException::class,
×
NEW
383
                [$json['minProperties'], '&$updatedPropertiesCount'],
×
NEW
384
            );
×
385
        }
386

387
        $schema->addMethod(
10✔
388
            '_removeUnevaluatedProperty',
10✔
389
            new RenderedMethod(
10✔
390
                $schema,
10✔
391
                $generatorConfiguration,
10✔
392
                'UnevaluatedProperties/RemoveUnevaluatedProperty.phptpl',
10✔
393
                ['minPropertyValidator' => $minPropertyValidator],
10✔
394
            ),
10✔
395
        );
10✔
396
    }
397

398
    /**
399
     * @throws FileSystemException
400
     */
401
    protected function renderCompanionFromEntry(array $entry): void
12✔
402
    {
403
        $this->renderCompanionClass(
12✔
404
            $entry['schema'],
12✔
405
            $entry['generatorConfiguration'],
12✔
406
            $entry['validationProperty'],
12✔
407
        );
12✔
408
    }
409

410
    /**
411
     * @throws FileSystemException
412
     */
413
    private function renderCompanionClass(
12✔
414
        Schema $schema,
415
        GeneratorConfiguration $generatorConfiguration,
416
        PropertyInterface $validationProperty,
417
    ): void {
418
        $renderHelper = new RenderHelper($generatorConfiguration);
12✔
419
        $isImmutable = $generatorConfiguration->isImmutable();
12✔
420
        $companionClassName = $schema->getClassName() . 'UnevaluatedProperties';
12✔
421

422
        $namespace = $this->resolveCompanionNamespace($schema, $generatorConfiguration);
12✔
423

424
        // get() always returns null when the key is absent, so the companion's get() return
425
        // type must allow null in addition to the schema-declared type.
426
        $nullableProperty = (clone $validationProperty)->setType(
12✔
427
            $validationProperty->getType(),
12✔
428
            new PropertyType($validationProperty->getType(true)->getNames(), true),
12✔
429
        );
12✔
430

431
        // The companion is a stand-alone class that mirrors the production-library accessor's
432
        // shape with narrowed types — it does not extend the base. This matches the pattern in
433
        // AdditionalPropertiesCompanion.phptpl. The base remains the fallback when no typed
434
        // companion is generated.
435
        $use = RenderHelper::filterClassImports(
12✔
436
            $isImmutable ? [] : ['Closure'],
12✔
437
            $namespace,
12✔
438
        );
12✔
439

440
        $this->writeAndRequireCompanionFile(
12✔
441
            $schema,
12✔
442
            $companionClassName,
12✔
443
            $namespace,
12✔
444
            join(DIRECTORY_SEPARATOR, ['Companion', 'UnevaluatedPropertiesCompanion.phptpl']),
12✔
445
            [
12✔
446
                'namespace' => $namespace,
12✔
447
                'use' => $use,
12✔
448
                'companionClassName' => $companionClassName,
12✔
449
                'immutable' => $isImmutable,
12✔
450
                'getReturnType' => $renderHelper->getTypeHintAnnotation($nullableProperty, true),
12✔
451
                'getNullablePhpType' => $renderHelper->getType($nullableProperty, true),
12✔
452
                'getAllReturnAnnotation' => $this->buildGetAllReturnAnnotation(
12✔
453
                    $renderHelper->getTypeHintAnnotation($validationProperty, true),
12✔
454
                ),
12✔
455
                'setParameterType' => $renderHelper->getType($validationProperty),
12✔
456
                'setParameterAnnotation' => $renderHelper->getTypeHintAnnotation($validationProperty),
12✔
457
            ],
12✔
458
        );
12✔
459
    }
460

461
    private function buildGetAllReturnAnnotation(string $typeAnnotation): string
12✔
462
    {
463
        // Wrap union types in parentheses so that e.g. 'DateTime|null' becomes '(DateTime|null)[]',
464
        // not 'DateTime[]|null[]' — the latter means "an array of nulls" to static analysers.
465
        if (str_contains($typeAnnotation, '|')) {
12✔
NEW
466
            return '(' . $typeAnnotation . ')[]';
×
467
        }
468

469
        return $typeAnnotation . '[]';
12✔
470
    }
471
}
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