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

source-academy / py-slang / 29927944717

22 Jul 2026 02:18PM UTC coverage: 86.428% (+0.2%) from 86.179%
29927944717

push

github

web-flow
Keep integers out of the module interface; unify pair/array; fix py2js DataType.ARRAY handling (#307)

* Keep integers out of the module interface

Squashes the full history of this branch's INTEGER work into one commit -
the intermediate commits explored tagging a Python int as DataType.INTEGER
across the module boundary and coercing it back to NUMBER at various points
(scalar args/returns, then array elements), based on an initial misreading
of what was needed. After discussion with Martin, the actual, final design
is simpler and is what this commit leaves in place:

Martin, explicit and final: "'would print 1.0, not 1' is a feature, not a
bug. We should keep integers out of modules." binary_tree's entry() printing
1.0 for a tree built from a Python int is the correct, intended behavior.
A Python int silently becomes DataType.NUMBER (a float) the moment it
crosses into any module, unconditionally - CSE/py2js/PVML's pythonToModule
(the "bigint" case) and PVML's pvmlToModule all convert directly to NUMBER,
with no separate INTEGER tag and no per-position/per-declared-type
exception, including for a value sitiing inside an OPAQUE-wrapped payload.

Net effect versus main, kept:
- GenericDataHandler.closure_call_unchecked now throws on an invalid closure
  identifier instead of silently returning undefined - independent,
  unrelated fix (every caller immediately calls .next() on the result, so
  the previous undefined return was a latent TypeError waiting to happen)
  that happened to be bundled into these commits; kept on its own merits.
- Each engine's moduleToPython/moduleToPvml/WASM's typedToHostValue keeps a
  minimal DataType.INTEGER case, converting to a float, purely for switch
  exhaustiveness over conductor's DataType enum (which permanently has the
  member now that conductor#47/0.7.2 is published) - dead code in practice,
  since nothing in py-slang produces INTEGER anymore.
- @sourceacademy/conductor bumped to ^0.7.2 - conductor#47 is backwa... (continued)

4384 of 5459 branches covered (80.31%)

Branch coverage included in aggregate %.

79 of 90 new or added lines in 6 files covered. (87.78%)

1 existing line in 1 file now uncovered.

9447 of 10544 relevant lines covered (89.6%)

178847.72 hits per line

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

61.35
/src/conductor/GenericDataHandler.ts
1
import type { IEvaluator, IInterfacableEvaluator } from "@sourceacademy/conductor/runner";
2
import {
18✔
3
  ArrayIdentifier,
4
  ClosureIdentifier,
5
  DataType,
6
  ExternCallable,
7
  IDataHandler,
8
  IFunctionSignature,
9
  OpaqueIdentifier,
10
  PairIdentifier,
11
  TypedValue,
12
} from "@sourceacademy/conductor/types";
13

14
/**
15
 * A conductor `IDataHandler` implementation with no engine-specific logic:
16
 * pairs, arrays, closures and opaques are all just bookkeeping over plain
17
 * Maps keyed by an incrementing id, and the list helpers (`list`/`is_list`/
18
 * `list_to_vec`/`accumulate`/`length`) walk that pair structure generically.
19
 * The only place an engine's own semantics enter the picture is the
20
 * `ExternCallable` passed to `closure_make` (authored by that engine's own
21
 * module-interop layer) and the arguments/results flowing through
22
 * `closure_call`/`closure_call_unchecked` — this class never inspects them.
23
 *
24
 * Originally written inline in PyCseEvaluatorBase (see PyCseEvaluator.ts);
25
 * extracted so every evaluator that talks to conductor modules (CSE, py2js,
26
 * and eventually WASM/PVML) shares one implementation instead of
27
 * re-deriving the same identifier-table bookkeeping per engine. An evaluator
28
 * holds one instance (`private dataHandler = new GenericDataHandler()`) and
29
 * hands it to both `context.evaluator` (or the engine's equivalent) and
30
 * `conductor.registerPlugin(ModuleLoaderRunnerPlugin, conductor, dataHandler)`.
31
 */
32
export class GenericDataHandler implements IDataHandler {
18✔
33
  hasDataInterface = true as const;
108✔
34
  private pairMap = new Map<
108✔
35
    PairIdentifier,
36
    { head: TypedValue<DataType>; tail: TypedValue<DataType> }
37
  >();
38
  private arrayMap = new Map<
108✔
39
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
40
    ArrayIdentifier<any>,
41
    { type: DataType; elements: TypedValue<DataType>[] }
42
  >();
43
  private closureMap = new Map<
108✔
44
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
45
    ClosureIdentifier<any>,
46
    {
47
      // eslint-disable-next-line @typescript-eslint/no-explicit-any
48
      sig: IFunctionSignature<any, any>;
49
      // eslint-disable-next-line @typescript-eslint/no-explicit-any
50
      func: ExternCallable<any, any>;
51
      dependsOn?: (TypedValue<DataType> | null)[];
52
      isVararg?: boolean;
53
    }
54
  >();
55
  private opaqueMap = new Map<OpaqueIdentifier, { value: unknown; immutable: boolean }>();
108✔
56
  private uniqueId = 0;
108✔
57
  pair_make(
58
    head: TypedValue<DataType>,
59
    tail: TypedValue<DataType>,
60
  ): Promise<TypedValue<DataType.PAIR>> {
61
    this.pairMap.set(this.uniqueId++ as PairIdentifier, { head, tail });
22✔
62
    return Promise.resolve({ type: DataType.PAIR, value: (this.uniqueId - 1) as PairIdentifier });
22✔
63
  }
64
  /**
65
   * Bridges pair_head/pair_tail/pair_sethead/pair_settail/pair_assert onto a DataType.ARRAY value
66
   * too, not just a genuine PAIR: per Martin, a pair is just a 2-element array, and module code is
67
   * free to keep calling pair_head/pair_tail for clarity even once the underlying value it's
68
   * handed is array-backed (e.g. a value pythonToModule built directly as an ARRAY). Reads/writes
69
   * index 0/1 directly; throws the same "Invalid pair identifier" a genuine dangling PAIR would,
70
   * for a dangling/too-short array.
71
   */
72
  private resolvePairView(
73
    p: TypedValue<DataType.PAIR>,
74
  ): { head: TypedValue<DataType>; tail: TypedValue<DataType> } & (
75
    | { kind: "pair"; pair: { head: TypedValue<DataType>; tail: TypedValue<DataType> } }
76
    | { kind: "array"; array: { type: DataType; elements: TypedValue<DataType>[] } }
77
  ) {
78
    if ((p.type as DataType) === DataType.ARRAY) {
29✔
79
      const array = this.arrayMap.get(p.value as unknown as ArrayIdentifier<DataType>);
14✔
80
      if (!array || array.elements.length < 2) {
14✔
81
        throw new Error(`Invalid pair identifier: ${p.value}`);
1✔
82
      }
83
      return { kind: "array", array, head: array.elements[0], tail: array.elements[1] };
13✔
84
    }
85
    const pair = this.pairMap.get(p.value);
15✔
86
    if (!pair) {
15!
87
      throw new Error(`Invalid pair identifier: ${p.value}`);
×
88
    }
89
    return { kind: "pair", pair, head: pair.head, tail: pair.tail };
15✔
90
  }
91
  pair_head(p: TypedValue<DataType.PAIR>): Promise<TypedValue<DataType>> {
92
    return Promise.resolve(this.resolvePairView(p).head);
15✔
93
  }
94
  pair_sethead(p: TypedValue<DataType.PAIR>, tv: TypedValue<DataType>): Promise<void> {
95
    const view = this.resolvePairView(p);
1✔
96
    if (view.kind === "array") {
1!
97
      view.array.elements[0] = tv;
1✔
98
    } else {
NEW
99
      view.pair.head = tv;
×
100
    }
101
    return Promise.resolve();
1✔
102
  }
103
  pair_tail(p: TypedValue<DataType.PAIR>): Promise<TypedValue<DataType>> {
104
    return Promise.resolve(this.resolvePairView(p).tail);
10✔
105
  }
106
  pair_settail(p: TypedValue<DataType.PAIR>, tv: TypedValue<DataType>): Promise<void> {
107
    const view = this.resolvePairView(p);
1✔
108
    if (view.kind === "array") {
1!
109
      view.array.elements[1] = tv;
1✔
110
    } else {
NEW
111
      view.pair.tail = tv;
×
112
    }
113
    return Promise.resolve();
1✔
114
  }
115
  pair_assert(
116
    p: TypedValue<DataType.PAIR>,
117
    headType?: DataType,
118
    tailType?: DataType,
119
  ): Promise<void> {
120
    const { head, tail } = this.resolvePairView(p);
2✔
121
    if (headType && head.type !== headType) {
2✔
122
      throw new Error(`Expected head of type ${headType}, got ${head.type}`);
1✔
123
    }
124
    if (tailType && tail.type !== tailType) {
1!
NEW
125
      throw new Error(`Expected tail of type ${tailType}, got ${tail.type}`);
×
126
    }
127
    return Promise.resolve();
1✔
128
  }
129
  array_make<T extends DataType>(
130
    t: T,
131
    len: number,
132
    init?: TypedValue<NoInfer<T>>,
133
  ): Promise<TypedValue<DataType.ARRAY, NoInfer<T>>> {
134
    const elements = new Array(len).fill(init ?? { type: t, value: undefined }) as TypedValue<
23✔
135
      NoInfer<T>
136
    >[];
137
    const arrayValue: TypedValue<DataType.ARRAY, NoInfer<T>> = {
23✔
138
      type: DataType.ARRAY,
139
      value: this.uniqueId++ as ArrayIdentifier<typeof t>,
140
    };
141
    this.arrayMap.set(arrayValue.value, { type: t, elements });
23✔
142
    return Promise.resolve(arrayValue);
23✔
143
  }
144
  array_length(a: TypedValue<DataType.ARRAY>): Promise<number> {
145
    const array = this.arrayMap.get(a.value);
6✔
146
    if (!array) {
6!
147
      throw new Error(`Invalid array identifier: ${a.value}`);
×
148
    }
149
    return Promise.resolve(array.elements.length);
6✔
150
  }
151
  array_get<T extends DataType>(
152
    a: TypedValue<DataType.ARRAY, T>,
153
    idx: number,
154
  ): Promise<TypedValue<NoInfer<T>>>;
155
  array_get(
156
    a: TypedValue<DataType.ARRAY, DataType.VOID>,
157
    idx: number,
158
  ): Promise<TypedValue<DataType>> {
159
    const array = this.arrayMap.get(a.value);
7✔
160

161
    if (!array) {
7!
162
      throw new Error(`Invalid array identifier: ${a.value}`);
×
163
    }
164

165
    if (idx < 0 || idx >= array.elements.length) {
7!
166
      throw new Error(`Index out of bounds: ${idx}`);
×
167
    }
168

169
    const value = array.elements[idx];
7✔
170

171
    if (!value) {
7!
172
      throw new Error(`Missing element at index ${idx}`);
×
173
    }
174

175
    return Promise.resolve(value);
7✔
176
  }
177

178
  array_type<T extends DataType>(a: TypedValue<DataType.ARRAY, T>): Promise<NoInfer<T>> {
179
    const array = this.arrayMap.get(a.value);
×
180
    if (array === undefined) {
×
181
      throw new Error(`Invalid array identifier: ${a.value}`);
×
182
    }
183
    return Promise.resolve(array.type as NoInfer<T>);
×
184
  }
185
  array_set(
186
    a: TypedValue<DataType.ARRAY, DataType.VOID>,
187
    idx: number,
188
    tv: TypedValue<DataType>,
189
  ): Promise<void>;
190
  array_set<T extends DataType>(
191
    a: TypedValue<DataType.ARRAY, T>,
192
    idx: number,
193
    tv: TypedValue<NoInfer<T>>,
194
  ): Promise<void> {
195
    const array = this.arrayMap.get(a.value) as
32✔
196
      | { type: T; elements: TypedValue<NoInfer<T>>[] }
197
      | undefined;
198

199
    if (!array) {
32!
200
      throw new Error(`Invalid array identifier: ${a.value}`);
×
201
    }
202

203
    if (idx < 0 || idx >= array.elements.length) {
32!
204
      throw new Error(`Index out of bounds: ${idx}`);
×
205
    }
206

207
    array.elements[idx] = tv;
32✔
208

209
    return Promise.resolve();
32✔
210
  }
211
  array_assert<T extends DataType>(
212
    a: TypedValue<DataType.ARRAY>,
213
    type?: T,
214
    length?: number,
215
  ): Promise<void> {
216
    const array = this.arrayMap.get(a.value);
×
217
    if (!array) {
×
218
      throw new Error(`Invalid array identifier: ${a.value}`);
×
219
    }
220
    if (type !== undefined && array.type !== type) {
×
221
      throw new Error(`Expected array of type ${type}, got ${array.type}`);
×
222
    }
223
    if (length !== undefined && array.elements.length !== length) {
×
224
      throw new Error(`Expected array of length ${length}, got ${array.elements.length}`);
×
225
    }
226
    return Promise.resolve();
×
227
  }
228
  closure_make<const Arg extends readonly DataType[], const Ret extends DataType>(
229
    sig: IFunctionSignature<Arg, Ret>,
230
    func: ExternCallable<Arg, Ret>,
231
    dependsOn?: (TypedValue<DataType> | null)[],
232
  ): Promise<TypedValue<DataType.CLOSURE, Ret>> {
233
    const closureValue: TypedValue<DataType.CLOSURE, Ret> = {
454✔
234
      type: DataType.CLOSURE,
235
      value: this.uniqueId++ as ClosureIdentifier<Ret>,
236
    };
237
    this.closureMap.set(closureValue.value, { sig, func, dependsOn });
454✔
238
    return Promise.resolve(closureValue);
454✔
239
  }
240
  closure_is_vararg(c: TypedValue<DataType.CLOSURE>): Promise<boolean> {
241
    return Promise.resolve(this.closureMap.get(c.value)?.isVararg ?? false);
×
242
  }
243
  closure_arity(c: TypedValue<DataType.CLOSURE>): Promise<number> {
244
    return Promise.resolve(this.closureMap.get(c.value)?.sig.args.length ?? 0);
12!
245
  }
246
  closure_call<T extends DataType>(
247
    c: TypedValue<DataType.CLOSURE, T>,
248
    args: TypedValue<DataType>[],
249
    returnType: T,
250
  ): AsyncGenerator<void, TypedValue<NoInfer<T>>, undefined> {
251
    const closure = this.closureMap.get(c.value);
×
252
    if (closure === undefined) {
×
253
      throw new Error(`Invalid closure identifier: ${c.value}`);
×
254
    }
255
    if (closure.sig.returnType !== returnType) {
×
256
      throw new Error(`Expected return type ${returnType}, got ${closure.sig.returnType}`);
×
257
    }
258
    return closure.func(...args) as AsyncGenerator<void, TypedValue<NoInfer<T>>, undefined>;
×
259
  }
260
  closure_call_unchecked<T extends DataType>(
261
    c: TypedValue<DataType.CLOSURE, T>,
262
    args: TypedValue<DataType>[],
263
  ): AsyncGenerator<void, TypedValue<NoInfer<T>>, undefined> {
264
    const closure = this.closureMap.get(c.value);
267✔
265
    if (closure === undefined) {
267!
NEW
266
      throw new Error(`Invalid closure identifier: ${c.value}`);
×
267
    }
268
    return closure.func(...args) as AsyncGenerator<void, TypedValue<NoInfer<T>>, undefined>;
267✔
269
  }
270
  /**
271
   * Fast path for a closure that provably never needs to leave the current
272
   * synchronous call - e.g. a scalar-in/scalar-out wave function sampled
273
   * 44100x/sec by the sound module. `func` (an `ExternCallable`, normally
274
   * only callable as an AsyncGenerator per conductor's own contract) may
275
   * additionally carry a `.sync` escape hatch: a plain function computing
276
   * the exact same result with no Promise/generator indirection at all. An
277
   * engine's module-interop layer sets `.sync` only when it can prove the
278
   * closure never needs a real host round-trip (see py2js's moduleInterop.ts
279
   * pyClosureFunc); a closure with no such proof (every CSE-machine closure
280
   * today, or a py2js closure that touches something asyncOnly) simply never
281
   * gets one.
282
   *
283
   * Returns `undefined` when the closure has no sync form - the signal for
284
   * "fall back to closure_call_unchecked" - which is unambiguous because a
285
   * TypedValue always wraps a real `{ type, value }` pair, even for
286
   * DataType.VOID; the bare JS value `undefined` is never a legitimate
287
   * closure result.
288
   */
289
  closure_call_sync<T extends DataType>(
290
    c: TypedValue<DataType.CLOSURE, T>,
291
    args: TypedValue<DataType>[],
292
  ): TypedValue<NoInfer<T>> | undefined {
293
    const func = this.closureMap.get(c.value)?.func as
3✔
294
      | (ExternCallable<DataType[], T> & {
295
          sync?: (...a: TypedValue<DataType>[]) => TypedValue<DataType> | undefined;
296
        })
297
      | undefined;
298
    return func?.sync?.(...args) as TypedValue<NoInfer<T>> | undefined;
3✔
299
  }
300
  closure_arity_assert(c: TypedValue<DataType.CLOSURE>, arity: number): Promise<void> {
301
    const closure = this.closureMap.get(c.value);
×
302
    if (!closure) {
×
303
      throw new Error(`Invalid closure identifier: ${c.value}`);
×
304
    }
305
    if (closure.sig.args.length !== arity && !closure.isVararg) {
×
306
      throw new Error(`Expected closure of arity ${arity}, got ${closure.sig.args.length}`);
×
307
    }
308
    return Promise.resolve();
×
309
  }
310
  opaque_make(v: unknown, immutable?: boolean): Promise<TypedValue<DataType.OPAQUE>> {
311
    const opaqueValue: TypedValue<DataType.OPAQUE> = {
3✔
312
      type: DataType.OPAQUE,
313
      value: this.uniqueId++ as OpaqueIdentifier,
314
    };
315
    this.opaqueMap.set(opaqueValue.value, { value: v, immutable: immutable || false });
3✔
316
    return Promise.resolve(opaqueValue);
3✔
317
  }
318
  opaque_get(o: TypedValue<DataType.OPAQUE>): Promise<unknown> {
319
    const opaque = this.opaqueMap.get(o.value);
1✔
320
    if (!opaque) {
1!
321
      throw new Error(`Invalid opaque identifier: ${o.value}`);
×
322
    }
323
    return Promise.resolve(opaque.value);
1✔
324
  }
325
  opaque_update(o: TypedValue<DataType.OPAQUE>, v: unknown): Promise<void> {
326
    const opaque = this.opaqueMap.get(o.value);
×
327
    if (!opaque) {
×
328
      throw new Error(`Invalid opaque identifier: ${o.value}`);
×
329
    }
330
    if (opaque.immutable) {
×
331
      throw new Error(`Cannot update immutable opaque value with identifier: ${o.value}`);
×
332
    }
333
    opaque.value = v;
×
334
    return Promise.resolve();
×
335
  }
336
  tie(_dependent: TypedValue<DataType>, _dependee: TypedValue<DataType> | null): Promise<void> {
337
    throw new Error("Method not implemented.");
×
338
  }
339
  untie(_dependent: TypedValue<DataType>, _dependee: TypedValue<DataType> | null): Promise<void> {
340
    throw new Error("Method not implemented.");
×
341
  }
342
  async list(...elements: TypedValue<DataType>[]): Promise<TypedValue<DataType.LIST>> {
343
    const list = await elements.reduceRight(
5✔
344
      async (acc, el) => {
345
        return this.pair_make(el, await acc);
13✔
346
      },
347
      Promise.resolve({ type: DataType.EMPTY_LIST, value: null }) as Promise<
348
        TypedValue<DataType.LIST>
349
      >,
350
    );
351
    return list;
5✔
352
  }
353
  /**
354
   * Reads every generic list helper's elements uniformly: an ARRAY is already flat (its elements
355
   * read straight off array_get, no walking needed), while a PAIR/EMPTY_LIST chain is walked node
356
   * by node the old way. Per Martin: a pair is just a 2-element array, not a distinct concept, so
357
   * these helpers treat both shapes as equally valid "list" inputs rather than only recognizing
358
   * the PAIR/EMPTY_LIST chain - this is what lets pythonToModule (CSE/PVML/py2js) freely encode a
359
   * Python list as DataType.ARRAY without breaking a module that calls list_to_vec/is_list/length/
360
   * accumulate on it (sound, midi, ...), with zero changes needed on the module's side. Throws the
361
   * same "Expected a list, got type X" a caller relying on that message already handles.
362
   */
363
  private readListElements(xs: TypedValue<DataType>): TypedValue<DataType>[] {
364
    if (xs.type === DataType.ARRAY) {
26✔
365
      const array = this.arrayMap.get(xs.value);
13✔
366
      if (!array) {
13!
NEW
367
        throw new Error(`Invalid array identifier: ${xs.value}`);
×
368
      }
369
      return array.elements;
13✔
370
    }
371
    const result: TypedValue<DataType>[] = [];
13✔
372
    let current: TypedValue<DataType> = xs;
13✔
373
    while (current.type !== DataType.EMPTY_LIST) {
13✔
374
      if (current.type !== DataType.PAIR) {
22✔
375
        throw new Error(`Expected a list, got type ${current.type}`);
5✔
376
      }
377
      const pair = this.pairMap.get(current.value);
17✔
378
      if (!pair) {
17✔
379
        throw new Error(`Invalid pair identifier: ${current.value}`);
3✔
380
      }
381
      result.push(pair.head);
14✔
382
      current = pair.tail;
14✔
383
    }
384
    return result;
5✔
385
  }
386
  is_list(xs: TypedValue<DataType.LIST>): Promise<boolean> {
387
    try {
5✔
388
      this.readListElements(xs);
5✔
389
      return Promise.resolve(true);
2✔
390
    } catch {
391
      return Promise.resolve(false);
3✔
392
    }
393
  }
394
  list_to_vec(xs: TypedValue<DataType.LIST>): Promise<TypedValue<DataType>[]> {
395
    try {
13✔
396
      return Promise.resolve(this.readListElements(xs));
13✔
397
    } catch (e) {
398
      return Promise.reject(e);
2✔
399
    }
400
  }
401
  async *accumulate<T extends Exclude<DataType, DataType.VOID>>(
402
    op: TypedValue<DataType.CLOSURE, T>,
403
    initial: TypedValue<T>,
404
    sequence: TypedValue<DataType.LIST>,
405
    _resultType: T,
406
  ): AsyncGenerator<void, TypedValue<NoInfer<T>>, undefined> {
407
    let acc = initial;
4✔
408
    for (const element of this.readListElements(sequence)) {
4✔
409
      acc = yield* this.closure_call_unchecked(op, [acc, element]);
6✔
410
    }
411
    return acc;
3✔
412
  }
413
  length(xs: TypedValue<DataType.LIST>): Promise<number> {
414
    return Promise.resolve(this.readListElements(xs).length);
4✔
415
  }
416
}
417

418
/**
419
 * `ModuleLoaderRunnerPlugin`'s constructor requires a single object
420
 * satisfying `IInterfacableEvaluator` (`IEvaluator & IDataHandler`) — but an
421
 * evaluator built around `GenericDataHandler` has those two halves on two
422
 * different objects (the evaluator itself, extending `BasicEvaluator`, is
423
 * the `IEvaluator`; its `dataHandler` field is the `IDataHandler`). A Proxy
424
 * combines them into the one object the registration call needs, so
425
 * combining stays a one-line call at each registration site instead of ~20
426
 * lines of per-evaluator forwarding methods duplicated alongside the
427
 * bookkeeping this class already centralizes.
428
 */
429
export function asInterfacableEvaluator(
18✔
430
  evaluator: IEvaluator,
431
  dataHandler: GenericDataHandler,
432
): IInterfacableEvaluator {
433
  return new Proxy(evaluator, {
45✔
434
    get(target, prop, receiver) {
435
      if (prop in dataHandler) {
471✔
436
        // Bind so stateful methods (this.uniqueId++ in pair_make etc.) read
437
        // and write dataHandler, not the proxy — a plain Reflect.get returns
438
        // the method unbound, so calling it here would set `this` to the
439
        // proxy and (absent a `set` trap) silently write to `evaluator`.
440
        const value = Reflect.get(dataHandler, prop, dataHandler);
470✔
441
        return typeof value === "function" ? value.bind(dataHandler) : value;
470!
442
      }
443
      return Reflect.get(target, prop, receiver);
1✔
444
    },
445
  }) as unknown as IInterfacableEvaluator;
446
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc