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

bramp / build-along / 19770083201

28 Nov 2025 05:10PM UTC coverage: 90.346% (+0.6%) from 89.792%
19770083201

push

github

bramp
Update AGENTS on the rules around dataclasses vs pydantic models.

8451 of 9354 relevant lines covered (90.35%)

0.9 hits per line

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

23.03
/src/build_a_long/pdf_extract/cli/reporting.py
1
"""Reporting and output formatting for PDF extraction."""
2

3
import logging
1✔
4
from typing import Any
1✔
5

6
from pydantic import BaseModel, ConfigDict, Field
1✔
7

8
from build_a_long.pdf_extract.classifier import (
1✔
9
    Candidate,
10
    ClassificationResult,
11
)
12
from build_a_long.pdf_extract.classifier.text import FontSizeHints, TextHistogram
1✔
13
from build_a_long.pdf_extract.extractor import PageData
1✔
14
from build_a_long.pdf_extract.extractor.bbox import BBox
1✔
15
from build_a_long.pdf_extract.extractor.hierarchy import build_hierarchy_from_blocks
1✔
16
from build_a_long.pdf_extract.extractor.lego_page_elements import Page
1✔
17
from build_a_long.pdf_extract.extractor.page_blocks import Blocks
1✔
18
from build_a_long.pdf_extract.validation import print_validation, validate_results
1✔
19

20
logger = logging.getLogger(__name__)
1✔
21

22
# ANSI color codes
23
GREY = "\033[90m"
1✔
24
RESET = "\033[0m"
1✔
25

26

27
class TreeNode(BaseModel):
1✔
28
    """Unified node for the classification debug tree.
29

30
    Represents either a Block with optional candidates, or a synthetic Candidate.
31
    """
32

33
    model_config = ConfigDict(frozen=True)
1✔
34

35
    bbox: BBox
36
    """Bounding box for this node"""
1✔
37

38
    block: Blocks | None = None
1✔
39
    """The source block, if this node represents a block"""
1✔
40

41
    candidates: list[Candidate] = Field(default_factory=list)
1✔
42
    """Candidates for this block (empty if synthetic or no candidates)"""
1✔
43

44
    synthetic_candidate: Candidate | None = None
1✔
45
    """If this is a synthetic candidate (no source blocks)"""
1✔
46

47

48
def print_summary(
1✔
49
    pages: list[PageData],
50
    results: list[ClassificationResult],
51
    *,
52
    detailed: bool = False,
53
    validate: bool = True,
54
) -> None:
55
    """Print a human-readable summary of classification results to stdout.
56

57
    Args:
58
        pages: List of PageData containing extracted elements
59
        results: List of ClassificationResult with labels
60
        detailed: If True, include additional details like missing page numbers
61
        validate: If True (default), run and print validation checks
62
    """
63
    total_pages = len(pages)
1✔
64
    total_blocks = 0
1✔
65
    blocks_by_type: dict[str, int] = {}
1✔
66
    labeled_counts: dict[str, int] = {}
1✔
67

68
    pages_with_page_number = 0
1✔
69
    missing_page_numbers: list[int] = []
1✔
70

71
    for page, result in zip(pages, results, strict=True):
1✔
72
        total_blocks += len(page.blocks)
1✔
73
        # Tally block types and labels
74
        has_page_number = False
1✔
75
        for block in page.blocks:
1✔
76
            t = block.__class__.__name__.lower()
×
77
            blocks_by_type[t] = blocks_by_type.get(t, 0) + 1
×
78

79
            label = result.get_label(block)
×
80
            if label:
×
81
                labeled_counts[label] = labeled_counts.get(label, 0) + 1
×
82
                if label == "page_number":
×
83
                    has_page_number = True
×
84

85
        if has_page_number:
1✔
86
            pages_with_page_number += 1
×
87
        else:
88
            missing_page_numbers.append(page.page_number)
1✔
89

90
    coverage = (pages_with_page_number / total_pages * 100.0) if total_pages else 0.0
1✔
91

92
    # Human-friendly, single-shot summary
93
    print("=== Classification summary ===")
1✔
94
    print(f"Pages processed: {total_pages}")
1✔
95
    print(f"Total blocks: {total_blocks}")
1✔
96
    if blocks_by_type:
1✔
97
        parts = [f"{k}={v}" for k, v in sorted(blocks_by_type.items())]
×
98
        print("Elements by type: " + ", ".join(parts))
×
99
    if labeled_counts:
1✔
100
        parts = [f"{k}={v}" for k, v in sorted(labeled_counts.items())]
×
101
        print("Labeled elements: " + ", ".join(parts))
×
102
    print(
1✔
103
        f"Page-number coverage: {pages_with_page_number}/{total_pages} "
104
        f"({coverage:.1f}%)"
105
    )
106
    if detailed and missing_page_numbers:
1✔
107
        sample = ", ".join(str(n) for n in missing_page_numbers[:20])
×
108
        more = " ..." if len(missing_page_numbers) > 20 else ""
×
109
        print(f"Pages missing page number: {sample}{more}")
×
110

111
    # Run validation checks
112
    if validate:
1✔
113
        print()
1✔
114
        validation = validate_results(pages, results)
1✔
115
        print_validation(validation)
1✔
116

117

118
def _print_font_size_distribution(
1✔
119
    title: str,
120
    counter: Any,
121
    *,
122
    max_items: int = 10,
123
    empty_message: str = "(no data)",
124
    total_label: str = "Total text elements",
125
    unique_label: str = "Total unique sizes",
126
) -> None:
127
    """Print a font size distribution with bar chart.
128

129
    Args:
130
        title: Section title to display
131
        counter: Counter/dict mapping font sizes to counts
132
        max_items: Maximum number of items to display
133
        empty_message: Message to show when counter is empty
134
        total_label: Label for total count summary
135
        unique_label: Label for unique size count
136
    """
137
    print(title)
×
138
    print("-" * 60)
×
139

140
    total = sum(counter.values())
×
141

142
    if total > 0:
×
143
        print(f"{'Size':>8} | {'Count':>6} | Distribution")
×
144
        print("-" * 60)
×
145

146
        # Get most common items
147
        if hasattr(counter, "most_common"):
×
148
            items = counter.most_common(max_items)
×
149
        else:
150
            items = sorted(counter.items(), key=lambda x: x[1], reverse=True)[
×
151
                :max_items
152
            ]
153

154
        max_count = items[0][1] if items else 1
×
155
        for size, count in items:
×
156
            bar_length = int((count / max_count) * 30)
×
157
            bar = "█" * bar_length
×
158
            print(f"{size:8.1f} | {count:6d} | {bar}")
×
159

160
        print("-" * 60)
×
161
        print(f"{unique_label}: {len(counter)}")
×
162
        print(f"{total_label}: {total}")
×
163
    else:
164
        print(empty_message)
×
165
    print()
×
166

167

168
def print_histogram(histogram: TextHistogram) -> None:
1✔
169
    """Print the text histogram showing font size and name distributions.
170

171
    Args:
172
        histogram: TextHistogram containing font statistics across all pages
173
    """
174
    print("=== Text Histogram ===")
×
175
    print()
×
176

177
    # 1. Part counts (\dx pattern) - calculated first
178
    _print_font_size_distribution(
×
179
        "1. Part Count Font Sizes (\\dx pattern, e.g., '2x', '3x'):",
180
        histogram.part_count_font_sizes,
181
        empty_message="(no part count data)",
182
        total_label="Total part counts",
183
    )
184

185
    # 2. Page numbers (±1) - calculated second
186
    _print_font_size_distribution(
×
187
        "2. Page Number Font Sizes (digits ±1 from current page):",
188
        histogram.page_number_font_sizes,
189
        empty_message="(no page number data)",
190
        total_label="Total page numbers",
191
    )
192

193
    # 3. Element IDs (6-7 digit numbers) - calculated third
194
    _print_font_size_distribution(
×
195
        "3. Element ID Font Sizes (6-7 digit numbers):",
196
        histogram.element_id_font_sizes,
197
        empty_message="(no Element ID data)",
198
        total_label="Total Element IDs",
199
    )
200

201
    # 4. Other integer font sizes - calculated fourth
202
    _print_font_size_distribution(
×
203
        "4. Other Integer Font Sizes (integers not matching above patterns):",
204
        histogram.remaining_font_sizes,
205
        max_items=20,
206
        empty_message="(no other integer font size data)",
207
    )
208

209
    # 5. Font name distribution - calculated fifth
210
    print("5. Font Name Distribution:")
×
211
    print("-" * 60)
×
212

213
    font_name_total = sum(histogram.font_name_counts.values())
×
214

215
    if font_name_total > 0:
×
216
        print(f"{'Font Name':<30} | {'Count':>6} | Distribution")
×
217
        print("-" * 60)
×
218

219
        font_names = histogram.font_name_counts.most_common(20)
×
220
        max_count = font_names[0][1] if font_names else 1
×
221
        for font_name, count in font_names:
×
222
            bar_length = int((count / max_count) * 30)
×
223
            bar = "█" * bar_length
×
224
            name_display = font_name[:27] + "..." if len(font_name) > 30 else font_name
×
225
            print(f"{name_display:<30} | {count:6d} | {bar}")
×
226

227
        print("-" * 60)
×
228
        print(f"Total unique fonts:  {len(histogram.font_name_counts)}")
×
229
        print(f"Total text elements: {font_name_total}")
×
230
    else:
231
        print("(no font name data)")
×
232

233
    print()
×
234

235

236
def print_font_hints(hints: FontSizeHints) -> None:
1✔
237
    """Print font size hints extracted from the document.
238

239
    Args:
240
        hints: FontSizeHints containing identified font sizes for different elements
241
    """
242
    print("=== Font Size Hints ===")
×
243
    print()
×
244

245
    def format_size(size: float | None) -> str:
×
246
        """Format a font size for display."""
247
        return f"{size:.1f}pt" if size is not None else "N/A"
×
248

249
    print("Identified font sizes:")
×
250
    print(f"  Part count size:         {format_size(hints.part_count_size)}")
×
251
    print(f"  Catalog part count size: {format_size(hints.catalog_part_count_size)}")
×
252
    print(f"  Step number size:        {format_size(hints.step_number_size)}")
×
253
    print(f"  Step repeat size:        {format_size(hints.step_repeat_size)}")
×
254
    print(f"  Catalog element ID size: {format_size(hints.catalog_element_id_size)}")
×
255
    print(f"  Page number size:        {format_size(hints.page_number_size)}")
×
256

257
    print()
×
258
    print("Remaining font sizes after removing known patterns:")
×
259
    if hints.remaining_font_sizes:
×
260
        print(f"{'Size':>8} | {'Count':>6}")
×
261
        print("-" * 20)
×
262
        for size, count in hints.remaining_font_sizes:
×
263
            print(f"{size:8.1f} | {count:6d}")
×
264
        print(f"\nTotal unique sizes: {len(hints.remaining_font_sizes)}")
×
265
    else:
266
        print("  (no remaining font sizes)")
×
267
    print()
×
268

269

270
def print_classification_debug(
1✔
271
    page: PageData,
272
    result: ClassificationResult,
273
    *,
274
    show_candidates: bool = True,
275
    show_hierarchy: bool = True,
276
    label: str | None = None,
277
) -> None:
278
    """Print comprehensive classification debug information.
279

280
    Shows all classification details in one consolidated view with blocks
281
    and candidates intermixed in a unified tree.
282

283
    Args:
284
        page: PageData containing all elements
285
        result: ClassificationResult with classification information
286
        show_candidates: Include detailed candidate breakdown
287
        show_hierarchy: Include page hierarchy summary
288
        label: If provided, filter candidate analysis to this label only
289
    """
290
    print(f"\n{'=' * 80}")
×
291
    print(f"CLASSIFICATION DEBUG - Page {page.page_number}")
×
292
    print(f"{'=' * 80}\n")
×
293

294
    # Build mapping from blocks to their candidates
295
    block_to_candidates: dict[int, list[Candidate]] = {}
×
296
    all_candidates = result.get_all_candidates()
×
297

298
    for _label_name, candidates in all_candidates.items():
×
299
        for candidate in candidates:
×
300
            for source_block in candidate.source_blocks:
×
301
                if source_block.id not in block_to_candidates:
×
302
                    block_to_candidates[source_block.id] = []
×
303
                block_to_candidates[source_block.id].append(candidate)
×
304

305
    # Create TreeNode for each block
306
    block_nodes: list[TreeNode] = []
×
307
    block_node_map: dict[int, TreeNode] = {}  # block.id -> TreeNode
×
308

309
    for block in page.blocks:
×
310
        candidates_list = block_to_candidates.get(block.id, [])
×
311
        node = TreeNode(
×
312
            bbox=block.bbox,
313
            block=block,
314
            candidates=candidates_list,
315
        )
316
        block_nodes.append(node)
×
317
        block_node_map[block.id] = node
×
318

319
    # Create TreeNode for synthetic candidates
320
    synthetic_nodes: list[TreeNode] = []
×
321
    for _label_name, candidates in all_candidates.items():
×
322
        for candidate in candidates:
×
323
            if not candidate.source_blocks:
×
324
                node = TreeNode(
×
325
                    bbox=candidate.bbox,
326
                    synthetic_candidate=candidate,
327
                )
328
                synthetic_nodes.append(node)
×
329

330
    # Combine all nodes for hierarchy building
331
    all_nodes = block_nodes + synthetic_nodes
×
332

333
    # Build unified hierarchy
334
    tree = build_hierarchy_from_blocks(all_nodes)
×
335

336
    def print_node(node: TreeNode, depth: int, is_last: bool = True) -> None:
×
337
        """Recursively print a node and its children."""
338
        # Build tree characters
339
        if depth == 0:
×
340
            tree_prefix = ""
×
341
            indent = ""
×
342
        else:
343
            tree_char = "└─" if is_last else "├─"
×
344
            indent = "  " * (depth - 1)
×
345
            tree_prefix = f"{indent}{tree_char} "
×
346

347
        # Check if this is a removed block
348
        is_removed = node.block and result.is_removed(node.block)
×
349
        color = GREY if is_removed else ""
×
350
        reset = RESET if is_removed else ""
×
351

352
        line = f"{color}{tree_prefix}"
×
353

354
        if node.synthetic_candidate:
×
355
            # Synthetic candidate (Page, Step, NewBag, etc.)
356
            candidate = node.synthetic_candidate
×
357
            elem_str = (
×
358
                str(candidate.constructed)
359
                if candidate.constructed
360
                else "NOT CONSTRUCTED"
361
            )
362
            line += f"[{candidate.label}] {elem_str} (score={candidate.score:.3f})"
×
363
        elif node.block:
×
364
            # Regular block
365
            block = node.block
×
366
            block_type = type(block).__name__
×
367
            line += f"{block.id:3d} ({block_type}) "
×
368

369
            if is_removed:
×
370
                reason = result.get_removal_reason(block)
×
371
                reason_text = reason.reason_type if reason else "unknown"
×
372
                line += f"* REMOVED: {reason_text}"
×
373
                if reason:
×
374
                    target = reason.target_block
×
375
                    line += f" by {target.id}"
×
376
                    target_best = result.get_best_candidate(target)
×
377
                    if target_best:
×
378
                        line += f" ({target_best.label})"
×
379
                line += f"* {str(block)}"
×
380
            elif node.candidates:
×
381
                # Show all candidates for this block
382
                sorted_candidates = sorted(
×
383
                    node.candidates, key=lambda c: c.score, reverse=True
384
                )
385
                best = sorted_candidates[0]
×
386

387
                # Show best candidate prominently
388
                elem_str = str(best.constructed) if best.constructed else str(block)
×
389
                line += f"[{best.label}] {elem_str} (score={best.score:.3f})"
×
390

391
                # If multiple candidates, show others on additional lines
392
                if len(sorted_candidates) > 1:
×
393
                    print(line + reset)
×
394
                    for other in sorted_candidates[1:]:
×
395
                        other_indent = indent + ("  " if is_last else "│ ")
×
396
                        elem_str = (
×
397
                            str(other.constructed) if other.constructed else str(block)
398
                        )
399
                        alt_line = (
×
400
                            f"{color}{other_indent}   alt [{other.label}] "
401
                            f"{elem_str} (score={other.score:.3f}){reset}"
402
                        )
403
                        print(alt_line)
×
404
                    line = None  # Already printed
×
405
            else:
406
                line += f"[no candidates] {str(block)}"
×
407

408
        if line:
×
409
            print(line + reset)
×
410

411
        # Print children
412
        children = tree.get_children(node)
×
413
        sorted_children = sorted(
×
414
            children, key=lambda n: (n.block.id if n.block else -1)
415
        )
416
        for i, child in enumerate(sorted_children):
×
417
            child_is_last = i == len(sorted_children) - 1
×
418
            print_node(child, depth + 1, child_is_last)
×
419

420
    # Print tree
421
    for root in tree.roots:
×
422
        print_node(root, 0)
×
423

424
    # Summary stats
425
    total = len(page.blocks)
×
426
    with_labels = sum(1 for b in page.blocks if result.get_label(b) is not None)
×
427
    removed = sum(1 for b in page.blocks if result.is_removed(b))
×
428
    no_candidates = total - with_labels - removed
×
429
    total_candidates = sum(len(candidates) for candidates in all_candidates.values())
×
430
    num_synthetic = len(synthetic_nodes)
×
431

432
    print(f"\n{'─' * 80}")
×
433
    print(
×
434
        f"Blocks: {total} total | {with_labels} labeled | "
435
        f"{removed} removed | {no_candidates} no candidates"
436
    )
437
    print(f"Candidates: {total_candidates} total | {num_synthetic} synthetic")
×
438

439
    warnings = result.get_warnings()
×
440
    if warnings:
×
441
        print(f"Warnings: {len(warnings)}")
×
442
        for warning in warnings:
×
443
            print(f"  ⚠ {warning}")
×
444

445
    # Detailed candidate analysis
446
    if show_candidates:
×
447
        print(f"\n{'=' * 80}")
×
448
        print("CANDIDATES BY LABEL")
×
449
        print(f"{'=' * 80}")
×
450

451
        # Get all candidates
452
        all_candidates = result.get_all_candidates()
×
453

454
        # Filter to specific label if requested
455
        if label:
×
456
            labels_to_show = {label: all_candidates.get(label, [])}
×
457
        else:
458
            labels_to_show = all_candidates
×
459

460
        # Build set of elements that made it into the final Page hierarchy
461
        # These are the "winners" - candidates whose constructed elements
462
        # were actually used in the final output
463
        elements_in_page: set[int] = set()
×
464
        if result.page:
×
465
            for element in result.page.iter_elements():
×
466
                elements_in_page.add(id(element))
×
467

468
        # Summary table
469
        print(f"\n{'Label':<20} {'Total':<8} {'In Page':<8} {'Constructed':<12}")
×
470
        print(f"{'-' * 52}")
×
471
        for lbl in sorted(labels_to_show.keys()):
×
472
            candidates = labels_to_show[lbl]
×
473
            in_page = [
×
474
                c
475
                for c in candidates
476
                if c.constructed and id(c.constructed) in elements_in_page
477
            ]
478
            constructed = [c for c in candidates if c.constructed is not None]
×
479
            print(
×
480
                f"{lbl:<20} {len(candidates):<8} "
481
                f"{len(in_page):<8} {len(constructed):<12}"
482
            )
483

484
        # Detailed per-label breakdown
485
        for lbl in sorted(labels_to_show.keys()):
×
486
            candidates = labels_to_show[lbl]
×
487
            if not candidates:
×
488
                continue
×
489

490
            # Sort by score (highest first) for better readability
491
            sorted_candidates = sorted(candidates, key=lambda c: c.score, reverse=True)
×
492

493
            print(f"\n{lbl} ({len(candidates)} candidates):")
×
494
            for candidate in sorted_candidates:
×
495
                # TODO Use all source blocks, not just the first one.
496
                block = candidate.source_blocks[0] if candidate.source_blocks else None
×
497
                block_id_str = f"{block.id:3d}" if block else "  ?"
×
498

499
                # Determine if this candidate made it into the final Page
500
                in_page = (
×
501
                    candidate.constructed
502
                    and id(candidate.constructed) in elements_in_page
503
                )
504
                winner_mark = "✓ " if in_page else "  "
×
505

506
                if candidate.constructed:
×
507
                    constructed_str = str(candidate.constructed)
×
508
                else:
509
                    constructed_str = "<never constructed>"
×
510

511
                source_str = str(block) if block else "no source"
×
512
                print(
×
513
                    f"  {winner_mark}{block_id_str} [{lbl}] {constructed_str} | "
514
                    f"score={candidate.score:.3f} | {source_str}"
515
                )
516

517
    # Page hierarchy
518
    if show_hierarchy:
×
519
        page_obj = result.page
×
520
        if page_obj:
×
521
            print(f"\n{'=' * 80}")
×
522
            print("PAGE HIERARCHY")
×
523
            print(f"{'=' * 80}")
×
524
            page_num_str = (
×
525
                page_obj.page_number.value if page_obj.page_number else "None"
526
            )
527
            categories_str = (
×
528
                ", ".join(c.name for c in page_obj.categories)
529
                if page_obj.categories
530
                else "None"
531
            )
532
            print(f"Page number: {page_num_str}")
×
533
            print(f"Categories: {categories_str}")
×
534
            print(f"Progress bar: {'Yes' if page_obj.progress_bar else 'No'}")
×
535

536
            if page_obj.catalog:
×
537
                parts_count = len(page_obj.catalog)
×
538
                total_items = sum(p.count.count for p in page_obj.catalog)
×
539
                print(f"Catalog: {parts_count} parts ({total_items} total items)")
×
540

541
            print(f"Steps: {len(page_obj.steps)}")
×
542

543
            for i, step in enumerate(page_obj.steps, 1):
×
544
                parts_count = len(step.parts_list.parts) if step.parts_list else 0
×
545
                print(f"  Step {i}: #{step.step_number.value} ({parts_count} parts)")
×
546

547
    print(f"\n{'=' * 80}\n")
×
548

549

550
def print_page_hierarchy(page_data: PageData, page: Page) -> None:
1✔
551
    """Print the structured LEGO page hierarchy.
552

553
    Args:
554
        page_data: PageData containing the raw page number
555
        page: Structured Page object with steps, parts lists, etc.
556
    """
557
    categories_str = (
1✔
558
        f" ([{', '.join(c.name for c in page.categories)}])" if page.categories else ""
559
    )
560
    print(f"Page {page_data.page_number}{categories_str}:")
1✔
561

562
    if page.page_number:
1✔
563
        print(f"  ✓ Page Number: {page.page_number.value}")
1✔
564

565
    if page.new_bags:
1✔
566
        print(f"  ✓ New Bags: {len(page.new_bags)}")
×
567
        for new_bag in page.new_bags:
×
568
            bag_label = f"Bag {new_bag.number.value}" if new_bag.number else "Bag (all)"
×
569
            print(f"    - {bag_label} at {new_bag.bbox}")
×
570

571
    if page.catalog:
1✔
572
        parts_count = len(page.catalog)
×
573
        total_items = sum(p.count.count for p in page.catalog)
×
574
        print(f"  ✓ Catalog: {parts_count} parts ({total_items} total items)")
×
575
        if page.catalog:
×
576
            print("      Parts:")
×
577
            for part in page.catalog:
×
578
                number_str = part.number.element_id if part.number else "no number"
×
579
                print(f"        • {part.count.count}x ({number_str})")
×
580

581
    if page.steps:
1✔
582
        print(f"  ✓ Steps: {len(page.steps)}")
1✔
583
        for step in page.steps:
1✔
584
            parts_count = len(step.parts_list.parts) if step.parts_list else 0
1✔
585
            print(f"    - Step {step.step_number.value} ({parts_count} parts)")
1✔
586
            # Print parts list details
587
            if step.parts_list and step.parts_list.parts:
1✔
588
                print("      Parts List:")
1✔
589
                for part in step.parts_list.parts:
1✔
590
                    number_str = part.number.element_id if part.number else "no number"
1✔
591
                    print(f"        • {part.count.count}x ({number_str})")
1✔
592
            else:
593
                print("      Parts List: (none)")
1✔
594

595
            if step.diagram:
1✔
596
                print(f"      Diagram: {step.diagram.bbox}")
1✔
597

598

599
def build_and_print_page_hierarchy(
1✔
600
    pages: list[PageData], results: list[ClassificationResult]
601
) -> None:
602
    """Build LEGO page hierarchy from classification results and print structure.
603

604
    Args:
605
        pages: List of PageData containing extracted elements
606
        results: List of ClassificationResult with labels and relationships
607
    """
608
    print("Building LEGO page hierarchy...")
×
609

610
    for page_data, result in zip(pages, results, strict=True):
×
611
        page = result.page
×
612
        if page:
×
613
            print_page_hierarchy(page_data, page)
×
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