Skip to content

codecov/patch under-reports coverage in templated code: llvm-cov's per-instantiation line records are never aggregated #92

Description

@Yaraslaut

Problem

scripts/coverage.sh already has a fix for one class of per-template-
instantiation noise in llvm-cov's coverage export
(scripts/aggregate_lcov_branches.py, added for branch/BRDA records —
see that script's own docstring): llvm-cov export -format=lcov emits a
separate coverage record per template instantiation of the same source
line, so a header-only function instantiated with N different type
arguments produces N copies of every line inside it. For branches, the
existing script aggregates these by source location before generating the
final .lcov, so a branch fully covered in aggregate isn't scored
"partial" just because one particular instantiation didn't take an arm.

The same duplication happens for plain line-hit counts (DA: records),
and nothing aggregates those.
aggregate_lcov_branches.py's main loop
passes every DA: line straight through unchanged
(scripts/aggregate_lcov_branches.py's final else: out.append(line)
branch) — it only rewrites BRDA/BRF/BRH. If instantiation A of a
function hits line 42 and instantiation B (a different lambda/model type)
never does, llvm-cov — and therefore Codecov's codecov/patch check —
counts line 42 as uncovered, even though the underlying source line is
exercised by real test coverage.

Confirmed real impact

Investigating PR #88's stuck codecov/patch percentage (94-95% against a
97.37% target) surfaced this exact pattern three times, each independently
confirmed via CI logs proving the "uncovered" line's owning test actually
ran and passed:

  • examples/common/testkit/pump.hpp: pumpUntil<Pred>'s timeout branch
    (lines 74-79) and awaitQt's deadline-throw (112-113) each appeared
    15+ times in the coverage HTML report — once per distinct lambda-type
    instantiation across the whole test suite. Both are genuinely tested
    (test_pump.cpp's "pumpUntil returns false on timeout without hanging"
    and "awaitQt timeout does not leave dangling references" cases); every
    other call site's own instantiation just never happens to time out,
    which is correct passing-test behavior, not a gap.
  • include/morph/core/remote.hpp:747 (acquireSharedInstance's final
    "ok" reply) — every line in its enclosing locked block shows covered;
    this exact line, immediately after the block, does not, despite an
    existing test ("two connections sharing a key reach one instance")
    demonstrably reaching it per CI logs.
  • examples/common/testkit/backend_rig.hpp's client<T>() bounds-check
    throw (lines 297-298/301) — a test named exactly
    "BackendRig::client() throws out_of_range past nClients in Socket mode"
    already exists and passes per CI logs, yet the line shows red; client<T>
    is instantiated with several different Model types across the test
    file (RigProbeModel, RigCounterModel, RigBlobModel), each a
    separate instantiation.

This inflates the real uncovered-line count in any header-only,
heavily-templated file (which describes most of morph's core: bridge.hpp,
completion.hpp, registry.hpp, forms.hpp, the ladder's own testkit
headers) well beyond what real test coverage actually leaves untested,
making codecov/patch's percentage systematically pessimistic for such
files and the target correspondingly harder to hit through test-writing
alone — no additional test can fix a report-level double-count.

Proposed fix

Extend scripts/aggregate_lcov_branches.py (or add a sibling step in
scripts/coverage.sh) to aggregate DA: line-hit records the same way it
already aggregates BRDA: branch records: for each source file, sum hit
counts across every instantiation's copy of the same line number before
emitting the final DA:<line>,<hits> record, instead of passing each
instantiation's own DA: line straight through. The coverage.json export
coverage.sh already generates (llvm-cov export -format=lcov step,
consumed for the BRDA aggregation) has the same per-instantiation
"segments"/hit-count data needed to do this for lines — no new llvm-cov
invocation should be required, only extending the existing script's
line-record handling to match its branch-record handling.

Not in scope here

This issue is about the tooling gap, not about writing more tests. Recent
work (PR #88) found and fixed several genuinely-real coverage gaps in
remote.hpp/backend_rig.hpp/presenter.hpp (deterministic preconditions
and forced concurrency races that had never been exercised) before
concluding the remainder is this reporting artifact — that work should not
be redone once this is fixed; re-running the fixed tooling against the
current test suite should simply show the true, already-adequate coverage
for the lines this issue describes.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions