repowise-dev / repowise-dev/repowise

[Bug] A file with zero coverable lines (LF:0) is ingested as 0% coverage, so coverage_gradient imputes what it documents it never will

Open
#2,193 3 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

bug help wanted
Dominant language
Python
Stars
6.7k
Forks
711
Avg merge
1d 13h
Merged PRs (30d)
439

Description

Describe the Bug

A file with zero coverable lines (LF:0) is ingested as line_coverage_pct = 0.0 instead of "not applicable". Downstream that is indistinguishable from a file that has coverable lines of which none were hit, so coverage_gradient and untested_hotspot both fire on a file that contains no executable code at all.

This bypasses the guarantee coverage_gradient documents for itself:

Absent coverage ≠ zero coverage. When no coverage report was ingested (line_coverage_pct is None) the biomarker is silent - it never imputes uncovered for missing data.

The guard is if cov is None: return []. It holds when no report was ingested, but a 0/0 record hands the detector a hard 0.0, so the biomarker imputes exactly what the docstring promises it never will.

The same function already handles the analogous case correctly one branch over — branch_coverage_pct becomes None when there are no branches, while line_coverage_pct becomes 0.0 when there are no lines:

# core/analysis/health/coverage/lcov.py:46-51
line_pct = (hit / total * 100.0) if total else 0.0     # 0 lines  -> "0% covered"
branch_pct: float | None
if has_branches and branches_found:
    branch_pct = branches_hit / branches_found * 100.0
else:
    branch_pct = None                                   # 0 branches -> "not applicable"

Steps to Reproduce

Minimal lcov with one type-only file (LF:0) and one genuinely uncovered file:

TN:
SF:src/types.ts
FNF:0
FNH:0
LF:0
LH:0
BRF:0
BRH:0
end_of_record
TN:
SF:src/real.ts
FNF:2
FNH:0
DA:1,0
DA:2,0
LF:2
LH:0
BRF:0
BRH:0
end_of_record
from repowise.core.analysis.health.coverage.lcov import parse_lcov

for f in parse_lcov(open("repro.lcov").read()).files:
    print(f"{f.file_path:16} coverable_lines={f.total_coverable_lines}  "
          f"line_pct={f.line_coverage_pct!r:8} branch_pct={f.branch_coverage_pct!r}")

Expected Behavior

The two records are not the same fact and should not collapse to the same value. A file with nothing to cover should be reported as unknown/not-applicable (None), the way branch_coverage_pct already is, so the coverage biomarkers stay silent on it.

Actual Behavior

src/types.ts     coverable_lines=0  line_pct=0.0      branch_pct=None
src/real.ts      coverable_lines=2  line_pct=0.0      branch_pct=None

Both report 0.0. total_coverable_lines does carry the distinction, but nothing downstream consults it before applying the coverage deductions.

On a real repo. A TypeScript barrel of 28 export interface / export type declarations and no executable statement (LF:0 FNF:0 BRF:0, emitted by @vitest/coverage-v8) scored:

coverage_gradient −2.00 · "100% of lines uncovered (0% line coverage)"
untested_hotspot −2.00 · critical · "Hotspot with 0% line coverage and 21 dependents"
score 2.5
weighted_deficit 2079 — 15.8% of the whole repo gap, ranked #2 in high_leverage_files

So fix_first's runner-up was a file with no code to fix. Excluding the module from the coverage report moved the repo from 2 alert files to 1 and the average from 8.04 to 8.12, which is the same bug seen from the other side.

Root cause (and why it is not a one-line fix)

FileCoverage cannot express the distinction for lines, but can for branches:

# core/analysis/health/coverage/model.py:19-21
file_path: str
line_coverage_pct: float          # no None
branch_coverage_pct: float | None  # has None

The three if total else 0.0 sites are a consequence of that asymmetry, and all four parsers share it:

  • coverage/lcov.py:46(hit / total * 100.0) if total else 0.0
  • coverage/cobertura.py:96 — same expression
  • coverage/clover.py:77(len(covered) / total_n * 100.0) if total_n else 0.0
  • coverage/repowise_json.py:111 — clamps pct with no zero-coverable guard

A fix presumably widens the field to float | None and returns None when there is nothing coverable, then lets the existing cov is None guards in coverage_gradient / coverage_gap / untested_hotspot do their job unchanged.

Worth deciding explicitly whether "no coverable lines" should be None (unknown) or 100.0 (vacuously covered). None seems right and matches the branch behaviour, but it is a product call — an lcov record whose producer genuinely instrumented the file and found nothing to instrument is arguably a different statement from no record at all.

Environment

  • OS: macOS 27.0.0 (Darwin, arm64)
  • Python version: 3.14.6
  • Repowise version: 0.49.0 (also present in 0.48.0)
  • Installation method: pipx
  • Coverage producer: @vitest/coverage-v8 4.x, reporter: ['text', 'lcov']

Additional Context

The distinction is already available at the point of the bug — total_coverable_lines is set in the same flush() that computes line_pct — so the parsers have everything they need to answer "not applicable" without consulting the source tree.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with core/analysis/health/coverage/model.py and the parser logic in coverage/lcov.py, coverage/cobertura.py, coverage/clover.py, and coverage/repowise_json.py. Decide how zero coverable lines should be represented, update the shared model and parser paths consistently, then verify that the existing coverage biomarkers remain silent for those records while genuinely uncovered files still report 0% coverage.

Written by the indexing model from the issue text.

Assessment

Tech stack
python
Domain
testing-qa, tooling
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
55/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.