Repository navigation
Coverage: export raw lcov (functions, branches) for annotated source views #46
Copy link
Copy link
Closed
Description
Use case
Veritas should show a source snippet annotated the way coverage services (Codecov, Coveralls) do:
- each executable line marked covered, uncovered or not executable;
- lines with branches marked partially covered when some branches never ran (an
ifwhoseelsewas never taken, a short-circuita && bwherebnever ran), with the untaken branch identifiable; - per-function coverage next to the complexity score, to rank complex code that is poorly tested.
Line hits alone give the first bullet. plz-out/log/coverage.json already has them as one marker per line (for example "NNNNNCNNNCNNNNC": C covered, N not executable; U for uncovered executable lines, which I have not seen in these fixtures). Partial coverage and function data need the raw reports described here, because Please flattens everything to per-line data and does not read lcov.
Findings
- Please accepts gcov, Go cover profile, Istanbul JSON and Cobertura XML in
$COVERAGE_FILE, and reduces all of them to per-line data. It does not read lcov. - Where richer data is lost today:
| Rule set | Tool output | What we hand Please |
|---|---|---|
| Rust | llvm-cov export -format=lcov |
lcov converted to line-only coverage |
| Swift | llvm-cov export -format=lcov |
lcov converted to Cobertura (lines only) |
| TypeScript (Deno, Vitest) | lcov | lcov converted to Cobertura (lines only) |
| Kotlin | JaCoCo XML (line, method, branch counters) | converted to gcov (lines only) |
Go (upstream go-rules) |
Go block profile (file:l.c,l.c stmts count) |
passed through; sub-line blocks, no branches |
Python (upstream python-rules) |
coverage.py Cobertura XML, branch measurement off | passed through |
- Neither Go nor Python provides lcov.
- A declared
test_outputsfile lands atplz-out/bin/<pkg>/<file>(observed with a probe target). Whether a missing declared file fails a plainplz testis not established: my later probes errored for a reason I did not isolate.
Decisions
- Format: lcov (
DAlines,FN/FNDAfunctions,BRDAbranches). It is the richest format all of these tools produce or convert to, and Codecov reads it directly. - Additive:
$COVERAGE_FILEstays as is for Please's merged report; the raw report is a side file. - Branch detail is in scope (the use case above), not optional.
Implementation plan
Phase 0: spike (decides the rest)
- Add a fixture per rule set with branchy code (an
if/else, a short-circuit expression, a function never called) and capture each tool's raw report. - Record, per tool, whether it emits
FN/FNDAandBRDA, and how accurate they are. To verify, not assumed:- Kotlin: JaCoCo reports missed and covered branches per line (
mb/cb), soBRDAshould be derivable. - TypeScript: V8-based lcov (Vitest) and
deno coverage --lcovshould includeBRDA. - Rust and Swift:
llvm-cov export -format=lcovbranch records depend on how the code was instrumented. Rust branch coverage may need an unstablerustcflag and Swift may need extraswiftcoptions; this could mean no branch data for a rule set.
- Kotlin: JaCoCo reports missed and covered branches per line (
- Settle the
test_outputsquestion: does a missing declared file failplz test, and is the file retained when the result is cached? Pick the mechanism (always write an empty file, or something else) from the result. - Output: a table of what each rule set can provide, and an updated plan if a tool cannot emit branches.
Phase 1: shared lcov package (on main, merged downstream)
tools/common/lcov: types (File{Path, Lines, Functions, Branches}), parser, writer, repo-relative path normalization, merge of reports by summing hits.- Unit tests, including merging overlapping reports and malformed input. Propagates to the language branches through the existing sync workflow.
Phase 2: per-language export
- Each test runner writes
<name>.lcovnext to its other results and the rule declares it intest_outputs. The file name includes the target name because outputs are per package and would collide otherwise. - Rust, Swift, TypeScript: stop discarding the lcov they already produce; normalize paths with the shared package.
- Kotlin: generate
FN/FNDAandBRDAfrom the JaCoCo report. - Written only when coverage is active; behaviour for plain
plz testfollows the Phase 0 result. - Docs for each rule set: location, format, what is and is not included.
Phase 3: consumer contract (Veritas)
- Contract: glob
plz-out/bin/**/*.lcov, merge with the shared semantics (line hits and branch-taken counts summed across test targets), keep the covered revision so the source matches. - States to render: covered, uncovered, not executable, partial (some
BRDAtaken zero times), with the untaken branch identifiable. - Per-function coverage from
FN/FNDA, falling back to line hits joined with function ranges where a tool lacks it.
Phase 4: tests and CI
- Branchy fixtures per language assert in CI that the
.lcovexists, is non-empty undercover, and contains a partially covered branch line and an uncalled function, where the tool supports them.
Phase 5: Go and Python (upstream rules)
- Out of scope for this repo. Options per language: document how consumers read Go's block profile and coverage.py data themselves, convert in Veritas, or contribute an lcov export or
branch = Trueupstream.
Acceptance criteria
- For each rule set whose tool supports it, a
<target>.lcovwith lines, functions and branches is retained afterplz cover, with repo-relative paths. - A rule set whose tool cannot emit branches or functions says so in its docs.
plz testwithout coverage is unaffected.- CI guards each language's export with a branchy fixture.
- Veritas can render a partially covered line from the files alone.
Risks
- Some tools may not emit usable branch data (see Phase 0); the plan then degrades to lines plus functions for that rule set.
- Branch semantics differ per tool (V8 block ranges, JaCoCo branch counters, LLVM regions), so "partial" will not mean exactly the same thing everywhere. The docs should say so.
- Adds output files per test target; size is small but should be measured.
Follow-up to #41.
Reactions are currently unavailable
Metadata
Metadata
Assignees
Labels
No labels
Activity
[-]Coverage: keep the raw lcov (functions, branches) instead of flattening to lines[/-][+]Coverage: export raw lcov (functions, branches) for annotated source views[/+]walterjgsp commentedon Oct 3, 2026
Phase 0 results
Each tool was run directly on a small branchy fixture: an
if/else ifchain, a short-circuita && b, and a function that is never called. The tests covered-1,5andboth(false, true). Versions: hermeticrustc1.85 (LLVM 19), Swift 6.3.3, Vitest 5.0.1 with the V8 provider, Deno 2.9, JaCoCo 0.8.12.What each tool provides
DAFN/FNDA(start line, mangled name)-Zcoverage-options=branch(works on stable 1.85 only viaRUSTC_BOOTSTRAP=1; still nightly-only in 1.97)BRDArecords appear and match the fixture (for exampleBRDA:4,0,0,0/BRDA:4,0,1,1forn == 0)DAFN/FNDA, including the never-called closure for the right side of&&BRF:0, and I found noswiftcoption for branch regionsDA(brace-only lines excluded)FN/FNDABRDA, correct for the fixtureDA(brace-only lines counted as executable)FN/FNDABRDA, but arm order differs from Vitest (BRDA:12,2,0,0vsBRDA:12,2,1,0)mi/ci<method>with start line and aMETHODcountermb/cb= missed and covered branch countsNotes:
FNDAandBRDAwould be synthesized as 0 or 1.bothhas 4), V8 counts source-level arms.Partial lines without branch records
Rust and Swift lcov has no branch data on stable, but
llvm-cov export -format=text(JSON) has sub-line segments with counts. For the Swift fixture, line 4 column 22 has count 0 (theelse ifbody never ran) and line 12 column 17 has count 0 (the&&right side). A line is partially covered when its segments mix zero and non-zero counts. This works on stable and needs no extra compiler flags, but it is not branch data in the lcov sense.test_outputsbehaviour (probe target, Please 17.31.2)plz-out/bin/<pkg>/<file>after the test runs. Underplz coverit holds what the test wrote.set -u; scripts must use${COVERAGE:-}and${COVERAGE_FILE:-}.plz cover, a plainplz testwas a cache hit and left the file untouched.plz cleanalso cleared the cache, so the next run was a real run. Consumers should therefore not assume the file exists after a cached run; they should runplz cover --rerunor read the file right after a fresh run.Consequences for the plan
BRDA.BRDA. Either write lcov with synthesizedBRDArecords (one not-taken branch per zero-count sub-region on a line that also has covered code), or add a small JSON side file. I'd start with synthesizedBRDAso the consumer reads a single format, documented as a heuristic.test_outputsnotes above).walterjgsp commentedon Oct 3, 2026
Status
Phases 0 to 3 are done on the repository side.
tools/common/lcov)docs/coverage-lcov.mdEach rule set keeps
plz-out/bin/<package>/<name>.lcovwith functions and branches (see the contract for what each provides and its limits). A branchy fixture and a CI smoke test per language guard it.Not released yet
The language branches have the code and changelog entries but no release: ts 0.6.0, kotlin 0.4.0, rust 0.6.0, swift 0.3.0 (the ts and kotlin versions also carry the earlier coverage fixes).
VERSIONand the tool hashes change when the release workflow runs.Remaining
plz-out/bin/**/*.lcov, merge per file, classify lines and render the annotated snippet. Not part of this repository.branch = Trueupstream.if/elseshows as uncovered lines rather than partial ones; real branch regions need an unstablerustcflag and have noswiftcoption yet.walterjgsp commentedon Oct 3, 2026
Update: Go and Python (#60)
Both languages can get the raw lcov report without forking
go-rulesorpython-rules.Go. Please already keeps each target's raw coverage file after
plz coveratplz-out/bin/<pkg>/.test_coverage_<name>; forgo_testit is the Go cover profile.lcov.FromGoProfileand thegocover2lcovcommand convert it to lcov (lines, functions parsed from the source, partial-line branch records). Counts are 0/1 undermode: set;a && bis never partial because Go's coverage has no short-circuit regions. The hidden file name is Please-internal, so CI should check it.Python. A
pytest-covrecipe, verified by hand onpython-rulesv2.1.3, produces lcov with functions and branches using only existingpython_testarguments (flags,env,data,test_outputs), with Please's own report unchanged. Two traps:COVERAGE_FILEis also coverage.py's data-file variable (it overwrote the file Please reads), and pex paths (name.pex/pkg/lib.py) match no repository file unless the sources are staged asdatawithPYTHONPATH=.. The parser now accepts coverage.py's text branch arms. The recipe is documented indocs/coverage-lcov.mdbut is not packaged as a rule and not run by CI here.Still open
python_test_lcovmacro wrapping the recipe would need a home. Apythonlanguage branch is awkward: the plugin namepythonclashes with the upstream plugin in any consumer that uses both, so it would need a different plugin name than the gitflow rules'Name = <lang>convention.plz-out/bin/**/*.lcovaftercover --rerun; for Go convert.test_coverage_*first. Not part of this repository.rustcflag and have noswiftcoption.walterjgsp commentedon Oct 3, 2026
Closing: everything on this repository's side is done and released.
Delivered
tools/common/lcovpackage: parse, write, merge, path normalisation, line classification (feat(common): add shared lcov package #47), region-based partial lines for Rust and Swift (feat(common): derive partial-line branches from llvm-cov regions #50), Go profile converter and thegocover2lcovcommand (feat(common): convert Go cover profiles to lcov; document Go and Python routes #60).plz-out/bin/<package>/<name>.lcovfor TypeScript (ts 0.6.0, feat(ts): export the raw lcov report of ts_test targets #48), Kotlin (kotlin 0.4.0, feat(kotlin): export the raw lcov report of kotlin_test targets #49), Rust (rust 0.6.0, feat(rust): export the raw lcov report of rust_test targets #51) and Swift (swift 0.3.0, feat(swift): export the raw lcov report of swift_test targets #52), each with a branchy fixture and a CI check on its output..test_coverage_<name>. Python: a verifiedpytest-covrecipe, documented only (decision: no packaging for now).docs/coverage-lcov.md(docs: add the raw lcov coverage report contract #53, feat(common): convert Go cover profiles to lcov; document Go and Python routes #60) and the ingestion spec on walterjgsp/veritas#20.Not done, for follow-ups if wanted
pytest-covrecipe..test_coverage_<name>file name used by the Go route.rustcflag) and Swift (noswiftcoption yet).