Pre-computed context
Current branch: !git branch --show-current 2>/dev/null || echo "unknown"
Purpose
Coverage is a dynamic measure. This skill reads it statically: it parses an artifact a test run
already wrote and joins it to the functions in scope. Running the tests is the project's own job
and stays there, so nothing here executes a test command, starts a runner, or installs a tool.
Two numbers come out. Line coverage per file and per function is read from the artifact. CRAP per
function is derived: comp^2 * (1 - cov/100)^3 + comp, where comp is the function's cyclomatic
complexity from the sibling audit-complexity script, run over the same scope.
| Format |
Written by |
What the join gets from it |
lcov .info |
most JavaScript and C/C++ toolchains, coverage lcov |
line hits; function records from FN/FNDA (1.x) or FNL/FNA (2.2 and later), which is where a function end line can come from at all |
| Cobertura XML |
gcovr, coverlet, kcov (which is how a Bash lane gets an artifact) |
line hits, <sources> prefixes, and <method> regions with their hit flag |
| coverage.py JSON |
coverage json |
executed and missing lines, and the per-function regions it has carried since 7.6.0 |
| Go cover profile |
go test -coverprofile |
statement counts over line ranges, which is not a line table: the profile never says which lines carry the statements, so a Go file reports the statement ratio go tool cover -func prints, labels it cov_source: statement-ratio, and reports lines_executable and lines_hit as null. The format names no functions either, so a Go function reports coverage_pct and crap as null, and its line counts as null unless a line-measuring artifact also covered the file |
The SQLite data file coverage.py writes while measuring is never read: its schema is internal by
its own documentation and free to change without a major version bump. Run coverage json or
coverage lcov and point this skill at the result.
Run it
"${CLAUDE_SKILL_DIR}/scripts/audit-coverage.sh" # the change, with the artifact auto-discovered
"${CLAUDE_SKILL_DIR}/scripts/audit-coverage.sh" --artifacts build/lcov.info src/ # an explicit artifact and an explicit scope
"${CLAUDE_SKILL_DIR}/scripts/audit-coverage.sh" --all --artifacts coverage.xml --artifacts cover.out
"${CLAUDE_SKILL_DIR}/scripts/audit-coverage.sh" --json --all # the code-metrics/v1 document instead of markdown
With no --artifacts and no coverage.artifacts in the config, the well-known names are looked
for under the repository root, at most two directory levels deep and never inside node_modules,
.git or vendor: coverage/lcov.info, lcov.info, coverage.xml, cobertura.xml,
coverage.json, coverage.out, cover.out. Each artifact's format is detected from its content,
not its extension.
Present the markdown report as printed: the scope, the "Coverage of this run" table, the
references with their provenance, then one row per file and per function. Keep the --json
document when the numbers feed a comparison: pass it to /verification:measure metrics when the
verification plugin is installed, treating status: empty on either side as INCONCLUSIVE;
otherwise keep the JSON beside your notes and compare by hand.
Reading the numbers
- A file row is the artifact's own line table:
coverage_pct, lines_executable, lines_hit.
A file a Go cover profile covers is the exception: the profile weighs statements, so the
percentage is its statement ratio, cov_source reads statement-ratio, and both line counts
are null.
- A function row exists for every function whose complexity collector reported a real end line.
It carries
coverage_pct, cyclomatic, crap, and, in the JSON, cov_source and hit.
cov_source says where the coverage came from: artifact-region when the artifact carried the
function's own region (coverage.py functions, a Cobertura <method>, an lcov 2.2 FNL end
line), line-range when the range came from the complexity collector, in which case nested
function ranges are subtracted from the parent first, statement-ratio on a file row whose
percentage came from a Go profile's statements rather than from a line table, and ambiguous
on a function the join refused. The markdown table renders the values; the per-row cov_source
and hit fields are in the --json document.
- A function the artifact names by a short name binds to the function whose line range holds the
lines the artifact recorded, not to whichever function the name happens to end at. Two
run
methods in one file and a record named run are one function's coverage, and the range says
whose. Where nothing separates the candidates, because the artifact placed none of them at a
line or because two of them fall inside the same range, the join is refused rather than guessed:
the row reads cov_source: ambiguous; coverage, line counts, hit, and CRAP are null because
those numbers would be a guess, while cyclomatic from the complexity row is kept because that
collector already measured it; it carries a coverage-ambiguous label, and the lane's run row
turns partial and names the functions it left unjoined. A missing coverage number an operator
can see beats a wrong number they cannot.
hit is the artifact's function-hit flag. When it says the function was never entered,
coverage_pct is 0 rather than the 1/N a declaration line executed at import would produce.
- A function with no executable lines in the artifact reports
coverage_pct: null and
crap: null, never 0: a fabricated 0 percent would produce a fabricated maximal CRAP. Its
lines_executable and lines_hit are 0 when an artifact that measures lines covers the file
and carries none in that range, and null when no artifact covering the file measures lines at
all, which is every function of a file only a Go profile covers.
- CRAP is Savoia and Evans' formula (Agitar Labs, 2007). It is a description of two numbers you
already have, not a validated predictor of change risk, and no standard sets a threshold for it.
/code-metrics:principles carries the provenance, the rename history, and the caveats; read it
before you quote a CRAP number at anyone.
- A lane whose resolved complexity collector reports no function end lines gets a
<lane>/crap
row with status: not-applicable and that reason. Bash is that lane in this version: no
maintained Bash collector reports an end line, so Bash CRAP is a stated gap, not a null.
- A lane matched by fewer than all of its scope files carries
status: partial and the reason
partial, N of M scope files present in the artifacts, so a total miss never reads as "no
executable lines" and the document cannot settle as complete while a row says N of M. A lane
no artifact covers is unavailable with that count; when no artifact was found at all, the
reason lists every path searched. A lane that left a function unjoined is partial for that
reason too.
- Whether the covered code is actually checked by its tests is a different question, and coverage
alone cannot answer it: a line can execute under a test that asserts nothing.
/mutation-testing:audit owns that question when the mutation-testing plugin is installed;
without it, say that the coverage number is an execution count and stop there.
- Exit 0 whenever a report was produced, including an empty one; exit 2 for a usage error, which
includes a named artifact or scope path that does not exist; exit 3 when a complexity collector
ran and produced nothing parseable, with its stderr in the run table.
Configuration
Everything tunable resolves through .claude/code-metrics.yaml (user-global, team, local
overlay; per-key override; keys in ${CLAUDE_PLUGIN_ROOT}/reference/config.md):
| Key |
Default |
Effect |
coverage.artifacts |
[] |
Explicit artifact paths; empty means auto-discover. A listed path that does not exist is a usage error, so a stale entry is loud |
coverage.path_prefix_strip |
[] |
Prefixes removed from artifact paths before the join, for compiled-output layouts (dist/, build/) |
coverage.reference |
null |
No bar. A number counts rows below it as over_reference and nothing else |
coverage.crap.reference |
null |
No bar; no standard sets one for CRAP |
/code-metrics:setup writes the team file and probes the collectors.
What this skill does not do
- It never runs tests, starts a test runner, or installs a coverage tool. It reads what a build
already wrote; if there is no artifact, the report says so and stops.
- It never reads the SQLite data file coverage.py writes while measuring.
- It never judges. A reference here is a value to count against, and no finding, severity, or
exit-code gate follows from one.
- It does not measure branch, condition, or MC/DC coverage. Those records are parsed past, not
reported, because the join is a line-level one.
Next
- The numbers feed a before-and-after comparison:
/verification:measure metrics.
- Coverage looks healthy but the tests may assert nothing:
/mutation-testing:audit.
- A number is about to be quoted at someone:
/code-metrics:principles.
Gotchas
- The complexity collector decides which functions can carry CRAP at all. With no collector
resolving for a lane, that lane has no function rows and its
<lane>/crap row is unavailable
naming the collector's own reason. File-level coverage is unaffected: the scope the join keys on
is the one the audits measure, not the files a complexity collector emitted rows for, so a file
with no functions still reports its lines.
- Path shapes differ between artifact and source tree. The join normalizes both sides, strips the
repository root and the configured prefixes, then falls back to a component-wise suffix match
and finally to a unique basename (which is how a Go profile's module path resolves). Both
fallbacks require a single winner: an artifact path that fits two scoped files equally well
(two services vendoring the same
pkg/a.py) stays unmatched and shows up in the partial count,
because attributing it to either would credit one service with the other's coverage. The
basename fallback needs a single winner on the artifact side too, counted across every artifact
read rather than within one: when two services each ship a handler.go and only one is in
scope, the out-of-scope path stays unmatched instead of merging its coverage into the scoped
file, even though the scope names exactly one candidate.
- An artifact older than the source it describes joins by line number and will be wrong without
saying so. Re-run the tests before reading the numbers when the diff has moved lines.
- Two artifacts covering one file are merged by keeping the larger hit count per line, so a line
is never counted twice. Two artifacts covering one function are folded the same way: the hit
flag is the larger of the two, so a suite that never entered the function cannot report it at 0
percent beside a file row the other suite already showed as covered. Two Go profiles covering
one file merge block by block on the profile's own
start.col,end.col identity and are totalled
after that, so two test shards that entered different blocks report what they covered between
them rather than what the luckier shard covered alone.
- A Go profile and a line artifact naming the same
.go file do not average or overwrite each
other: the profile's statement ratio is that file's exact native measure and wins the
percentage, the row says statement-ratio, and the line counts stay null rather than reading as
the counts behind a number they did not produce. The lane's collector still names both formats
read. Function rows are line-based, so a Go function does take its region from a line artifact
when one covers it.
- A Cobertura report with several
<source> roots is read against all of them: a relative class
filename takes the first root under which that path exists in the scanned tree, and falls back
to the first root when no candidate exists. Two roots that both hold the file is an ambiguity
the report cannot settle, so the first one wins and the class lands under it. The probe settles
anything only when the roots are relative, or absolute and present on the machine running the
audit. A report built elsewhere declares absolute roots (the usual coverlet, kcov and gcovr shape
for a CI run) naming directories this machine does not have, so every candidate misses and every
class lands under the first root the way it did before the roots were read at all. Rewriting a
root from another machine onto the local tree needs a mapping the report does not carry.
- Two functions in one file whose qualified names share a tail (
A.run and B.run) can bind to
each other's artifact region when the artifact carries only the short name; the file-level
numbers are unaffected.
1---2name: audit-coverage3description: Read coverage artifacts a build already wrote (lcov `.info` including the 2.2 `FNL`/`FNA` records, Cobertura XML, coverage.py JSON, a Go cover profile) and report line coverage per file and function for a change, a path, or the tree, plus CRAP per function (`comp^2 * (1 - cov/100)^3 + comp`, Savoia and Evans 2007) from the sibling complexity numbers. It runs no tests and installs nothing. A missing artifact warns, naming the paths searched, never a silent skip; a function with no executable lines reports null, not 0. Coverage and CRAP default to a null reference (no bar) with its provenance, and no finding, severity, or exit-code gate is emitted. Use when: 'coverage of this change', 'coverage per function', 'CRAP score', 'which functions are complex and untested', 'read the lcov report', 'coverage.xml', 'how covered is this file'; for complexity alone use /code-metrics:audit-complexity; for what these numbers can and cannot say, /code-metrics:principles.4---56## Pre-computed context78Current branch: !`git branch --show-current 2>/dev/null || echo "unknown"`910## Purpose1112Coverage is a dynamic measure. This skill reads it statically: it parses an artifact a test run13already wrote and joins it to the functions in scope. Running the tests is the project's own job14and stays there, so nothing here executes a test command, starts a runner, or installs a tool.1516Two numbers come out. Line coverage per file and per function is read from the artifact. CRAP per17function is derived: `comp^2 * (1 - cov/100)^3 + comp`, where `comp` is the function's cyclomatic18complexity from the sibling `audit-complexity` script, run over the same scope.1920| Format | Written by | What the join gets from it |21|---|---|---|22| lcov `.info` | most JavaScript and C/C++ toolchains, `coverage lcov` | line hits; function records from `FN`/`FNDA` (1.x) or `FNL`/`FNA` (2.2 and later), which is where a function end line can come from at all |23| Cobertura XML | gcovr, coverlet, kcov (which is how a Bash lane gets an artifact) | line hits, `<sources>` prefixes, and `<method>` regions with their hit flag |24| coverage.py JSON | `coverage json` | executed and missing lines, and the per-function regions it has carried since 7.6.0 |25| Go cover profile | `go test -coverprofile` | statement counts over line ranges, which is not a line table: the profile never says which lines carry the statements, so a Go file reports the statement ratio `go tool cover -func` prints, labels it `cov_source: statement-ratio`, and reports `lines_executable` and `lines_hit` as null. The format names no functions either, so a Go function reports `coverage_pct` and `crap` as null, and its line counts as null unless a line-measuring artifact also covered the file |2627The SQLite data file coverage.py writes while measuring is never read: its schema is internal by28its own documentation and free to change without a major version bump. Run `coverage json` or29`coverage lcov` and point this skill at the result.3031## Run it3233```bash34"${CLAUDE_SKILL_DIR}/scripts/audit-coverage.sh" # the change, with the artifact auto-discovered35"${CLAUDE_SKILL_DIR}/scripts/audit-coverage.sh" --artifacts build/lcov.info src/ # an explicit artifact and an explicit scope36"${CLAUDE_SKILL_DIR}/scripts/audit-coverage.sh" --all --artifacts coverage.xml --artifacts cover.out37"${CLAUDE_SKILL_DIR}/scripts/audit-coverage.sh" --json --all # the code-metrics/v1 document instead of markdown38```3940With no `--artifacts` and no `coverage.artifacts` in the config, the well-known names are looked41for under the repository root, at most two directory levels deep and never inside `node_modules`,42`.git` or `vendor`: `coverage/lcov.info`, `lcov.info`, `coverage.xml`, `cobertura.xml`,43`coverage.json`, `coverage.out`, `cover.out`. Each artifact's format is detected from its content,44not its extension.4546Present the markdown report as printed: the scope, the "Coverage of this run" table, the47references with their provenance, then one row per file and per function. Keep the `--json`48document when the numbers feed a comparison: pass it to `/verification:measure metrics` when the49`verification` plugin is installed, treating `status: empty` on either side as INCONCLUSIVE;50otherwise keep the JSON beside your notes and compare by hand.5152## Reading the numbers5354- A file row is the artifact's own line table: `coverage_pct`, `lines_executable`, `lines_hit`.55 A file a Go cover profile covers is the exception: the profile weighs statements, so the56 percentage is its statement ratio, `cov_source` reads `statement-ratio`, and both line counts57 are null.58- A function row exists for every function whose complexity collector reported a real end line.59 It carries `coverage_pct`, `cyclomatic`, `crap`, and, in the JSON, `cov_source` and `hit`.60- `cov_source` says where the coverage came from: `artifact-region` when the artifact carried the61 function's own region (coverage.py `functions`, a Cobertura `<method>`, an lcov 2.2 `FNL` end62 line), `line-range` when the range came from the complexity collector, in which case nested63 function ranges are subtracted from the parent first, `statement-ratio` on a file row whose64 percentage came from a Go profile's statements rather than from a line table, and `ambiguous`65 on a function the join refused. The markdown table renders the values; the per-row `cov_source`66 and `hit` fields are in the `--json` document.67- A function the artifact names by a short name binds to the function whose line range holds the68 lines the artifact recorded, not to whichever function the name happens to end at. Two `run`69 methods in one file and a record named `run` are one function's coverage, and the range says70 whose. Where nothing separates the candidates, because the artifact placed none of them at a71 line or because two of them fall inside the same range, the join is refused rather than guessed:72 the row reads `cov_source: ambiguous`; coverage, line counts, hit, and CRAP are null because73 those numbers would be a guess, while cyclomatic from the complexity row is kept because that74 collector already measured it; it carries a `coverage-ambiguous` label, and the lane's run row75 turns `partial` and names the functions it left unjoined. A missing coverage number an operator76 can see beats a wrong number they cannot.77- `hit` is the artifact's function-hit flag. When it says the function was never entered,78 `coverage_pct` is 0 rather than the 1/N a declaration line executed at import would produce.79- A function with no executable lines in the artifact reports `coverage_pct: null` and80 `crap: null`, never 0: a fabricated 0 percent would produce a fabricated maximal CRAP. Its81 `lines_executable` and `lines_hit` are 0 when an artifact that measures lines covers the file82 and carries none in that range, and null when no artifact covering the file measures lines at83 all, which is every function of a file only a Go profile covers.84- CRAP is Savoia and Evans' formula (Agitar Labs, 2007). It is a description of two numbers you85 already have, not a validated predictor of change risk, and no standard sets a threshold for it.86 `/code-metrics:principles` carries the provenance, the rename history, and the caveats; read it87 before you quote a CRAP number at anyone.88- A lane whose resolved complexity collector reports no function end lines gets a `<lane>/crap`89 row with `status: not-applicable` and that reason. Bash is that lane in this version: no90 maintained Bash collector reports an end line, so Bash CRAP is a stated gap, not a null.91- A lane matched by fewer than all of its scope files carries `status: partial` and the reason92 `partial, N of M scope files present in the artifacts`, so a total miss never reads as "no93 executable lines" and the document cannot settle as `complete` while a row says `N of M`. A lane94 no artifact covers is `unavailable` with that count; when no artifact was found at all, the95 reason lists every path searched. A lane that left a function unjoined is `partial` for that96 reason too.97- Whether the covered code is actually checked by its tests is a different question, and coverage98 alone cannot answer it: a line can execute under a test that asserts nothing.99 `/mutation-testing:audit` owns that question when the `mutation-testing` plugin is installed;100 without it, say that the coverage number is an execution count and stop there.101- Exit 0 whenever a report was produced, including an empty one; exit 2 for a usage error, which102 includes a named artifact or scope path that does not exist; exit 3 when a complexity collector103 ran and produced nothing parseable, with its stderr in the run table.104105## Configuration106107Everything tunable resolves through `.claude/code-metrics.yaml` (user-global, team, local108overlay; per-key override; keys in `${CLAUDE_PLUGIN_ROOT}/reference/config.md`):109110| Key | Default | Effect |111|---|---|---|112| `coverage.artifacts` | `[]` | Explicit artifact paths; empty means auto-discover. A listed path that does not exist is a usage error, so a stale entry is loud |113| `coverage.path_prefix_strip` | `[]` | Prefixes removed from artifact paths before the join, for compiled-output layouts (`dist/`, `build/`) |114| `coverage.reference` | `null` | No bar. A number counts rows below it as `over_reference` and nothing else |115| `coverage.crap.reference` | `null` | No bar; no standard sets one for CRAP |116117`/code-metrics:setup` writes the team file and probes the collectors.118119## What this skill does not do120121- It never runs tests, starts a test runner, or installs a coverage tool. It reads what a build122 already wrote; if there is no artifact, the report says so and stops.123- It never reads the SQLite data file coverage.py writes while measuring.124- It never judges. A reference here is a value to count against, and no finding, severity, or125 exit-code gate follows from one.126- It does not measure branch, condition, or MC/DC coverage. Those records are parsed past, not127 reported, because the join is a line-level one.128129## Next130131- The numbers feed a before-and-after comparison: `/verification:measure metrics`.132- Coverage looks healthy but the tests may assert nothing: `/mutation-testing:audit`.133- A number is about to be quoted at someone: `/code-metrics:principles`.134135## Gotchas136137- The complexity collector decides which functions can carry CRAP at all. With no collector138 resolving for a lane, that lane has no function rows and its `<lane>/crap` row is `unavailable`139 naming the collector's own reason. File-level coverage is unaffected: the scope the join keys on140 is the one the audits measure, not the files a complexity collector emitted rows for, so a file141 with no functions still reports its lines.142- Path shapes differ between artifact and source tree. The join normalizes both sides, strips the143 repository root and the configured prefixes, then falls back to a component-wise suffix match144 and finally to a unique basename (which is how a Go profile's module path resolves). Both145 fallbacks require a single winner: an artifact path that fits two scoped files equally well146 (two services vendoring the same `pkg/a.py`) stays unmatched and shows up in the partial count,147 because attributing it to either would credit one service with the other's coverage. The148 basename fallback needs a single winner on the artifact side too, counted across every artifact149 read rather than within one: when two services each ship a `handler.go` and only one is in150 scope, the out-of-scope path stays unmatched instead of merging its coverage into the scoped151 file, even though the scope names exactly one candidate.152- An artifact older than the source it describes joins by line number and will be wrong without153 saying so. Re-run the tests before reading the numbers when the diff has moved lines.154- Two artifacts covering one file are merged by keeping the larger hit count per line, so a line155 is never counted twice. Two artifacts covering one function are folded the same way: the hit156 flag is the larger of the two, so a suite that never entered the function cannot report it at 0157 percent beside a file row the other suite already showed as covered. Two Go profiles covering158 one file merge block by block on the profile's own `start.col,end.col` identity and are totalled159 after that, so two test shards that entered different blocks report what they covered between160 them rather than what the luckier shard covered alone.161- A Go profile and a line artifact naming the same `.go` file do not average or overwrite each162 other: the profile's statement ratio is that file's exact native measure and wins the163 percentage, the row says `statement-ratio`, and the line counts stay null rather than reading as164 the counts behind a number they did not produce. The lane's `collector` still names both formats165 read. Function rows are line-based, so a Go function does take its region from a line artifact166 when one covers it.167- A Cobertura report with several `<source>` roots is read against all of them: a relative class168 filename takes the first root under which that path exists in the scanned tree, and falls back169 to the first root when no candidate exists. Two roots that both hold the file is an ambiguity170 the report cannot settle, so the first one wins and the class lands under it. The probe settles171 anything only when the roots are relative, or absolute and present on the machine running the172 audit. A report built elsewhere declares absolute roots (the usual coverlet, kcov and gcovr shape173 for a CI run) naming directories this machine does not have, so every candidate misses and every174 class lands under the first root the way it did before the roots were read at all. Rewriting a175 root from another machine onto the local tree needs a mapping the report does not carry.176- Two functions in one file whose qualified names share a tail (`A.run` and `B.run`) can bind to177 each other's artifact region when the artifact carries only the short name; the file-level178 numbers are unaffected.