Overview
LCOV is "a tool suite for manipulating and displaying code coverage information" with three command-line utilities: geninfo (creates LCOV data files from raw coverage data), lcov (captures, filters, manipulates, processes in parallel), and genhtml (HTML report generation) (lcov-readme (opens in new window)).
The toolchain is "language-agnostic (via converter scripts: llvm2lcov, py2lcov, perl2lcov, xml2lcov)" (lcov-readme (opens in new window)) - LCOV .info is the lingua franca every coverage UI (Coveralls, Codecov, Codacy, SonarQube, in-house dashboards) ingests.
This skill covers parsing the .info text format directly so the team can gate PRs without running the full HTML generation step. The sibling Cobertura XML format (same PR-gating shape, different parser) is covered in references/cobertura.md - pick whichever format the existing reporter produces.
When to use
How to use
Worked example
A single source file's .info record, and how to read it:
TN:
SF:src/checkout/cart.ts
FN:10,addItem
FN:32,removeItem
FNDA:42,addItem
FNDA:0,removeItem
FNF:2
FNH:1
DA:11,42
DA:12,42
DA:13,0
DA:33,0
DA:34,0
LF:5
LH:2
BRDA:13,0,0,42
BRDA:13,0,1,0
BRF:2
BRH:1
end_of_recordReading: cart.ts has 2 functions, 1 hit (50% function coverage); 5 lines, 2 hit (40% line); 2 branches, 1 hit (50% branch). removeItem was never called (FNDA:0). SF opens the record, DA:<line>,<count> gives per-line hits, LH/LF and BRH/BRF are the roll-ups, and end_of_record closes the file. The full record-keyword table is in references/format-diff-and-ci.md.
Parse
# scripts/parse_lcov.py
from collections import defaultdict
def parse_lcov(path):
files = []
cur = None
with open(path) as f:
for line in f:
line = line.strip()
if line.startswith('SF:'):
cur = {
'path': line[3:],
'functions': [],
'lines': {},
'branches': defaultdict(list),
'fnf': 0, 'fnh': 0,
'lf': 0, 'lh': 0,
'brf': 0, 'brh': 0,
}
elif line.startswith('FN:'):
lineno, name = line[3:].split(',', 1)
cur['functions'].append({'line': int(lineno), 'name': name, 'hits': 0})
elif line.startswith('FNDA:'):
hits, name = line[5:].split(',', 1)
for fn in cur['functions']:
if fn['name'] == name:
fn['hits'] = int(hits)
break
elif line.startswith('DA:'):
lineno, hits = line[3:].split(',', 1)
cur['lines'][int(lineno)] = int(hits.split(',')[0]) # checksum optional
elif line.startswith('BRDA:'):
lineno, block, branch, taken = line[5:].split(',', 3)
cur['branches'][int(lineno)].append({
'block': int(block),
'branch': int(branch),
'taken': 0 if taken == '-' else int(taken),
})
elif line.startswith(('FNF:', 'FNH:', 'LF:', 'LH:', 'BRF:', 'BRH:')):
key, val = line.split(':', 1)
cur[key.lower()] = int(val)
elif line == 'end_of_record':
files.append(cur)
cur = None
return filesDon't trust the FNF/FNH/LF/LH/BRF/BRH summary fields blindly - some buggy emitters produce summaries that don't match the per-line data. For correctness, recompute from lines, functions, branches.
Gate rules
A defensible per-file gate has three rules:
Per-file gates beat whole-repo gates: an aggregate drop hides which file caused it; per-file output gives the reviewer a direct target. The coverage_diff + gate reference implementation and the PR coverage-gate CI job are in references/format-diff-and-ci.md.
Anti-patterns
| Anti-pattern | Why it fails | Fix |
|---|---|---|
| Whole-repo gate only | Aggregate drops hide which file caused them; review focus is unclear. | Per-file gates; per-file PR comments. |
BRH < BRF ignored | A single uncovered branch on a critical path silently slips through. | Track branch% separately from line%; gate threshold differs. |
Trusting FNF/FNH/LF/LH summary fields without recomputing | Some emitters produce wrong summaries; gate verdict drifts from the data. | Recompute from per-line records (see the Parse note). |
| Gate against the PR's own merge base (running coverage twice) | Slow; flaky if coverage itself is non-deterministic. | Cache main's LCOV as an artifact; PRs diff against it (see the CI wiring in references). |
| Treating new test files as "new code, gate at 80%" | The new test file is the test, not the SUT. | Filter the file list to source paths only (e.g. src/**, not tests/**). |
| Strict mode that fails on any drop | Refactors that legitimately remove dead code drop coverage; team disables gate. | Allow whole-repo drop ≤0.5pp; allow per-file drop ≤5pp; only new files have a hard min. |
| One unified threshold for line + branch | Branch coverage is harder; identical thresholds always fail one or the other. | Separate thresholds (e.g. line 80, branch 70). |
Limitations
References
Cobertura XML - parsing, gating, and cross-tool normalization
View source (opens in a new tab) (opens in new window)Reference detail for lcov-analysis (opens in new window): the sibling Cobertura XML format, with the same PR-gating shape as LCOV but a different parser.
Cobertura is "a free Java tool that calculates the percentage of code accessed by tests" (cobertura-home (opens in new window)). Its XML report format - sometimes called coverage-04.dtd after its DTD - became the de-facto JVM coverage interchange and is now emitted by JaCoCo, coverage.py (--xml), Jest's cobertura reporter, Istanbul, gocover-cobertura, .NET's coverlet, and many CI plugins.
Schema (coverage-04.dtd)
Per cobertura-dtd (opens in new window), the DTD declares this hierarchy:
coverage
├── sources* (paths the report is rooted at)
└── packages
└── package* (one per package; in non-Java languages, often per-directory)
└── classes
└── class* (one per file, despite the name)
├── methods
│ └── method*
│ └── lines/line*
└── lines
└── line* (line | condition)Required attributes per cobertura-dtd (opens in new window):
| Element | Required attributes |
|---|---|
coverage | line-rate, branch-rate, lines-covered, lines-valid, branches-covered, branches-valid, complexity, version, timestamp |
package | name, line-rate, branch-rate, complexity |
class | name, filename, line-rate, branch-rate, complexity |
method | name, signature, line-rate, branch-rate, complexity |
line | number, hits, plus branch="false" (default) and condition-coverage="100%" (default) |
Two important nuances:
Sample document
<?xml version="1.0" ?>
<!DOCTYPE coverage SYSTEM "http://cobertura.sourceforge.net/xml/coverage-04.dtd">
<coverage line-rate="0.78" branch-rate="0.62" lines-covered="156" lines-valid="200"
branches-covered="31" branches-valid="50" complexity="0" version="2.1.1" timestamp="1715000000">
<sources>
<source>src</source>
</sources>
<packages>
<package name="checkout" line-rate="0.92" branch-rate="0.83" complexity="0">
<classes>
<class name="cart.ts" filename="checkout/cart.ts" line-rate="0.88" branch-rate="0.75" complexity="0">
<methods>
<method name="addItem" signature="(Item)V" line-rate="1.0" branch-rate="1.0" complexity="0">
<lines><line number="11" hits="42"/></lines>
</method>
</methods>
<lines>
<line number="11" hits="42"/>
<line number="12" hits="42"/>
<line number="13" hits="42" branch="true" condition-coverage="50% (1/2)"/>
<line number="33" hits="0"/>
</lines>
</class>
</classes>
</package>
</packages>
</coverage>The SYSTEM DOCTYPE URL above no longer resolves, but emitters still write that exact literal - see the hardcoded string in istanbul-reports' cobertura reporter (opens in new window) - so keep it in fixtures and disable external-entity resolution when parsing. A live copy of the DTD text is at raw.githubusercontent.com/cobertura/web (opens in new window).
The condition-coverage attribute on a branch line ("50% (1/2)") means one of two branch arms was hit. Parse it as /(\d+(?:\.\d+)?)% \((\d+)\/(\d+)\)/ to extract (pct, hit, total).
Parse
# scripts/parse_cobertura.py
import re
import xml.etree.ElementTree as ET
CC_RE = re.compile(r'(\d+(?:\.\d+)?)% \((\d+)/(\d+)\)')
def parse_cobertura(path):
root = ET.parse(path).getroot()
files = []
for pkg in root.findall('packages/package'):
for cls in pkg.findall('classes/class'):
lines = []
for ln in cls.findall('lines/line'):
hit = int(ln.get('hits', '0'))
line_data = {'number': int(ln.get('number')), 'hits': hit}
if ln.get('branch') == 'true':
cc = CC_RE.match(ln.get('condition-coverage', '0% (0/0)'))
if cc:
pct, br_hit, br_total = cc.groups()
line_data['branch'] = {
'pct': float(pct), 'hit': int(br_hit), 'total': int(br_total),
}
lines.append(line_data)
files.append({
'package': pkg.get('name'),
'name': cls.get('name'),
'filename': cls.get('filename'),
'line_rate': float(cls.get('line-rate')),
'branch_rate': float(cls.get('branch-rate')),
'lines': lines,
})
return {
'overall': {
'line_rate': float(root.get('line-rate')),
'branch_rate': float(root.get('branch-rate')),
'lines_covered': int(root.get('lines-covered')),
'lines_valid': int(root.get('lines-valid')),
'branches_covered': int(root.get('branches-covered')),
'branches_valid': int(root.get('branches-valid')),
},
'files': files,
}Diff vs baseline + gate
The same shape as the LCOV baseline-diff in the parent SKILL - pivot on filename, compute deltas, apply the per-file + whole-repo gate rules:
def diff(current, baseline):
base = {f['filename']: f for f in baseline['files']}
out = []
for f in current['files']:
b = base.get(f['filename'])
out.append({
'filename': f['filename'],
'line_now': f['line_rate'] * 100,
'line_then': b['line_rate'] * 100 if b else None,
'branch_now': f['branch_rate'] * 100,
'branch_then': b['branch_rate'] * 100 if b else None,
'is_new': b is None,
})
return outCross-tool normalization
When the team has Cobertura from one language and LCOV from another, emit a normalized intermediate (file → line% → branch% → uncovered line list) that both parsers feed:
def normalize_cobertura(parsed):
return [
{
'path': f['filename'],
'line_pct': f['line_rate'] * 100,
'branch_pct': f['branch_rate'] * 100,
'uncovered_lines': [ln['number'] for ln in f['lines'] if ln['hits'] == 0],
}
for f in parsed['files']
]The downstream gate / reporter consumes the normalized shape, language-agnostic.
CI shape
# Java with JaCoCo emitting Cobertura
- run: ./mvnw -B verify
- run: |
# JaCoCo's Cobertura output (via maven-jacoco-plugin's report goal):
cat target/site/jacoco/cobertura.xml > coverage.xml
# Python with coverage.py
- run: |
coverage run -m pytest
coverage xml -o coverage.xml
# JavaScript with Jest
- run: npx jest --coverage --coverageReporters=cobertura
# Then parse + gate (same shape regardless of upstream)
- run: python scripts/parse_cobertura.py coverage.xml > current.json
- run: python scripts/coverage_gate.py current.json baseline.jsonAnti-patterns
| Anti-pattern | Why it fails | Fix |
|---|---|---|
Treating line-rate as a percentage | The DTD specifies decimal 0 - 1 (cobertura-dtd (opens in new window)); code mistakes 0.85 for 85 / 100 mid-pipeline. | Multiply by 100 only in display layer; preserve decimal in storage. |
Pivoting on class@name instead of class@filename | "name" can be a Java FQCN that overlaps two physical files (inner classes). | Pivot on filename. |
Ignoring condition-coverage | Branch coverage drops invisible; line% looks fine while branch% degrades. | Parse the pct (hit/total) form; gate branch% separately. |
| Mixing Cobertura + LCOV without normalization | Branch coverage definitions differ; cross-tool sums lie. | Normalize first (above). |
Using coverage root's summary blindly | Some emitters miscompute the summary on multi-package merges. | Recompute by summing lines-covered / lines-valid from all class records. |
Loading multi-100MB XML with ET.parse | Whole-tree-in-memory; OOM on large reports. | ET.iterparse for streaming + element clearing. |
Assuming package@name == JVM package | Non-Java emitters use it for directory paths or arbitrary labels. | Treat package@name as a label only; group by filename. |
Limitations
References
LCOV record format, baseline diff, and CI wiring
View source (opens in a new tab) (opens in new window)Deep reference for the lcov-analysis SKILL.md. Consult for the full .info record-keyword table, the baseline-diff and gate reference implementation, and the PR coverage-gate CI job.
.info record-keyword reference
Per lcov-readme (opens in new window), the LCOV coverage data format uses these record types:
| Keyword | Meaning |
|---|---|
TN:<test> | Test name (often empty for whole-suite captures). |
SF:<path> | Source file path (one record set per source file). |
FN:<line>,<name> | Function declared at <line> named <name>. |
FNDA:<count>,<name> | Function <name> was called <count> times. |
FNF:<n> | Functions found in this file. |
FNH:<n> | Functions hit at least once. |
BRDA:<line>,<block>,<branch>,<taken> | Branch coverage data. |
BRF:<n> | Branches found. |
BRH:<n> | Branches hit. |
DA:<line>,<count> | Line <line> was executed <count> times. |
LH:<n> | Lines hit. |
LF:<n> | Lines found. |
end_of_record | Marks completion of the current source file's data. |
Per record, BRDA's fourth value <taken> is the hit count for that branch arm or - if the branch was never reached (the preceding line wasn't executed).
Diff vs baseline
def coverage_diff(current, baseline):
"""For each file, compute (line%_now - line%_then), (branch%_now - branch%_then)."""
base_by_path = {f['path']: f for f in baseline}
out = []
for f in current:
b = base_by_path.get(f['path'])
line_now = pct(f['lh'], f['lf'])
line_then = pct(b['lh'], b['lf']) if b else None
branch_now = pct(f['brh'], f['brf'])
branch_then = pct(b['brh'], b['brf']) if b else None
out.append({
'path': f['path'],
'line_now': line_now, 'line_then': line_then,
'branch_now': branch_now, 'branch_then': branch_then,
'is_new': b is None,
})
return out
def pct(num, denom):
return None if denom == 0 else round(100 * num / denom, 1)The interesting outputs are drops (line_now < line_then) and new files with sub-threshold coverage (is_new and line_now < gate).
Gate
def gate(diff, whole_drop_max=0.5, file_drop_max=5.0, new_file_min=80.0):
failures = []
for f in diff:
if f['is_new'] and f['line_now'] is not None and f['line_now'] < new_file_min:
failures.append((f['path'], 'new file below threshold', f['line_now']))
elif f['line_then'] is not None and f['line_now'] is not None:
drop = f['line_then'] - f['line_now']
if drop > file_drop_max:
failures.append((f['path'], f'line% dropped {drop:.1f}pp', drop))
# Whole-repo drop:
sum_then_lh = sum(f['line_then'] for f in diff if f['line_then'] is not None)
sum_now_lh = sum(f['line_now'] for f in diff if f['line_now'] is not None)
return failuresCI shape
- name: Run tests with LCOV reporter
run: npm test -- --coverage --coverageReporters=lcov
- name: Download baseline
uses: actions/download-artifact@v4
with:
name: lcov-main
path: baseline/
- name: Parse + diff + gate
run: |
python scripts/parse_lcov.py coverage/lcov.info > current.json
python scripts/parse_lcov.py baseline/lcov.info > baseline.json
python scripts/coverage_gate.py current.json baseline.json
- name: Upload current LCOV (becomes next PR's baseline when on main)
if: github.ref == 'refs/heads/main'
uses: actions/upload-artifact@v4
with:
name: lcov-main
path: coverage/lcov.info
retention-days: 90Cache main's LCOV as an artifact so PRs diff against it rather than recomputing the merge base's coverage twice.