Skip to content

check-coverage passes when there is no coverage data to check #610

Open

Description

@mahirhir

check-coverage exits 0 when there is no coverage data to check. The report for the same run says Unknown% ( 0/0 ), so the fact that nothing was measured is known at report time and does not reach the threshold check.

Reproduction

Fresh directory, npm init -y, npm i c8@12.0.0.

  1. A run that produces no coverage data:
$ npx c8 --check-coverage --lines 100 node -e "1"
----------|---------|----------|---------|---------|-------------------
File      | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
----------|---------|----------|---------|---------|-------------------
All files |       0 |        0 |       0 |       0 |
----------|---------|----------|---------|---------|-------------------
exit code: 0
  1. The same run with --reporter=text-summary:
Statements   : Unknown% ( 0/0 )
Branches     : Unknown% ( 0/0 )
Functions    : Unknown% ( 0/0 )
Lines        : Unknown% ( 0/0 )
  1. Control, so the threshold is visibly working when there is something to measure. lib.js holds one function, t.js is require("./lib.js"):
$ npx c8 --check-coverage --lines 100 node t.js
All files |   66.66 |      100 |       0 |   66.66 |
ERROR: Coverage for lines (66.66%) does not meet global threshold (100%)
exit code: 1

Three more shapes of the same thing, all exit 0:

  • npx c8 --check-coverage --lines 100 --include 'src/**' node t.js, with the same real test as the control and a glob that matches none of it. The table prints 0 in every column and the gate passes.
  • npx c8 --check-coverage --per-file --lines 100 node -e "1".
  • npx c8 check-coverage --temp-directory ./empty-dir --lines 100, which prints nothing at all and exits 0.

Where it comes from

Two independent paths reach the same result, in case that is useful:

  • checkCoverage() in lib/commands/check-coverage.js does summary[key].pct < thresholds[key]. For an empty map, istanbul's CoverageSummary sets pct: 'Unknown' (istanbul-lib-coverage/lib/coverage-summary.js), and 'Unknown' < 100 is false, so nothing is ever below a threshold.
  • With --per-file, map.files() is empty, so the forEach body never runs and there is nothing to fail.

What I am not proposing

I am not saying istanbul's Unknown is wrong. A file with no branches should not fail a branch threshold, and that is the same mechanism doing something useful. I also have no view I would defend on which way c8 should go: fail on an empty coverage map by default, warn, put it behind a flag, or document that check-coverage means "no measured metric is below the threshold". Each of those changes what existing CI does, including for people who run c8 over a process that legitimately produces no JS coverage, so the choice looks like yours rather than mine. No PR from me. If you land on a direction and want it written up, I am glad to do that.

Of the shapes above, the --include one seems most likely to happen by accident. A glob that stops matching after a directory is renamed leaves a coverage gate that always passes, and nothing in the output says it stopped measuring.

Activity

  1. mahirhir commented on Sep 12, 2026

    @mahirhir
    Author

    Re-ran this on c8@latest, which is still 12.0.0, so nothing has moved under it. Same result, and this time with the control in the same table so the zeros mean something:

    === c8 12.0.0 / node v24.15.0 ===
    CONTROL: real coverage under threshold   exit=1  threshold_error=1  All files | 75 | 100 | 50 | 75 |
    empty: no coverage data at all           exit=0  threshold_error=0  All files |  0 |   0 |  0 |  0 |
    empty: --per-file                        exit=0  threshold_error=0  All files |  0 |   0 |  0 |  0 |
    empty: include glob matches nothing      exit=0  threshold_error=0  All files |  0 |   0 |  0 |  0 |
    empty: check-coverage over empty dir     exit=0  threshold_error=0  <no table>
    

    Every one of those is --lines 100. The control shows the threshold is alive in that exact invocation; the four below it are the same flag against nothing.

    Two reporters describe the same empty run differently, which is the part I find most useful for locating it:

    text table    All files | 0 | 0 | 0 | 0
    text-summary  Statements : Unknown% ( 0/0 )
                  Lines      : Unknown% ( 0/0 )
    

    text-summary is right. The table prints 0, which reads as a real measurement of zero, and the threshold check sees the 'Unknown' that text-summary shows:

    > 'Unknown' < 100
    false
    > 0 < 100
    true
    > NaN < 100
    false

    So summary[key].pct < thresholds[key] is false for an empty map, and false means "not below threshold", which means pass. It is the same shape as a NaN comparison: the value that means we could not measure this is ordered against the threshold rather than excluded from the comparison.

    I am not asking for a particular fix and I know the ecosystem-compatibility question here is real (istanbul's pct: 'Unknown' is longstanding, and people do run check-coverage in matrix jobs where some shards legitimately produce nothing). If it helps, the options as I see them, cheapest first:

    1. Treat a non-numeric pct as a distinct third outcome and fail the check with its own message, rather than routing it through the same < that numeric percentages use.
    2. Fail only when the coverage map is empty and a threshold was explicitly requested, leaving pct: 'Unknown' alone for anyone parsing reports.
    3. Leave the exit code and make the table stop printing 0 for an unmeasured run, so the two reporters at least agree.

    Happy to send a patch for whichever of those you would take, or none if this is intended. Mostly I wanted the reproduction on record with its control attached, since "exit 0" and "exit 0 because there was nothing to check" are indistinguishable in CI.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions