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

satisfactory-dev / ajv-utilities / 25229246085

01 May 2026 07:21PM UTC coverage: 99.005% (+0.007%) from 98.998%
25229246085

push

github

SignpostMarv
implement inside-out checks

570 of 587 branches covered (97.1%)

Branch coverage included in aggregate %.

109 of 119 new or added lines in 1 file covered. (91.6%)

13 existing lines in 1 file now uncovered.

4702 of 4738 relevant lines covered (99.24%)

22388.43 hits per line

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

94.25
/src/TypeScriptify/preprocessors/CollectValidateCalls.ts
1
import type {
1✔
2
} from '@signpostmarv/js-types';
1✔
3

1✔
4
import type {
1✔
5
        BinaryExpression,
1✔
6
        CallExpression,
1✔
7
        ElementAccessExpression,
1✔
8
        Identifier,
1✔
9
        Node,
1✔
10
        NodeArray,
1✔
11
        ObjectLiteralExpression,
1✔
12
        PropertyAccessExpression,
1✔
13
        PropertyAssignment,
1✔
14
        ShorthandPropertyAssignment,
1✔
15
        StringLiteral,
1✔
16
} from 'typescript';
1✔
17
import {
1✔
18
        isBinaryExpression,
1✔
19
        isCallExpression,
1✔
20
        isElementAccessExpression,
1✔
21
        isFunctionDeclaration,
1✔
22
        isIdentifier,
1✔
23
        isObjectLiteralExpression,
1✔
24
        isPropertyAccessExpression,
1✔
25
        isPropertyAssignment,
1✔
26
        isShorthandPropertyAssignment,
1✔
27
        isStringLiteral,
1✔
28
        SyntaxKind,
1✔
29
} from 'typescript';
1✔
30

1✔
31
import {
1✔
32
        ConditionalPreprocessor,
1✔
33
} from '../abstracts.ts';
1✔
34

1✔
35
import type {
1✔
36
        Config,
1✔
37
        specify_type_nested,
1✔
38
        specify_type_with_nested,
1✔
39
        specify_types_instance,
1✔
40
        validate_call_argument_1_match,
1✔
41
} from '../types.ts';
1✔
42

1✔
43
import type {
1✔
44
        prepend_with_imports,
1✔
45
} from '../TypeReferences.ts';
1✔
46
import {
1✔
47
        to_string,
1✔
48
        Types,
1✔
49
} from '../TypeReferences.ts';
1✔
50

1✔
51
type CandidateBinaryExpressionString = (
1✔
52
        & BinaryExpression
1✔
53
        & {
1✔
54
                left: Identifier,
1✔
55
                operatorToken: {
1✔
56
                        kind: SyntaxKind.PlusToken,
1✔
57
                },
1✔
58
                right: StringLiteral,
1✔
59
        }
1✔
60
);
1✔
61

1✔
62
type CandidateBinaryExpressionStringConcat = (
1✔
63
        & BinaryExpression
1✔
64
        & {
1✔
65
                left: CandidateBinaryExpressionString,
1✔
66
                operatorToken: {
1✔
67
                        kind: SyntaxKind.PlusToken,
1✔
68
                },
1✔
69
                right: (
1✔
70
                        | Identifier
1✔
71
                        | PropertyAccessExpression
1✔
72
                        | CallExpression
1✔
73
                ),
1✔
74
        }
1✔
75
);
1✔
76

1✔
77
type instancePath_initializer = (
1✔
78
        | StringLiteral
1✔
79
        | CandidateBinaryExpressionString
1✔
80
        | CandidateBinaryExpressionStringConcat
1✔
81
);
1✔
82

1✔
83
type argument_1_property_instancePath = (
1✔
84
        & (
1✔
85
                | ShorthandPropertyAssignment
1✔
86
                | (
1✔
87
                        & PropertyAssignment
1✔
88
                        & {
1✔
89
                                initializer: instancePath_initializer,
1✔
90
                        }
1✔
91
                )
1✔
92
        )
1✔
93
        & {
1✔
94
                name: (
1✔
95
                        & Identifier
1✔
96
                        & {
1✔
97
                                text: 'instancePath',
1✔
98
                        }
1✔
99
                ),
1✔
100
        }
1✔
101
);
1✔
102

1✔
103
type Candidate = (
1✔
104
        & CallExpression
1✔
105
        & {
1✔
106
                expression: (
1✔
107
                        & Identifier
1✔
108
                        & {
1✔
109
                                text: `validate${number}`,
1✔
110
                        }
1✔
111
                ),
1✔
112
                arguments: (
1✔
113
                        & NodeArray<(
1✔
114
                                | Identifier
1✔
115
                                | PropertyAccessExpression
1✔
116
                                | ObjectLiteralExpression
1✔
117
                                | ElementAccessExpression
1✔
118
                        )>
1✔
119
                        & {
1✔
120
                                length: 2,
1✔
121
                                0: (
1✔
122
                                        | Identifier
1✔
123
                                        | PropertyAccessExpression
1✔
124
                                        | ElementAccessExpression
1✔
125
                                ),
1✔
126
                                1: (
1✔
127
                                        & ObjectLiteralExpression
1✔
128
                                        & {
1✔
129
                                                properties: (
1✔
130
                                                        & NodeArray<PropertyAssignment>
1✔
131
                                                        & {
1✔
132
                                                                length: 5,
1✔
133
                                                                0: argument_1_property_instancePath,
1✔
134
                                                                2: (
1✔
135
                                                                        & PropertyAssignment
1✔
136
                                                                        & {
1✔
137
                                                                                name: (
1✔
138
                                                                                        & Identifier
1✔
139
                                                                                        & {
1✔
140
                                                                                                text: 'parentDataProperty',
1✔
141
                                                                                        }
1✔
142
                                                                                ),
1✔
143
                                                                                initializer: (
1✔
144
                                                                                        | StringLiteral
1✔
145
                                                                                        | Identifier
1✔
146
                                                                                ),
1✔
147
                                                                        }
1✔
148
                                                                ),
1✔
149
                                                        }
1✔
150
                                                ),
1✔
151
                                        }
1✔
152
                                ),
1✔
153
                        }
1✔
154
                ),
1✔
155
        }
1✔
156
);
1✔
157

1✔
158
const unique_id = Symbol('unique_id');
1✔
159

1✔
160
export type ValidateCallInfo = {
1✔
161
        name: string,
1✔
162

1✔
163
        // we're using null here as a placeholder for stuff we're not supporting
1✔
164
        instancePath: (
1✔
165
                | null
1✔
166
                | [string]
1✔
167
                | [string | null, ...(string | null)[]]
1✔
168
        ),
1✔
169

1✔
170
        parentDataProperty: string | null,
1✔
171

1✔
172
        [unique_id]: string,
1✔
173
};
1✔
174

1✔
175
export default class CollectValidateCalls extends ConditionalPreprocessor<
1✔
176
        Candidate
1✔
177
> {
1✔
178
        constructor(validate_calls: {
1✔
179
                [key: string]: [
30✔
180
                        ValidateCallInfo,
30✔
181
                        ...ValidateCallInfo[],
30✔
182
                ],
30✔
183
        }) {
30✔
184
                super(
30✔
185
                        (maybe): maybe is Candidate => (
30✔
186
                                isCallExpression(maybe)
289,267✔
187
                                && isIdentifier(maybe.expression)
289,267✔
188
                                && this.validate_function_name.test(maybe.expression.text)
289,267✔
189
                                && 2 === maybe.arguments.length
289,267✔
190
                                && (
289,267✔
191
                                        isIdentifier(maybe.arguments[0])
77✔
192
                                        || isPropertyAccessExpression(maybe.arguments[0])
77✔
193
                                        || isElementAccessExpression(maybe.arguments[0])
77✔
194
                                )
77✔
195
                                && isObjectLiteralExpression(maybe.arguments[1])
289,267✔
196
                                && 5 === maybe.arguments[1].properties.length
289,267✔
197
                                && (
289,267✔
198
                                        (
77✔
199
                                                isShorthandPropertyAssignment(
77✔
200
                                                        maybe.arguments[1].properties[0],
77✔
201
                                                )
77✔
202
                                                || (
77✔
203
                                                        isPropertyAssignment(
62✔
204
                                                                maybe.arguments[1].properties[0],
62✔
205
                                                        )
62✔
206
                                                        && this.#is_instancePath_initializer(
62✔
207
                                                                maybe.arguments[
62✔
208
                                                                        1
62✔
209
                                                                ].properties[
62✔
210
                                                                        0
62✔
211
                                                                ].initializer,
62✔
212
                                                        )
62✔
213
                                                )
62✔
214
                                        )
77✔
215
                                        && isIdentifier(
77✔
216
                                                maybe.arguments[1].properties[0].name,
77✔
217
                                        )
77✔
218
                                        && 'instancePath' === maybe.arguments[
77✔
219
                                                1
77✔
220
                                        ].properties[
77✔
221
                                                0
77✔
222
                                        ].name.text
77✔
223
                                )
77✔
224
                                && isPropertyAssignment(maybe.arguments[1].properties[2])
289,267✔
225
                                && isIdentifier(maybe.arguments[1].properties[2].name)
289,267✔
226
                                && 'parentDataProperty' === (
289,267✔
227
                                        maybe.arguments[1].properties[2].name.text
62✔
228
                                )
62✔
229
                                && (
289,267✔
230
                                        isStringLiteral(
62✔
231
                                                maybe.arguments[1].properties[2].initializer,
62✔
232
                                        )
62✔
233
                                        || isIdentifier(
62✔
234
                                                maybe.arguments[1].properties[2].initializer,
18✔
235
                                        )
18✔
236
                                )
62✔
237
                        ),
30✔
238
                        (node) => {
30✔
239
                                let checking: Node | undefined = node.parent;
62✔
240

62✔
241
                                while (checking) {
62✔
242
                                        if (
674✔
243
                                                isFunctionDeclaration(checking)
674✔
244
                                                && !!checking.name
674✔
245
                                                && isIdentifier(checking.name)
674✔
246
                                                && this.validate_function_name.test(
674✔
247
                                                        checking.name.text,
62✔
248
                                                )
62✔
249
                                        ) {
674✔
250
                                                const called_in = checking.name.getText();
62✔
251

62✔
252
                                                let instancePath: ValidateCallInfo['instancePath'];
62✔
253

62✔
254
                                                if (isShorthandPropertyAssignment(
62✔
255
                                                        node.arguments[1].properties[0],
62✔
256
                                                )) {
62!
UNCOV
257
                                                        instancePath = null;
×
258
                                                } else if (isStringLiteral(
62✔
259
                                                        node.arguments[1].properties[0].initializer,
62✔
260
                                                )) {
62!
261
                                                        instancePath = [
×
262
                                                                node.arguments[
×
263
                                                                        1
×
264
                                                                ].properties[
×
265
                                                                        0
×
UNCOV
266
                                                                ].initializer.text,
×
UNCOV
267
                                                        ];
×
268
                                                } else {
62✔
269
                                                        // oxlint-disable-next-line @stylistic/max-len
62✔
270
                                                        instancePath = this.#unpack_CandidateBinaryExpression(
62✔
271
                                                                node.arguments[1].properties[0].initializer,
62✔
272
                                                        );
62✔
273
                                                }
62✔
274

62✔
275
                                                const info: Omit<
62✔
276
                                                        ValidateCallInfo,
62✔
277
                                                        typeof unique_id
62✔
278
                                                > = {
62✔
279
                                                        name: node.expression.text,
62✔
280
                                                        instancePath,
62✔
281
                                                        parentDataProperty: (
62✔
282
                                                                isStringLiteral(
62✔
283
                                                                        node.arguments[
62✔
284
                                                                                1
62✔
285
                                                                        ].properties[
62✔
286
                                                                                2
62✔
287
                                                                        ].initializer,
62✔
288
                                                                )
62✔
289
                                                                        ? node.arguments[
62✔
290
                                                                                1
44✔
291
                                                                        ].properties[
44✔
292
                                                                                2
44✔
293
                                                                        ].initializer.text
44✔
294
                                                                        : null
62✔
295
                                                        ),
62✔
296
                                                };
62✔
297

62✔
298
                                                const with_id: ValidateCallInfo = {
62✔
299
                                                        ...info,
62✔
300
                                                        [unique_id]: JSON.stringify(info),
62✔
301
                                                };
62✔
302

62✔
303
                                                if (!(called_in in validate_calls)) {
62✔
304
                                                        validate_calls[called_in] = [with_id];
35✔
305
                                                } else if (!validate_calls[called_in].find((
62✔
306
                                                        maybe,
47✔
307
                                                ) => maybe[unique_id] === with_id[unique_id])) {
27✔
308
                                                        validate_calls[called_in].push(with_id);
27✔
309
                                                }
27✔
310

62✔
311
                                                return true;
62✔
312
                                        }
62✔
313

612✔
314
                                        checking = checking.parent;
612✔
315
                                }
612!
UNCOV
316

×
UNCOV
317
                                return false;
×
318
                        },
30✔
319
                );
30✔
320
        }
30✔
321

1✔
322
        #is_CandidateBinaryExpressionString(
1✔
323
                maybe: Node,
108✔
324
        ): maybe is CandidateBinaryExpressionString {
108✔
325
                return (
108✔
326
                        isBinaryExpression(maybe)
108✔
327
                        && (
108✔
328
                                isIdentifier(maybe.left)
90✔
329
                                || this.#is_CandidateBinaryExpressionStringConcat(maybe.left)
90✔
330
                        )
90✔
331
                        && SyntaxKind.PlusToken === maybe.operatorToken.kind
108✔
332
                        && isStringLiteral(maybe.right)
108✔
333
                );
108✔
334
        }
108✔
335

1✔
336
        #is_CandidateBinaryExpressionStringConcat(
1✔
337
                maybe: Node,
46✔
338
        ): maybe is CandidateBinaryExpressionStringConcat {
46✔
339
                return (
46✔
340
                        isBinaryExpression(maybe)
46✔
341
                        && (
46✔
342
                                this.#is_CandidateBinaryExpressionString(maybe.left)
46✔
343
                        )
46✔
344
                        && SyntaxKind.PlusToken === maybe.operatorToken.kind
46✔
345
                        && (
46✔
346
                                isIdentifier(maybe.right)
28✔
347
                                || isPropertyAccessExpression(maybe.right)
28✔
348
                                || isCallExpression(maybe.right)
28✔
349
                        )
28✔
350
                );
46✔
351
        }
46✔
352

1✔
353
        #is_instancePath_initializer(
1✔
354
                maybe: Node,
62✔
355
        ): maybe is instancePath_initializer {
62✔
356
                return (
62✔
357
                        isStringLiteral(maybe)
62✔
358
                        || this.#is_CandidateBinaryExpressionString(maybe)
62✔
359
                        || this.#is_CandidateBinaryExpressionStringConcat(maybe)
62✔
360
                );
62✔
361
        }
62✔
362

1✔
363
        #unpack_CandidateBinaryExpression(
1✔
364
                from: (
100✔
365
                        | CandidateBinaryExpressionString
100✔
366
                        | CandidateBinaryExpressionStringConcat
100✔
367
                ),
100✔
368
                current: (null | string)[] = [],
100✔
369
        ): [
100✔
370
                (string | null),
100✔
371
                ...(string | null)[],
100✔
372
        ] {
100✔
373
                if (isIdentifier(from.left)) {
100✔
374
                        return [
62✔
375
                                null,
62✔
376
                                (
62✔
377
                                        isStringLiteral(from.right)
62✔
378
                                                ? from.right.text
62✔
379
                                                : null
62!
380
                                ),
62✔
381
                                ...current,
62✔
382
                        ];
62✔
383
                }
62✔
384

38✔
385
                return this.#unpack_CandidateBinaryExpression(from.left, [null]);
38✔
386
        }
100✔
387

1✔
388
        static specify_types_from_collected(
1✔
389
                info: {
7✔
390
                        [key: string]: [
7✔
391
                                ValidateCallInfo,
7✔
392
                                ...ValidateCallInfo[],
7✔
393
                        ],
7✔
394
                },
7✔
395
                config: Partial<Config>,
7✔
396
                existing: specify_types_instance,
7✔
397
                prepend_with_imports: prepend_with_imports,
7✔
398
        ) {
7✔
399
                this.#specify_types_from_collected_outside_in(
7✔
400
                        info,
7✔
401
                        config,
7✔
402
                        existing,
7✔
403
                        prepend_with_imports,
7✔
404
                );
7✔
405
                this.#specify_types_from_collected_inside_out(
7✔
406
                        info,
7✔
407
                        config,
7✔
408
                        existing,
7✔
409
                        prepend_with_imports,
7✔
410
                );
7✔
411
        }
7✔
412

1✔
413
        static #specify_types_from_collected_outside_in(
1✔
414
                info: {
7✔
415
                        [key: string]: [
7✔
416
                                ValidateCallInfo,
7✔
417
                                ...ValidateCallInfo[],
7✔
418
                        ],
7✔
419
                },
7✔
420
                config: Partial<Config>,
7✔
421
                existing: specify_types_instance,
7✔
422
                prepend_with_imports: prepend_with_imports,
7✔
423
        ) {
7✔
424
                const configurable = Object.entries(config.specify_types || {})
7!
425
                        .filter((maybe): maybe is [
7✔
426
                                string,
8✔
427
                                specify_type_with_nested,
8✔
428
                        ] => 3 === maybe[1].length)
7✔
429
                        .map(([key, not_object]): [
7✔
430
                                string,
1✔
431
                                [
1✔
432
                                        ReturnType<(typeof Types)['toObject']>,
1✔
433
                                        (typeof not_object)[1],
1✔
434
                                        (typeof not_object)[2],
1✔
435
                                        string,
1✔
436
                                ],
1✔
437
                        ] => {
1✔
438
                                const as_object = Types.toObject(not_object[0]);
1✔
439

1✔
440
                                return [
1✔
441
                                        key,
1✔
442
                                        [
1✔
443
                                                as_object,
1✔
444
                                                not_object[1],
1✔
445
                                                not_object[2],
1✔
446
                                                to_string(as_object),
1✔
447
                                        ],
1✔
448
                                ];
1✔
449
                        });
7✔
450

7✔
451
                if (configurable.length < 1) {
7✔
452
                        return;
6✔
453
                }
6✔
454

1✔
455
                const existing_entries = Object.entries(existing);
1✔
456

1✔
457
                for (const [
1✔
458
                        ,
1✔
459
                        [
1✔
460
                                ,,
1✔
461
                                sub_types,
1✔
462
                                as_string,
1✔
463
                        ],
1✔
464
                ] of configurable) {
1✔
465
                        const found = existing_entries.find((maybe) => (
1✔
466
                                to_string(maybe[1]) === as_string
1✔
467
                        ));
1✔
468

1✔
469
                        if (!found) {
1!
UNCOV
470
                                continue;
×
UNCOV
471
                        }
×
472

1✔
473
                        const [function_name] = found;
1✔
474

1✔
475
                        if (!(function_name in info)) {
1!
UNCOV
476
                                continue;
×
UNCOV
477
                        }
×
478

1✔
479
                        this.#specify_types_from_collected_outside_in_deep_dive(
1✔
480
                                info,
1✔
481
                                function_name,
1✔
482
                                sub_types,
1✔
483
                                existing,
1✔
484
                                prepend_with_imports,
1✔
485
                        );
1✔
486
                }
1✔
487
        }
7✔
488

1✔
489
        static #specify_types_from_collected_inside_out(
1✔
490
                info: {
7✔
491
                        [key: string]: [
7✔
492
                                ValidateCallInfo,
7✔
493
                                ...ValidateCallInfo[],
7✔
494
                        ],
7✔
495
                },
7✔
496
                config: Partial<Config>,
7✔
497
                existing: specify_types_instance,
7✔
498
                prepend_with_imports: prepend_with_imports,
7✔
499
        ) {
7✔
500
                const configurable = config.specify_types_by_inside_out_match || [];
7✔
501

7✔
502
                if (configurable.length < 1) {
7✔
503
                        return;
6✔
504
                }
6✔
505

1✔
506
                const info_entries = Object.entries(info);
1✔
507

1✔
508
                for (const [
1✔
509
                        sub_type,
1✔
510
                        source,
1✔
511
                        match_with,
1✔
512
                        parent_is,
1✔
513
                ] of configurable) {
1✔
514
                        for (const [parent_function, has_calls] of info_entries) {
1✔
515
                                const checking = this.#filter_info(
7✔
516
                                        match_with,
7✔
517
                                        has_calls,
7✔
518
                                );
7✔
519

7✔
520
                                if (1 === checking.length) {
7✔
521
                                        if (!(source in prepend_with_imports)) {
1!
NEW
522
                                                prepend_with_imports[source] = new Types();
×
NEW
523
                                        }
×
524

1✔
525
                                        if (!(parent_is[1] in prepend_with_imports)) {
1!
NEW
526
                                                prepend_with_imports[parent_is[1]] = new Types();
×
NEW
527
                                        }
×
528

1✔
529
                                        existing[checking[0].name] = prepend_with_imports[
1✔
530
                                                source
1✔
531
                                        ].add(sub_type);
1✔
532

1✔
533
                                        existing[parent_function] = prepend_with_imports[
1✔
534
                                                parent_is[1]
1✔
535
                                        ].add(parent_is[0]);
1✔
536
                                }
1✔
537
                        }
7✔
538
                }
1✔
539
        }
7✔
540

1✔
541
        static #filter_info(
1✔
542
                {
14✔
543
                        instancePath,
14✔
544
                        instancePath_partial,
14✔
545
                        parentDataProperty,
14✔
546
                }: validate_call_argument_1_match,
14✔
547
                checking: ValidateCallInfo[],
14✔
548
        ) {
14✔
549
                if (undefined !== instancePath_partial) {
14✔
550
                        checking = checking.filter((maybe) => (
14✔
551
                                maybe.instancePath
33✔
552
                                && maybe.instancePath.includes(
33✔
553
                                        instancePath_partial,
33✔
554
                                )
33✔
555
                        ));
14✔
556
                } else if (undefined !== instancePath) {
14!
NEW
557
                        checking = checking.filter((maybe) => (
×
NEW
558
                                maybe.instancePath
×
NEW
559
                                && 1 === maybe.instancePath.length
×
NEW
560
                                && maybe.instancePath[0] === instancePath
×
NEW
561
                        ));
×
NEW
562
                }
×
563

14✔
564
                if (parentDataProperty) {
14✔
565
                        checking = checking.filter((maybe) => (
11✔
566
                                maybe.parentDataProperty === parentDataProperty
5✔
567
                        ));
11✔
568
                }
11✔
569

14✔
570
                return checking;
14✔
571
        }
14✔
572

1✔
573
        static #specify_types_from_collected_outside_in_deep_dive(
1✔
574
                info: {
4✔
575
                        [key: string]: [
4✔
576
                                ValidateCallInfo,
4✔
577
                                ...ValidateCallInfo[],
4✔
578
                        ],
4✔
579
                },
4✔
580
                function_name: keyof typeof info,
4✔
581
                sub_types: [specify_type_nested, ...specify_type_nested[]],
4✔
582
                existing: specify_types_instance,
4✔
583
                prepend_with_imports: prepend_with_imports,
4✔
584
        ) {
4✔
585
                for (const sub_type of sub_types) {
4✔
586
                        const [,, match_with] = sub_type;
7✔
587

7✔
588
                        const checking = this.#filter_info(
7✔
589
                                match_with,
7✔
590
                                info[function_name],
7✔
591
                        );
7✔
592

7✔
593
                        if (1 === checking.length) {
7✔
594
                                if (!(sub_type[1] in prepend_with_imports)) {
7!
UNCOV
595
                                        prepend_with_imports[sub_type[1]] = new Types();
×
UNCOV
596
                                }
×
597

7✔
598
                                existing[checking[0].name] = prepend_with_imports[
7✔
599
                                        sub_type[1]
7✔
600
                                ].add(sub_type[0]);
7✔
601

7✔
602
                                if (4 === sub_type.length && checking[0].name in info) {
7✔
603
                                        this.#specify_types_from_collected_outside_in_deep_dive(
3✔
604
                                                info,
3✔
605
                                                checking[0].name,
3✔
606
                                                sub_type[3],
3✔
607
                                                existing,
3✔
608
                                                prepend_with_imports,
3✔
609
                                        );
3✔
610
                                }
3✔
611
                        } else if (checking.length > 0) {
7!
UNCOV
612
                                throw new Error('Unexpected matches found!');
×
UNCOV
613
                        }
×
614
                }
7✔
615
        }
4✔
616
}
1✔
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