Coverage Analysis skill for Codex
Gate the diff, ratchet the total, read the gaps. Codex configures coverage with branch counting and the right file scope, so untested files show up at 0% instead of vanishing. It gates pull requests on coverage of changed lines with a bundled diff-coverage script for LCOV, Cobertura and Go profiles, ratchets the total instead of demanding a vanity number, and ranks untested code by churn and business risk.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- coverage-analysis Installs to ~/.agents/skills/coverage-analysis/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install coverage-analysis The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project coverage-analysis Installs to .agents/skills/coverage-analysis/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/coverage-analysis.zipunzip -o coverage-analysis.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) coverage-analysis PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $coverage-analysis, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Sets up and interprets code coverage - coverage.py and pytest-cov, Vitest and Jest with V8 or Istanbul, Go coverprofile and covdata, JaCoCo, cargo-llvm-cov, coverlet - with branch coverage on, correct include and exclude scopes, CI thresholds that ratchet instead of blocking, patch coverage on changed lines, and a gap review that ranks untested code by risk instead of chasing a percentage. Use when adding coverage to CI, choosing a threshold, explaining a coverage drop, finding what critical code is untested, or when a coverage number looks too good to trust.
Use it when
- Adding or tuning coverage reporting and thresholds in CI
- A coverage number changed and you need to know why
- Choosing what untested code deserves tests first
- Measuring coverage of the lines a pull request changed
Use something else when
- Coverage is fine but you doubt the tests catch bugs: use mutation-testing
- You want to run only the tests affected by a change: use test-impact-analysis
How to check the result
Adding an untested file shows it at 0%, removing a test changes the uncovered lines it covered, and the CI gate fails on a branch that adds untested code.
The mistake it prevents
Measuring only line coverage over imported files, which hides untested branches and files that no test ever loads.
Coverage tells you what code never ran during tests; it cannot tell you whether what ran was checked. Done well means: branch coverage measured over the right files, a CI gate on new and changed code rather than a vanity total, and a ranked list of risky untested paths that someone can act on.
Inspect first
- Read
AGENTS.mdand CI config for the current test command and any coverage step, threshold or upload (Codecov, Coveralls, SonarQube; uploads need network and a token). - Find the config:
[tool.coverage]or.coveragerc,vitest.config.*coverageblock,jest.config.*coverageThreshold,jacocoin the build file,codecov.yml. - Run the existing coverage command once and read the report before changing anything.
- Check the scope: are untested files reported at 0%, or silently missing? Are generated code, migrations, test helpers and vendored code excluded?
Tool setup
| Stack | Command | Key settings |
|---|---|---|
| Python | pytest --cov=src/pkg --cov-branch --cov-report=term-missing --cov-report=xml | [tool.coverage.run] branch = true, source; [tool.coverage.report] exclude_also, skip_covered |
| Vitest | vitest run --coverage | coverage.provider: "v8", coverage.include: ["src/**"] so unimported files count, coverage.thresholds |
| Jest | jest --coverage | collectCoverageFrom, coverageThreshold, coverageProvider: "v8" |
| Go | go test -covermode=atomic -coverpkg=./... -coverprofile=cover.out ./... | go tool cover -func=cover.out, -html=cover.out |
| Go binaries / integration | go build -cover, run with GOCOVERDIR=dir | go tool covdata percent -i=dir, go tool covdata textfmt -i=dir -o cover.out |
| Java / Kotlin | JaCoCo: Maven prepare-agent, report, check; Gradle jacocoTestReport, jacocoTestCoverageVerification | Rules on BRANCH and LINE counters per bundle or package |
| Rust | cargo llvm-cov --lcov --output-path lcov.info | --fail-under-lines, --html; install needs network |
| .NET | dotnet test --collect:"XPlat Code Coverage" | coverlet writes Cobertura XML; ReportGenerator for HTML |
# pyproject.toml
[tool.coverage.run]
branch = true
source = ["src/myapp"]
omit = ["src/myapp/migrations/*", "src/myapp/**/generated/*"]
[tool.coverage.report]
exclude_also = ["if TYPE_CHECKING:", "raise NotImplementedError", "@(abc\\.)?abstractmethod"]
show_missing = true
// vitest.config.ts (excerpt)
test: {
coverage: {
provider: "v8",
include: ["src/**/*.{ts,tsx}"],
exclude: ["src/**/*.stories.tsx", "src/**/generated/**"],
reporter: ["text-summary", "lcov", "json-summary"],
thresholds: { lines: 80, branches: 70, functions: 80, statements: 80 },
},
},
Always turn on branch coverage. Line coverage marks if (a && b) doThing() as covered after
one test that never exercises the false paths.
Threshold policy
| Situation | Gate |
|---|---|
| New project | Total threshold near current level, branch coverage included |
| Existing project, low coverage | No total gate; patch coverage on changed lines (for example 80%) plus a ratchet that fails if total drops more than 0.5 points |
| Critical module (payments, auth, billing, data deletion) | Per-path threshold higher than the global one (coverageThreshold["./src/billing/"], JaCoCo package rule) |
| Library with public API | Every exported function exercised at least once |
Never set a total threshold above what the team can sustain; 100% targets produce tests
that execute code without asserting anything. Mutation testing (mutation-testing) is the
better check of test strength once coverage is reasonable.
Patch coverage on a branch
Measure only what a change touched:
python3 scripts/diff_coverage.py coverage/lcov.info --base origin/main --fail-under 80
python3 scripts/diff_coverage.py coverage.xml --base origin/main # Cobertura (coverage.py, coverlet, JaCoCo converted)
python3 scripts/diff_coverage.py cover.out --base origin/main # Go coverprofile
The script reads LCOV, Cobertura XML or a Go profile, diffs against the base with git, and
prints covered/uncovered changed lines per file. It needs the base ref locally (git fetch origin main, which needs network). In CI, diff-cover coverage.xml --compare-branch=origin/main --fail-under=80 or Codecov patch status do the same job.
Gap review workflow
- Produce a per-file report sorted by missed branches, not by percentage
- Join it with risk signals: change frequency (
git log --format= --name-only --since=6.months | sort | uniq -c | sort -rn | head -30), business criticality, recent bugs and incidents - For the top items, read the uncovered lines; classify each as: needs a test, dead code
(delete it, see
dead-code-removal), defensive code that cannot be reached, or legitimately excluded (generated, debug-only) - Write tests for the risky gaps first: error handling, permission checks, money and date arithmetic, state transitions
- Exclude only with a reason in config or a pragma comment (
# pragma: no cover - platform specific)
Reading suspicious numbers
| Observation | Usual cause |
|---|---|
| Coverage jumped after a config change | Files excluded or no longer discovered; check the file count |
| Untested files missing from report | Only imported files are instrumented; set include / source / collectCoverageFrom |
| Go package at 0% though tested elsewhere | Missing -coverpkg=./...; coverage counts only the package under test by default |
| Python subprocess or multiprocessing code shows uncovered | Enable parallel = true and coverage combine, or patch = ["subprocess"] in coverage 7.10+ |
| Numbers differ between V8 and Istanbul | Different counting of statements and branches; compare like with like |
| High coverage, bugs still escape | Assertions are weak; run mutation testing on that module |
Verification
- Delete or comment out one test and confirm the reported coverage and the uncovered lines change where expected.
- Add a new untested file; it must appear in the report at 0%.
- In CI, confirm the gate fails on a branch that adds untested code, then passes when a test is added.
Output template
## Coverage review: <repo or module>
Tool: <tool + version>, branch coverage on. Scope: <included paths>, excluded: <list + reasons>
Total: lines 74.2%, branches 61.0% (baseline <x>). Patch coverage on this branch: 68% (gate 80%)
### Highest-risk gaps
1. `src/billing/refund.py:88-121` - partial refund rounding and currency branch never run;
changed 14 times in 6 months; incident #412. Proposed tests: <list>
### Dead or unreachable code found
### Gate recommendation
<threshold changes, per-path rules, ratchet>
Gotchas
- Coverage of test files themselves inflates the number; exclude test directories.
- Snapshot tests and smoke tests generate coverage with almost no assertions.
- Merging reports from shards: combine raw data (
coverage combine,go tool covdata merge,nyc merge) before computing percentages; averaging shard percentages is wrong. - A coverage gate that fails on unrelated total drops trains people to ignore it. Gate the diff, ratchet the total.
What the zip contains
Codex reads bundled files only when a step points to them. Every one is shown here, so you can review it before installing.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "Coverage Analysis"
short_description: "Gate the diff, ratchet the total, read the gaps"
default_prompt: "$coverage-analysis Add coverage to our CI and fail PRs that add untested code" scripts/diff_coverage.py
#!/usr/bin/env python3
"""Report coverage of the lines changed on a branch (patch / diff coverage).
Reads one coverage report (LCOV, Cobertura XML, or a Go coverprofile), asks git which
lines changed against a base ref, and prints covered/uncovered changed lines per file.
Usage:
python3 diff_coverage.py coverage/lcov.info --base origin/main
python3 diff_coverage.py coverage.xml --base origin/main --fail-under 80
python3 diff_coverage.py cover.out --base main --include-worktree
Exit codes: 0 ok, 1 below --fail-under, 2 usage or parse error.
"""
from __future__ import annotations
import argparse
import re
import subprocess
import sys
import xml.etree.ElementTree as ET
from collections import defaultdict
from pathlib import Path
Coverage = dict[str, dict[int, int]] # path -> {line: hits}
def parse_lcov(text: str) -> Coverage:
cov: Coverage = defaultdict(dict)
current = None
for line in text.splitlines():
if line.startswith("SF:"):
current = line[3:].strip()
elif line.startswith("DA:") and current:
num, hits = line[3:].split(",")[:2]
n, h = int(num), int(float(hits))
cov[current][n] = max(cov[current].get(n, 0), h)
elif line == "end_of_record":
current = None
return cov
def parse_cobertura(text: str) -> Coverage:
root = ET.fromstring(text)
sources = [s.text.strip() for s in root.iter("source") if s.text and s.text.strip()]
cov: Coverage = defaultdict(dict)
for cls in root.iter("class"):
fname = cls.get("filename", "")
# Prefer a path relative to the repo; keep the raw name and let suffix matching resolve it.
path = fname
for src in sources:
candidate = Path(src) / fname
if candidate.exists():
path = str(candidate)
break
for ln in cls.iter("line"):
n, h = int(ln.get("number", "0")), int(ln.get("hits", "0"))
cov[path][n] = max(cov[path].get(n, 0), h)
return cov
def parse_go(text: str) -> Coverage:
cov: Coverage = defaultdict(dict)
pat = re.compile(r"^(.+):(\d+)\.\d+,(\d+)\.\d+ \d+ (\d+)$")
for line in text.splitlines()[1:]:
m = pat.match(line.strip())
if not m:
continue
path, start, end, count = m.group(1), int(m.group(2)), int(m.group(3)), int(m.group(4))
for n in range(start, end + 1):
cov[path][n] = max(cov[path].get(n, 0), count)
return cov
def load(path: Path) -> Coverage:
text = path.read_text(encoding="utf-8", errors="replace")
head = text.lstrip()[:200]
if head.startswith("<?xml") or head.startswith("<coverage"):
return parse_cobertura(text)
if head.startswith("mode:"):
return parse_go(text)
if "SF:" in text:
return parse_lcov(text)
raise ValueError(f"unrecognised coverage format: {path}")
def changed_lines(base: str, include_worktree: bool) -> dict[str, set[int]]:
args = ["git", "diff", "--unified=0", "--no-color", "--no-ext-diff"]
args += [base] if include_worktree else [f"{base}...HEAD"]
out = subprocess.run(args, check=True, capture_output=True, text=True).stdout
changes: dict[str, set[int]] = defaultdict(set)
current = None
for line in out.splitlines():
if line.startswith("+++ "):
target = line[4:]
current = None if target == "/dev/null" else re.sub(r"^b/", "", target)
elif line.startswith("@@") and current:
m = re.search(r"\+(\d+)(?:,(\d+))?", line)
if m:
start, count = int(m.group(1)), int(m.group(2) or 1)
changes[current].update(range(start, start + count))
return changes
def match(cov: Coverage, repo_path: str) -> dict[int, int] | None:
if repo_path in cov:
return cov[repo_path]
best = None
for p in cov:
norm = p.replace("\\", "/")
if norm.endswith("/" + repo_path) or repo_path.endswith("/" + norm.lstrip("./")):
if best is None or len(p) < len(best):
best = p
return cov[best] if best else None
def ranges(nums: list[int]) -> str:
out, start, prev = [], None, None
for n in sorted(nums):
if start is None:
start = prev = n
elif n == prev + 1:
prev = n
else:
out.append(f"{start}-{prev}" if start != prev else str(start))
start = prev = n
if start is not None:
out.append(f"{start}-{prev}" if start != prev else str(start))
return ", ".join(out)
def main() -> int:
ap = argparse.ArgumentParser(description="Coverage of changed lines against a git base ref.")
ap.add_argument("report", type=Path, help="lcov.info, cobertura coverage.xml or Go cover.out")
ap.add_argument("--base", default="origin/main", help="base ref (default origin/main)")
ap.add_argument("--include-worktree", action="store_true", help="diff base against the working tree, not HEAD")
ap.add_argument("--fail-under", type=float, default=None, help="exit 1 if diff coverage %% is below this")
a = ap.parse_args()
try:
cov = load(a.report)
changes = changed_lines(a.base, a.include_worktree)
except (OSError, ValueError, ET.ParseError) as exc:
print(f"error: {exc}", file=sys.stderr)
return 2
except subprocess.CalledProcessError as exc:
print(f"error: git diff failed: {exc.stderr.strip()}", file=sys.stderr)
return 2
total_cov = total_exec = 0
rows, missing_files = [], []
for path in sorted(changes):
file_cov = match(cov, path)
if file_cov is None:
missing_files.append(path)
continue
executable = [n for n in changes[path] if n in file_cov]
if not executable:
continue
covered = [n for n in executable if file_cov[n] > 0]
uncovered = [n for n in executable if file_cov[n] == 0]
total_cov += len(covered)
total_exec += len(executable)
rows.append((path, len(covered), len(executable), uncovered))
print(f"diff coverage vs {a.base}\n")
for path, c, e, unc in rows:
pct = 100 * c / e
print(f" {pct:5.1f}% {c:>4}/{e:<4} {path}")
if unc:
print(f" uncovered lines: {ranges(unc)}")
if missing_files:
print("\n changed files absent from the report (not measured, or not source):")
for p in missing_files:
print(f" {p}")
if total_exec == 0:
print("\nno changed executable lines found in the report")
return 0
pct = 100 * total_cov / total_exec
print(f"\ntotal: {total_cov}/{total_exec} changed executable lines covered = {pct:.1f}%")
if a.fail_under is not None and pct < a.fail_under:
print(f"FAIL: below --fail-under {a.fail_under}")
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.