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

rokucommunity / brighterscript / #15701

29 Apr 2026 03:03PM UTC coverage: 88.947% (-0.2%) from 89.135%
#15701

push

web-flow
added source fix all code action support (#1659)

Co-authored-by: Bronley Plumb <bronley@gmail.com>

8399 of 9942 branches covered (84.48%)

Branch coverage included in aggregate %.

32 of 63 new or added lines in 9 files covered. (50.79%)

1 existing line in 1 file now uncovered.

10673 of 11500 relevant lines covered (92.81%)

2034.52 hits per line

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

84.48
/src/lsp/Project.ts
1
import { ProgramBuilder } from '../ProgramBuilder';
1✔
2
import * as EventEmitter from 'eventemitter3';
1✔
3
import util, { standardizePath as s } from '../util';
1✔
4
import * as path from 'path';
1✔
5
import type { ProjectConfig, ActivateResponse, LspDiagnostic, LspProject } from './LspProject';
6
import type { Plugin, Hover, MaybePromise } from '../interfaces';
7
import { DiagnosticMessages } from '../DiagnosticMessages';
1✔
8
import { URI } from 'vscode-uri';
1✔
9
import { Deferred } from '../deferred';
1✔
10
import type { StandardizedFileEntry } from 'roku-deploy';
11
import { rokuDeploy } from 'roku-deploy';
1✔
12
import type { DocumentSymbol, Position, Range, Location, WorkspaceSymbol } from 'vscode-languageserver-protocol';
13
import { CompletionList } from 'vscode-languageserver-protocol';
1✔
14
import { CancellationTokenSource } from 'vscode-languageserver-protocol';
1✔
15
import type { DocumentAction, DocumentActionWithStatus } from './DocumentManager';
16
import type { SignatureInfoObj } from '../Program';
17
import type { BsConfig } from '../BsConfig';
18
import type { Logger, LogLevel } from '../logging';
19
import { createLogger } from '../logging';
1✔
20
import * as fsExtra from 'fs-extra';
1✔
21

22
export class Project implements LspProject {
1✔
23
    public constructor(
24
        options?: {
25
            logger?: Logger;
26
        }
27
    ) {
28
        this.logger = options?.logger ?? createLogger({
154✔
29
            //when running inside a worker thread, we don't want to use colors
30
            enableColor: false
31
        });
32
    }
33

34
    /**
35
     * Activates this project. Every call to `activate` should completely reset the project, clear all used ram and start from scratch.
36
     */
37
    public async activate(options: ProjectConfig): Promise<ActivateResponse> {
38
        this.logger.info(`Project.activate. projectKey: ${options.projectKey}`);
122✔
39

40
        this.activateOptions = options;
122✔
41
        this.projectKey = options.projectKey ? util.standardizePath(options.projectKey) : options.projectKey;
122✔
42
        this.projectDir = options.projectDir ? util.standardizePath(options.projectDir) : options.projectDir;
122!
43
        this.workspaceFolder = options.workspaceFolder ? util.standardizePath(options.workspaceFolder) : options.workspaceFolder;
122✔
44
        this.projectNumber = options.projectNumber;
122✔
45
        this.bsconfigPath = await this.getConfigFilePath(options);
122✔
46

47
        this.builder = new ProgramBuilder({
122✔
48
            //share our logger with ProgramBuilder, which will keep our level in sync with the one in the program
49
            logger: this.logger
50
        });
51

52
        this.builder.logger.prefix = util.getProjectLogName(this);
122✔
53
        this.disposables.push(this.builder);
122✔
54

55
        let cwd: string;
56
        //if the config file exists, use it and its folder as cwd
57
        if (this.bsconfigPath && await util.pathExists(this.bsconfigPath)) {
122✔
58
            cwd = path.dirname(this.bsconfigPath);
57✔
59
            //load the bsconfig file contents (used for performance optimizations externally)
60
            try {
57✔
61
                this.bsconfigFileContents = (await fsExtra.readFile(this.bsconfigPath)).toString();
57✔
62
            } catch { }
63

64
        } else {
65
            cwd = this.projectDir;
65✔
66
            //config file doesn't exist...let `brighterscript` resolve the default way
67
            this.bsconfigPath = undefined;
65✔
68
        }
69

70
        const builderOptions = {
122✔
71
            cwd: cwd,
72
            project: this.bsconfigPath,
73
            watch: false,
74
            createPackage: false,
75
            deploy: false,
76
            copyToStaging: false,
77
            showDiagnosticsInConsole: false,
78
            validate: false
79
        } as BsConfig;
80

81
        //Assign .files (mostly used for standalone projects) if available, as a dedicated assignment because `undefined` overrides the default value in the `bsconfig.json`
82
        if (options.files) {
122✔
83
            builderOptions.files = rokuDeploy.normalizeFilesArray(options.files);
5✔
84
        }
85

86
        //run the builder to initialize the program. Skip validation for now, we'll trigger it soon in a more cancellable way
87
        await this.builder.run({
122✔
88
            ...builderOptions,
89
            validate: false,
90
            //don't show diagnostics in the console since this is run via the language server, they're presented in a different way
91
            showDiagnosticsInConsole: false
92
        });
93

94
        //flush diagnostics every time the program finishes validating
95
        //this plugin must be added LAST to the program to ensure we can see all diagnostics
96
        this.builder.plugins.add({
122✔
97
            name: 'bsc-language-server',
98
            afterProgramValidate: () => {
99
                const diagnostics = this.getDiagnostics();
153✔
100
                this.emit('diagnostics', {
153✔
101
                    diagnostics: diagnostics
102
                });
103
            }
104
        } as Plugin);
105

106
        //if we found a deprecated brsconfig.json, add a diagnostic warning the user
107
        if (this.bsconfigPath && path.basename(this.bsconfigPath) === 'brsconfig.json') {
122✔
108
            this.builder.addDiagnostic(this.bsconfigPath, {
1✔
109
                ...DiagnosticMessages.brsConfigJsonIsDeprecated(),
110
                range: util.createRange(0, 0, 0, 0)
111
            });
112
        }
113

114
        this.activationDeferred.resolve();
122✔
115

116
        return {
122✔
117
            bsconfigPath: this.bsconfigPath,
118
            logLevel: this.builder.program.options.logLevel as LogLevel,
119
            rootDir: this.builder.program.options.rootDir,
120
            filePatterns: this.filePatterns
121
        };
122
    }
123

124
    public isStandaloneProject = false;
154✔
125

126
    public logger: Logger;
127

128
    /**
129
     * Options used to activate this project
130
     */
131
    public activateOptions: ProjectConfig;
132

133
    public get rootDir() {
134
        return this.builder.program.options.rootDir;
150✔
135
    }
136
    /**
137
     * The file patterns from bsconfig.json that were used to find all files for this project
138
     */
139
    public get filePatterns() {
140
        if (!this.builder) {
272!
141
            return undefined;
×
142
        }
143
        const patterns = rokuDeploy.normalizeFilesArray(this.builder.program.options.files);
272✔
144
        return patterns.map(x => {
272✔
145
            return typeof x === 'string' ? x : x.src;
953✔
146
        });
147
    }
148

149
    /**
150
     * Gets resolved when the project has finished activating
151
     */
152
    private activationDeferred = new Deferred();
154✔
153

154
    /**
155
     * Promise that resolves when the project finishes activating
156
     * @returns a promise that resolves when the project finishes activating
157
     */
158
    public whenActivated() {
159
        return this.activationDeferred.promise;
215✔
160
    }
161

162
    private validationCancelToken: CancellationTokenSource;
163

164
    /**
165
     * Validate the project. This will trigger a full validation on any scopes that were changed since the last validation,
166
     * and will also eventually emit a new 'diagnostics' event that includes all diagnostics for the project.
167
     *
168
     * This will cancel any currently running validation and then run a new one.
169
     */
170
    public async validate() {
171
        this.logger.debug('Project.validate');
160✔
172

173
        this.cancelValidate();
160✔
174
        //store
175
        this.validationCancelToken = new CancellationTokenSource();
160✔
176

177
        try {
160✔
178
            this.emit('validate-begin', {});
160✔
179
            await this.builder.program.validate({
160✔
180
                async: true,
181
                cancellationToken: this.validationCancelToken.token
182
            });
183
        } finally {
184
            this.emit('validate-end', {});
160✔
185
        }
186
    }
187

188
    /**
189
     * Cancel any active running validation
190
     */
191
    public cancelValidate() {
192
        this.validationCancelToken?.cancel();
315✔
193
        delete this.validationCancelToken;
315✔
194
    }
195

196
    public getDiagnostics() {
197
        const diagnostics = this.builder.getDiagnostics();
156✔
198
        return diagnostics.map(x => {
156✔
199
            const uri = URI.file(x.file.srcPath).toString();
111✔
200
            return {
111✔
201
                ...util.toDiagnostic(x, uri),
202
                uri: uri
203
            };
204
        });
205
    }
206

207
    /**
208
     * Promise that resolves the next time the system is idle. If the system is already idle, it will resolve immediately
209
     */
210
    private async onIdle(): Promise<void> {
211
        await Promise.all([
73✔
212
            this.activationDeferred.promise
213
        ]);
214
    }
215

216
    /**
217
     * Add or replace the in-memory contents of the file at the specified path. This is typically called as the user is typing.
218
     * This will cancel any pending validation cycles and queue a future validation cycle instead.
219
     */
220
    public async applyFileChanges(documentActions: DocumentAction[]): Promise<DocumentActionWithStatus[]> {
221
        this.logger.debug('Project.applyFileChanges', documentActions.map(x => x.srcPath));
75✔
222

223
        await this.onIdle();
45✔
224

225
        let didChangeFiles = false;
45✔
226
        const result = [...documentActions] as DocumentActionWithStatus[];
45✔
227
        // eslint-disable-next-line @typescript-eslint/prefer-for-of
228
        for (let i = 0; i < result.length; i++) {
45✔
229
            const action = result[i];
78✔
230
            let didChangeThisFile = false;
78✔
231
            //if this is a `set` and the file matches the project's files array, set it
232
            if (action.type === 'set' && Project.willAcceptFile(action.srcPath, this.builder.program.options.files, this.builder.program.options.rootDir)) {
78✔
233
                //load the file contents from disk if we don't have an in memory copy
234
                const fileContents = action.fileContents ?? util.readFileSync(action.srcPath)?.toString();
69✔
235

236
                //if we got file contents, set the file on the program
237
                if (fileContents !== undefined) {
69✔
238
                    didChangeThisFile = this.setFile(action.srcPath, fileContents);
66✔
239
                    //this file was accepted by the program
240
                    action.status = 'accepted';
66✔
241

242
                    //if we can't get file contents, apply this as a delete
243
                } else {
244
                    action.status = 'accepted';
3✔
245
                    result.push({
3✔
246
                        id: action.id,
247
                        srcPath: action.srcPath,
248
                        type: 'delete',
249
                        status: undefined,
250
                        allowStandaloneProject: false
251
                    });
252
                    continue;
3✔
253
                }
254

255
                //try to delete the file or directory
256
            } else if (action.type === 'delete') {
9✔
257
                didChangeThisFile = this.removeFileOrDirectory(action.srcPath);
6✔
258
                //if we deleted at least one file, mark this action as accepted
259
                action.status = didChangeThisFile ? 'accepted' : 'rejected';
6✔
260

261
                //we did not handle this action, so reject
262
            } else {
263
                action.status = 'rejected';
3✔
264
            }
265
            didChangeFiles = didChangeFiles || didChangeThisFile;
75✔
266
        }
267
        if (didChangeFiles) {
45✔
268
            //trigger a validation (but don't wait for it. That way we can cancel it sooner if we get new incoming data or requests)
269
            this.validate().catch(e => this.logger.error(e));
36✔
270
        }
271

272
        this.logger.debug('project.applyFileChanges done', documentActions.map(x => x.srcPath));
75✔
273

274
        return result;
45✔
275
    }
276

277
    /**
278
     * Determine if this project will accept the file at the specified path (i.e. does it match a pattern in the project's files array)
279
     */
280
    public static willAcceptFile(srcPath: string, files: BsConfig['files'], rootDir: string) {
281
        srcPath = util.standardizePath(srcPath);
72✔
282
        if (rokuDeploy.getDestPath(srcPath, files, rootDir) !== undefined) {
72✔
283
            return true;
69✔
284
            //is this exact path in the `files` array? (this check is mostly for standalone projects)
285
        } else if ((files as StandardizedFileEntry[]).find(x => s`${x.src}` === srcPath)) {
4!
286
            return true;
×
287
        }
288
        return false;
3✔
289
    }
290

291
    /**
292
     * Set new contents for a file. This is safe to call any time. Changes will be queued and flushed at the correct times
293
     * during the program's lifecycle flow
294
     * @param srcPath absolute source path of the file
295
     * @param fileContents the text contents of the file
296
     * @returns true if this program accepted and added the file. false if the program didn't want the file, or if the contents didn't change
297
     */
298
    private setFile(srcPath: string, fileContents: string) {
299
        this.logger.debug('Project.setFile', { srcPath: srcPath, fileContentsLength: fileContents.length });
66✔
300

301
        const { files, rootDir } = this.builder.program.options;
66✔
302

303
        //get the dest path for this file.
304
        let destPath = rokuDeploy.getDestPath(srcPath, files, rootDir);
66✔
305

306
        //if we have a file and the contents haven't changed
307
        let file = this.builder.program.getFile(destPath);
66✔
308
        if (file && file.fileContents === fileContents) {
66✔
309
            return false;
7✔
310
        }
311

312
        //if we got a dest path, then the program wants this file
313
        if (destPath) {
59!
314
            this.builder.program.setFile(
59✔
315
                {
316
                    src: srcPath,
317
                    dest: destPath
318
                },
319
                fileContents
320
            );
321
            return true;
59✔
322
        }
323
        return false;
×
324
    }
325

326
    /**
327
     * Remove the in-memory file at the specified path. This is typically called when the user (or file system watcher) triggers a file delete
328
     * @param srcPath absolute path to the File
329
     * @returns true if we found and removed at least one file, or false if no files were removed
330
     */
331
    private removeFileOrDirectory(srcPath: string) {
332
        this.logger.debug('Project.removeFileOrDirectory', srcPath);
6✔
333

334
        srcPath = util.standardizePath(srcPath);
6✔
335
        //if this is a direct file match, remove the file
336
        if (this.builder.program.hasFile(srcPath)) {
6✔
337
            this.builder.program.removeFile(srcPath);
2✔
338
            return true;
2✔
339
        }
340

341
        //maybe this is a directory. Remove all files that start with this path
342
        let removedSomeFiles = false;
4✔
343
        let lowerSrcPath = srcPath.toLowerCase();
4✔
344
        for (let file of Object.values(this.builder.program.files)) {
4✔
345
            //if the file path starts with the parent path and the file path does not exactly match the folder path
346
            if (file.srcPath?.toLowerCase().startsWith(lowerSrcPath)) {
4!
347
                this.logger.debug('Project.removeFileOrDirectory removing file because it matches the given directory', { dir: srcPath, srcPath: file.srcPath });
3✔
348

349
                this.builder.program.removeFile(file.srcPath, false);
3✔
350
                removedSomeFiles = true;
3✔
351
            }
352
        }
353
        //return true if we removed at least one file
354
        return removedSomeFiles;
4✔
355
    }
356

357
    /**
358
     * Get the full list of semantic tokens for the given file path
359
     * @param options options for getting semantic tokens
360
     * @param options.srcPath absolute path to the source file
361
     */
362
    public async getSemanticTokens(options: { srcPath: string }) {
363
        await this.onIdle();
1✔
364
        if (this.builder.program.hasFile(options.srcPath)) {
1!
365
            return this.builder.program.getSemanticTokens(options.srcPath);
1✔
366
        }
367
    }
368

369
    public async transpileFile(options: { srcPath: string }) {
370
        await this.onIdle();
2✔
371
        if (this.builder.program.hasFile(options.srcPath)) {
2!
372
            return this.builder.program.getTranspiledFileContents(options.srcPath);
2✔
373
        }
374
    }
375

376
    public async getHover(options: { srcPath: string; position: Position }): Promise<Hover[]> {
377
        await this.onIdle();
×
378
        if (this.builder.program.hasFile(options.srcPath)) {
×
379
            return this.builder.program.getHover(options.srcPath, options.position);
×
380
        }
381
    }
382

383
    public async getDefinition(options: { srcPath: string; position: Position }): Promise<Location[]> {
384
        await this.onIdle();
5✔
385
        if (this.builder.program.hasFile(options.srcPath)) {
5!
386
            return this.builder.program.getDefinition(options.srcPath, options.position);
5✔
387
        }
388
    }
389

390
    public async getSignatureHelp(options: { srcPath: string; position: Position }): Promise<SignatureInfoObj[]> {
391
        await this.onIdle();
4✔
392
        if (this.builder.program.hasFile(options.srcPath)) {
4!
393
            return this.builder.program.getSignatureHelp(options.srcPath, options.position);
4✔
394
        }
395
    }
396

397
    public async getDocumentSymbol(options: { srcPath: string }): Promise<DocumentSymbol[]> {
398
        await this.onIdle();
6✔
399
        if (this.builder.program.hasFile(options.srcPath)) {
6!
400
            return this.builder.program.getDocumentSymbols(options.srcPath);
6✔
401
        }
402
    }
403

404
    public async getWorkspaceSymbol(): Promise<WorkspaceSymbol[]> {
405
        await this.onIdle();
4✔
406
        const result = this.builder.program.getWorkspaceSymbols();
4✔
407
        return result;
4✔
408
    }
409

410
    public async getReferences(options: { srcPath: string; position: Position }) {
411
        await this.onIdle();
3✔
412
        if (this.builder.program.hasFile(options.srcPath)) {
3!
413
            return this.builder.program.getReferences(options.srcPath, options.position);
3✔
414
        }
415
    }
416

417
    public async getSelectionRanges(options: { srcPath: string; positions: Position[] }) {
418
        await this.onIdle();
×
419
        if (this.builder.program.hasFile(options.srcPath)) {
×
420
            return this.builder.program.getSelectionRanges(options.srcPath, options.positions);
×
421
        }
422
    }
423

424
    public async getCodeActions(options: { srcPath: string; range: Range }) {
425
        await this.onIdle();
×
426

427
        if (this.builder.program.hasFile(options.srcPath)) {
×
428
            const codeActions = this.builder.program.getCodeActions(options.srcPath, options.range);
×
429
            //clone each diagnostic since certain diagnostics can have circular reference properties that kill the language server if serialized
430
            for (const codeAction of codeActions ?? []) {
×
431
                if (codeAction.diagnostics) {
×
432
                    codeAction.diagnostics = codeAction.diagnostics?.map(x => util.toDiagnostic(x, options.srcPath));
×
433
                }
434
            }
435
            return codeActions;
×
436
        }
437
    }
438

439
    public async getSourceFixAllCodeActions(options: { srcPath: string }) {
440
        await this.onIdle();
2✔
441

442
        if (this.builder.program.hasFile(options.srcPath)) {
2!
NEW
443
            return this.builder.program.getSourceFixAllCodeActions(options.srcPath);
×
444
        }
445
    }
446

447
    public async getCompletions(options: { srcPath: string; position: Position }): Promise<CompletionList> {
448
        await this.onIdle();
1✔
449

450
        this.logger.debug('Project.getCompletions', options.srcPath, options.position);
1✔
451

452
        if (this.builder.program.hasFile(options.srcPath)) {
1!
453
            const completions = this.builder.program.getCompletions(options.srcPath, options.position);
1✔
454
            const result = CompletionList.create(completions);
1✔
455
            result.itemDefaults = {
1✔
456
                commitCharacters: ['.']
457
            };
458
            return result;
1✔
459
        }
460
        return undefined;
×
461
    }
462

463
    /**
464
     * Manages the BrighterScript program. The main interface into the compiler/validator
465
     */
466
    private builder: ProgramBuilder;
467

468
    /**
469
     * A unique key to represent this project. The format of this key may change, but it will always be unique to this project and can be used for comparison purposes.
470
     *
471
     * For directory-only projects, this is the path to the dir. For bsconfig.json projects, this is the path to the config file (typically bsconfig.json).
472
     */
473
    projectKey: string;
474

475
    /**
476
     * The directory for the root of this project (typically where the bsconfig.json or manifest is located)
477
     */
478
    projectDir: string;
479

480
    /**
481
     * A unique number for this project, generated during this current language server session. Mostly used so we can identify which project is doing logging
482
     */
483
    public projectNumber: number;
484

485
    /**
486
     * The path to the workspace where this project resides. A workspace can have multiple projects (by adding a bsconfig.json to each folder).
487
     * Defaults to `.projectPath` if not set
488
     */
489
    public workspaceFolder: string;
490

491
    /**
492
     * Path to a bsconfig.json file that will be used for this project
493
     */
494
    public bsconfigPath?: string;
495

496
    /**
497
     * The contents of the bsconfig.json file. This is used to detect when the bsconfig file has not actually been changed (even if the fs says it did).
498
     *
499
     * Only available after `.activate()` has completed.
500
     * @deprecated do not depend on this property. This will certainly be removed in a future release
501
     */
502
    public bsconfigFileContents?: string;
503

504
    /**
505
     * Find the path to the bsconfig.json file for this project
506
     * @returns path to bsconfig.json, or undefined if unable to find it
507
     */
508
    private async getConfigFilePath(config: { bsconfigPath: string; projectDir: string }) {
509
        let bsconfigPath: string;
510
        //if there's a setting, we need to find the file or show error if it can't be found
511
        if (config?.bsconfigPath) {
125✔
512
            bsconfigPath = path.resolve(config.projectDir, config.bsconfigPath);
55✔
513
            if (await util.pathExists(bsconfigPath)) {
55✔
514
                return util.standardizePath(bsconfigPath);
54✔
515
            } else {
516
                this.emit('critical-failure', {
1✔
517
                    message: `Cannot find config file specified in user or workspace settings at '${bsconfigPath}'`
518
                });
519
            }
520
        }
521

522
        //the rest of these require a path to a project directory, so return early if we don't have one
523
        if (!config?.projectDir) {
71✔
524
            return undefined;
1✔
525
        }
526

527
        //default to config file path found in the root of the workspace
528
        bsconfigPath = s`${config.projectDir}/bsconfig.json`;
70✔
529
        if (await util.pathExists(bsconfigPath)) {
70✔
530
            return util.standardizePath(bsconfigPath);
3✔
531
        }
532

533
        //look for the deprecated `brsconfig.json` file
534
        bsconfigPath = s`${config.projectDir}/brsconfig.json`;
67✔
535
        if (await util.pathExists(bsconfigPath)) {
67✔
536
            return util.standardizePath(bsconfigPath);
1✔
537
        }
538

539
        //no config file could be found
540
        return undefined;
66✔
541
    }
542

543
    public on(eventName: 'validate-begin', handler: (data: any) => MaybePromise<void>);
544
    public on(eventName: 'validate-end', handler: (data: any) => MaybePromise<void>);
545
    public on(eventName: 'critical-failure', handler: (data: { message: string }) => MaybePromise<void>);
546
    public on(eventName: 'diagnostics', handler: (data: { diagnostics: LspDiagnostic[] }) => MaybePromise<void>);
547
    public on(eventName: 'all', handler: (eventName: string, data: any) => MaybePromise<void>);
548
    public on(eventName: string, handler: (...args: any[]) => MaybePromise<void>) {
549
        this.emitter.on(eventName, handler as any);
142✔
550
        return () => {
142✔
551
            this.emitter.removeListener(eventName, handler as any);
1✔
552
        };
553
    }
554

555
    private emit(eventName: 'validate-begin', data: any);
556
    private emit(eventName: 'validate-end', data: any);
557
    private emit(eventName: 'critical-failure', data: { message: string });
558
    private emit(eventName: 'diagnostics', data: { diagnostics: LspDiagnostic[] });
559
    private async emit(eventName: string, data?) {
560
        //emit these events on next tick, otherwise they will be processed immediately which could cause issues
561
        await util.sleep(0);
477✔
562
        this.emitter.emit(eventName, data);
477✔
563
        //emit the 'all' event
564
        this.emitter.emit('all', eventName, data);
477✔
565
    }
566
    private emitter = new EventEmitter();
154✔
567

568
    public disposables: LspProject['disposables'] = [];
154✔
569

570
    public dispose() {
571
        this.cancelValidate();
155✔
572
        for (let disposable of this.disposables ?? []) {
155!
573
            disposable?.dispose?.();
234!
574
        }
575
        this.disposables = [];
155✔
576

577
        this.emitter?.removeAllListeners();
155!
578
        if (this.activationDeferred?.isCompleted === false) {
155!
579
            this.activationDeferred.reject(
31✔
580
                new Error('Project was disposed, activation has been cancelled')
581
            );
582
        }
583
    }
584
}
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