lcov-analysis
Parses LCOV `.info` text files (the de-facto coverage interchange format produced by gcov, llvm-cov, Coverage.py via `py2lcov`, JaCoCo via `xml2lcov`, Devel::Cover, Jest via `lcov` reporter, NYC, and most others). Extracts per-file line / function / branch metrics from the canonical record keywords (TN/SF/FN/FNDA/FNF/FNH/BRDA/BRF/BRH/DA/LH/LF), computes the diff vs a baseline, and emits per-file gating verdicts. Use for PR coverage gates that don't depend on a specific language runtime.
Install with skills.sh (any agent)
npx skills add testland/qa --skill lcov-analysislcov-analysis
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.
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
LCOV record format, baseline diff, and CI wiring
View source (opens in new window)LCOV record format, baseline diff, and CI wiring
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.
Related skills
allure-reports
Configures Allure Report (test-runner adapter install, `allure-results` directory wiring, `categories.json` for failure classification, `history-trend.json` retention via the copy-history-between-runs pattern), runs the Allure CLI to convert `allure-results` to a static HTML site, and uploads the report as a CI artifact. Use when the team needs richer test reporting than JUnit XML - step-level attachments, per-test history, retry tracking, and severity / epic / feature labeling across framework-agnostic adapters (pytest, Jest, JUnit, TestNG, NUnit, Mocha). As a rich static HTML report generator, it is the open-source alternative to extentreports (JVM/.NET per-test HTML narrative); for hosted cross-run flakiness analytics rather than a static per-run report use currents-integration.
cobertura-analysis
Parses Cobertura XML coverage reports (the JVM-canonical format originally from the cobertura-cobertura tool, also emitted by JaCoCo `--coverage-xml`, coverage.py `--xml`, Istanbul / Jest `cobertura` reporter, gocover-cobertura, and dotnet's `coverlet`). Walks the coverage-04 DTD structure (coverage → packages → classes → methods → lines + conditions), computes per-file deltas, and emits PR-time gating verdicts. Use when the existing CI emits Cobertura XML - typical for JVM-heavy stacks and tools that ship Cobertura as a default reporter.
coverage-diff-reporter
Builds a per-PR coverage delta report from any pair of LCOV / Cobertura / JSON coverage outputs (current run + baseline from the merge target) - emits a per-file table with line% / branch% deltas, called-out new files, hidden drops (overall +0.1pp but one file -8pp), and a single-line PR-comment summary. Use when the team has coverage in CI but needs human-readable PR feedback that points at the specific file the reviewer should focus on, not just an aggregate number.
coverage-py-analysis
Configures coverage.py for Python projects - wires `coverage run` (replacing `python` for instrumentation), enables branch coverage via the `--branch` flag or `branch = True` config, manages the `.coverage` data file (single-process and `combine` for parallel pytest-xdist runs), authors `.coveragerc` with `source` / `omit` / `fail_under`, and emits the format the downstream tool needs (`coverage report` for terminal, `coverage xml` for Cobertura, `coverage html` for human review, `coverage lcov` for SaaS, `coverage json` for programmatic post-processing). Use for any Python test stack (pytest, unittest, nose) that needs PR-time coverage signal.
currents-integration
Wires Currents.dev cross-run test analytics into a Playwright suite: installs `@currents/playwright`, authors `currents.config.ts` (env-sourced `recordKey` + `projectId`), registers `currentsReporter()`, enables trace/video/screenshot artifacts, and runs via `npx pwc` so per-test traces stream to the Currents dashboard with over-time flakiness, slowest-test, and pass-rate trends. Use when a Playwright suite needs hosted cross-run suite-health analytics; for a static per-run report use extentreports or allure-reports, and to sync results into Jira test management use zephyr-integration or xray-integration.
extentreports
Configures ExtentReports v5 for a JVM (or .NET via `extentreports-dotnet`) test run: wires `ExtentSparkReporter`, `attachReporter`, `createTest`, the `info`/`pass`/`warning`/`skip`/`fail` log chain, screenshots via `MediaEntityBuilder`, hierarchical `createNode` parent/child tests, and category/author/device labels, emitting a static HTML report alongside JUnit XML for CI artifact upload. Use when a suite on the Aventstack ExtentReports stack wants a richer per-test HTML narrative than JUnit XML gives; for code-coverage reporting use jacoco-analysis, and for hosted cross-run flakiness analytics use currents-integration.
jacoco-analysis
Configures JaCoCo for JVM projects (Java / Kotlin / Scala / Groovy) - wires the runtime agent via `jacoco-maven-plugin` `prepare-agent`, generates per-build reports (HTML / XML / CSV) via the `report` goal, gates the build via the `check` goal with element / limit / minimum rules, parses the six native counters (instructions, branches, lines, methods, classes, cyclomatic complexity), and converts JaCoCo XML to LCOV / Cobertura when downstream tools need a different format. Use when the JVM build is Maven / Gradle and the team wants the canonical JVM coverage tool - or to convert JaCoCo output for cross-language coverage aggregation.
jest-coverage-analysis
Configures Jest's built-in coverage (Istanbul-instrumented `babel` provider or V8-native `v8` provider), wires the right `coverageReporters` for downstream consumption (`lcov` for SaaS / cross-tool, `cobertura` for Jenkins, `text-summary` for terminal, `html` for human review), authors per-file `coverageThreshold` rules that focus the gate on critical paths (vs the global-only foot-gun), and parses the per-file JSON output for PR-time deltas. Use when the project tests with Jest (or Vitest, which uses the same Istanbul/V8 provider) and the team needs PR-time coverage signal that's both local-runnable and CI-gateable.
junit-xml-analysis
Parses JUnit-format XML reports (the de-facto interchange format every CI ingests - Jenkins, GitHub Actions, GitLab, Buildkite, CircleCI) into structured, machine-readable per-suite and per-case metrics tables (passed / failed / errored / skipped, time, classname, message, stack), groups failures by classname for trend analysis, and distinguishes "new failures vs flakes" by cross-referencing the `flakyFailure` and `rerunFailure` rerun elements. Use when the downstream consumer is a dashboard, script, or aggregator - not when the goal is a human-readable prose summary (use test-run-summary-author for that). Single-run, in-XML aggregation only; for cross-run cross-environment roll-ups, use a cross-run test-suite aggregator.
test-coverage-targeter
Builds a "what to test next" recommendation by combining a coverage report (LCOV / Cobertura / coverage.py JSON / Jest JSON / JaCoCo XML) with the PR's `git diff`, ranking uncovered branches by risk × cost - risk weighted by McCabe cyclomatic complexity and code-churn frequency, cost weighted by the unit-test pyramid layer (unit tests cheaper than integration than E2E). Emits a prioritized list with concrete file:line targets and the test layer recommended for each. Use when a team has the budget to write 5 - 10 new tests and needs help picking which uncovered code to target first instead of blindly chasing 100% coverage.
test-run-summary-author
Build-an-X workflow that turns a structured test-run artifact (JUnit XML, Allure JSON, TestRail / Xray / Zephyr export) plus optional release context (version, build URL, deploy target) into a narrative markdown summary for release notes, an exec status update, or a stand-up Slack post. Distinct from the per-framework parsers junit-xml-analysis / allure-reports / coverage-diff-reporter, which emit structured tabular reports: this skill takes the same data and writes the human-readable narrative. Use when a manager needs a draft release note or stand-up summary from a single run; for cross-run trend analytics use currents-integration.
testrail-integration
Syncs test runs / results / cases between an automated test suite and TestRail (Gurock / Idera) - opens a Test Run for the build (`add_run`), batches per-case results back via `add_results_for_cases` (preferred over per-test `add_result_for_case` - N+1 API calls vs 1), maps the test framework's pass/fail/skip to TestRail status IDs, and attaches build URL + version + elapsed time. Use when the team's test management is standalone TestRail (not a Jira app) and automated suites must update it without a human copy-paste step; when the TCM is instead a Jira app use xray-integration (Xray) or zephyr-integration (Zephyr Scale), and for hosted cross-run flakiness analytics rather than TCM sync use currents-integration.
xray-integration
Imports CI test results into Xray for Jira - authenticates via the `client_id` + `client_secret` → JWT exchange (Cloud) or PAT / Basic (Server), posts to the format-specific `/api/v2/import/execution/*` endpoint (`/junit` for JUnit XML, `/cucumber` for Cucumber JSON, `/nunit` / `/testng` / `/robot` for the others), and maps automated results to existing Xray Test issues via the `xray-junit-extensions` `@XrayTest(key="...")` annotation. Use when the team uses the Xray Jira app to manage Test, Test Set, and Test Execution issue types and CI must keep those execution issues in sync; for the other Jira TCM app use zephyr-integration (Zephyr Scale), for standalone non-Jira TestRail use testrail-integration, and for hosted cross-run flakiness analytics rather than TCM sync use currents-integration.
zephyr-integration
Syncs automated test results to Zephyr Scale for Jira (formerly TM4J / SmartBear / Adaptavist): picks the product variant (Scale Cloud / Squad / Enterprise), authenticates with a long-lived API token as a Bearer header, opens a Test Cycle per build, posts executions via `POST /testexecutions` (or bulk JUnit via `/automations/executions/junit`), and maps test methods to Zephyr Test Cases via `@TestCaseKey`-style annotations. Use when the team's Jira test management is Zephyr Scale; for the Xray Jira app use xray-integration, for standalone TestRail use testrail-integration, and for over-time flakiness analytics rather than TCM sync use currents-integration.