Use when a pull request needs readable coverage feedback rather than one total. Compares current and baseline coverage (LCOV, Cobertura or JSON) and reports per-file line and branch deltas, new files and hidden drops, with a one-line summary for the PR comment.
90%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
Passed
No findings from the security scan
An aggregate coverage number hides where coverage moved: -0.4pp overall
can be one file at -12pp. This skill turns two parsed coverage runs
(PR head and merge-target baseline) into a per-file delta report and a
one-line PR summary. It does not decide pass/fail; the gate in
lcov-analysis does that. This skill makes the diff legible.
Inputs: the current run's LCOV or Cobertura file and the baseline's.
Output: diff.md (full report) posted as one sticky PR comment.
Use the parser from lcov-analysis (its "Parse" section; Cobertura
parser in its references/cobertura.md), saved in your repo as
scripts/parse_lcov.py or scripts/parse_cobertura.py. Each emits a
list of per-file dicts with path, lf, lh, brf, brh - the LCOV
LF/LH (lines found / hit) and BRF/BRH (branches found / hit)
counters (lcov).
| Existing reporter | Use |
|---|---|
LCOV .info | lcov-analysis LCOV parser |
| Cobertura XML | lcov-analysis Cobertura parser (references/cobertura.md) |
| Jest / V8 coverage | Emit LCOV first (jest --coverageReporters=lcov) |
| JaCoCo XML | jacoco-analysis, or convert to Cobertura |
| coverage.py | coverage xml (Cobertura) or coverage lcov (LCOV) |
Run the same parser on both files, so both sides use identical path and counting rules.
Pattern A (default) - cached artifact. The merge target's last
green CI run uploads its coverage file; the PR job downloads it with
dawidd6/action-download-artifact (workflow, branch and
name select the run and artifact; if_no_artifact_found defaults to
fail):
- name: Restore baseline
uses: dawidd6/action-download-artifact@v3
with:
workflow: coverage.yml
branch: main
name: coverage-baseline
path: baseline/
if_no_artifact_found: warn
- name: Parse both runs
run: |
python scripts/parse_lcov.py coverage/lcov.info > current.json
python scripts/parse_lcov.py baseline/lcov.info > baseline.jsonPattern B (fallback) - recompute. Check out the merge target, run the tests with coverage, save the file, then check out the PR head and run again. About twice the runtime; use it when the artifact has expired or the baseline run is not deterministic.
Stop and fix the inputs if any check fails; a diff of bad inputs is worse than no report.
# scripts/coverage_diff.py
def pct(hit, found):
return round(100.0 * hit / found, 1) if found else None
def delta(now, then):
return None if now is None or then is None else round(now - then, 1)
def compute_diff(current, baseline):
base_idx = {f['path']: f for f in baseline}
cur_paths = {f['path'] for f in current}
rows = []
for f in current:
b = base_idx.get(f['path'])
line_now, branch_now = pct(f['lh'], f['lf']), pct(f['brh'], f['brf'])
line_then = pct(b['lh'], b['lf']) if b else None
branch_then = pct(b['brh'], b['brf']) if b else None
rows.append({'path': f['path'], 'is_new': b is None,
'line_now': line_now, 'line_delta': delta(line_now, line_then),
'branch_now': branch_now, 'branch_delta': delta(branch_now, branch_then)})
for path, b in base_idx.items(): # deleted files
if path not in cur_paths:
rows.append({'path': path, 'is_deleted': True,
'line_then': pct(b['lh'], b['lf'])})
return rowsNEW_FILE_MIN, REGRESSION_PP, NOISE_PP = 80, -5, 0.5 # tune per repo
def classify(row):
if row.get('is_deleted'):
return 'deleted'
if row['is_new']:
return 'new_below_threshold' if (row['line_now'] or 0) < NEW_FILE_MIN else 'new_ok'
d = row['line_delta']
if d is None or abs(d) < NOISE_PP:
return 'unchanged'
return 'regressed' if d <= REGRESSION_PP else 'declined' if d < 0 else 'improved'Sort regressions by line_delta ascending (largest drop first) and put
new below-threshold files at the top. Leave unchanged rows out of the
tables; report only their count. NOISE_PP hides the sub-0.5pp jitter
that test-order non-determinism produces.
## Coverage diff - `<sha>` vs `main` `<base-sha>` (baseline 2 h old)
**Overall:** line 84.2% (-0.3pp) | branch 71.5% (-0.1pp)
**Files changed:** 7 (3 regressed, 1 new, 2 improved, 1 deleted); 412 unchanged
### Regressions (3)
| File | Line% | Branch% |
|---|---|---|
| `src/checkout/cart.ts` | 65.4 (-12.8) | 50.0 (-25.0) |
### New files (1)
| File | Line% | Branch% |
|---|---|---|
| `src/checkout/discount-stack.ts` | 35.0 (below 80%) | 25.0 |
### Improvements (2)
| File | Line% | Branch% |
|---|---|---|
| `src/orders/list.ts` | 92.0 (+4.5) | 85.0 (+10.0) |
### Deleted (1)
| File | Was line% |
|---|---|
| `src/legacy/old-checkout.ts` | 22.0 |Keep improvements in the report: a report that only scolds gets disabled. Put a one-line summary above the tables so the reviewer sees it without scrolling:
Coverage 84.2% (-0.3pp) - 3 files regressed, 1 new file below threshold.- name: Generate diff report
run: python scripts/coverage_diff.py current.json baseline.json > diff.md
- name: Post / update PR comment
uses: marocchino/sticky-pull-request-comment@v3
with:
header: coverage-diff
path: diff.mdsticky-pull-request-comment uses header to find and
update its own earlier comment, so each push edits one comment instead
of adding another. After the first run, open the PR and confirm exactly
one coverage comment exists and its header shows the expected baseline
SHA; if not, fix the header value or the Step 3 path check and re-run
the job.
| Anti-pattern | Why it fails | Fix |
|---|---|---|
| Posting only the aggregate | Hides which file regressed | Per-file table sorted by drop (Steps 4-5) |
| A new comment per push | PR thread drowns in coverage churn | Sticky comment updated in place (Step 7) |
| Showing every unchanged file | 500-row tables bury the 3 regressions | Count unchanged files, list only changed ones |
| Diffing without the Step 3 checks | A path-root mismatch shows every file as new | Check inputs first |
| Hiding new files ("no baseline") | New files are where untested code enters | Always list them; flag sub-threshold ones |
| Ignoring deleted files | Deleting well-covered code moves the aggregate | List deletions |
genhtml, coverage html).SF, LF, LH, BRF, BRH, end_of_record).header.lcov-analysis (parsers and the pass/fail gate), jacoco-analysis, coverage-py-analysis, js-unit-tests (Jest/Vitest coverage output), test-coverage-targeter (which uncovered branches to target next).