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.

  • Testing & QA
  • $coverage-analysis
  • 145 lines
  • 3 files
  • ~1,850 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- coverage-analysis

Installs to ~/.agents/skills/coverage-analysis/ for every project on this machine.

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

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.

~/.agents/skills/coverage-analysis/SKILL.md

--- name: coverage-analysis description: Sets up and interprets code coverage - coverage.py and pytest-cov, Vitest and Jest with V8 or Istanbul, Go coverprofile and covdata, JaCoCo,… license: MIT ---

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

  1. Read AGENTS.md and CI config for the current test command and any coverage step, threshold or upload (Codecov, Coveralls, SonarQube; uploads need network and a token).
  2. Find the config: [tool.coverage] or .coveragerc, vitest.config.* coverage block, jest.config.* coverageThreshold, jacoco in the build file, codecov.yml.
  3. Run the existing coverage command once and read the report before changing anything.
  4. Check the scope: are untested files reported at 0%, or silently missing? Are generated code, migrations, test helpers and vendored code excluded?

Tool setup

StackCommandKey settings
Pythonpytest --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
Vitestvitest run --coveragecoverage.provider: "v8", coverage.include: ["src/**"] so unimported files count, coverage.thresholds
Jestjest --coveragecollectCoverageFrom, coverageThreshold, coverageProvider: "v8"
Gogo test -covermode=atomic -coverpkg=./... -coverprofile=cover.out ./...go tool cover -func=cover.out, -html=cover.out
Go binaries / integrationgo build -cover, run with GOCOVERDIR=dirgo tool covdata percent -i=dir, go tool covdata textfmt -i=dir -o cover.out
Java / KotlinJaCoCo: Maven prepare-agent, report, check; Gradle jacocoTestReport, jacocoTestCoverageVerificationRules on BRANCH and LINE counters per bundle or package
Rustcargo llvm-cov --lcov --output-path lcov.info--fail-under-lines, --html; install needs network
.NETdotnet 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

SituationGate
New projectTotal threshold near current level, branch coverage included
Existing project, low coverageNo 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 APIEvery 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

ObservationUsual cause
Coverage jumped after a config changeFiles excluded or no longer discovered; check the file count
Untested files missing from reportOnly imported files are instrumented; set include / source / collectCoverageFrom
Go package at 0% though tested elsewhereMissing -coverpkg=./...; coverage counts only the package under test by default
Python subprocess or multiprocessing code shows uncoveredEnable parallel = true and coverage combine, or patch = ["subprocess"] in coverage 7.10+
Numbers differ between V8 and IstanbulDifferent counting of statements and branches; compare like with like
High coverage, bugs still escapeAssertions 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.yamlgenerated from this page

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.py187 lines, 6.8 KB
#!/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.

esc