Skip to content

Coverage: export raw lcov (functions, branches) for annotated source views #46

Closed

Description

@walterjgsp

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 if whose else was never taken, a short-circuit a && b where b never 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_outputs file lands at plz-out/bin/<pkg>/<file> (observed with a probe target). Whether a missing declared file fails a plain plz test is not established: my later probes errored for a reason I did not isolate.

Decisions

  • Format: lcov (DA lines, FN/FNDA functions, BRDA branches). It is the richest format all of these tools produce or convert to, and Codecov reads it directly.
  • Additive: $COVERAGE_FILE stays 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)

  1. 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.
  2. Record, per tool, whether it emits FN/FNDA and BRDA, and how accurate they are. To verify, not assumed:
    • Kotlin: JaCoCo reports missed and covered branches per line (mb/cb), so BRDA should be derivable.
    • TypeScript: V8-based lcov (Vitest) and deno coverage --lcov should include BRDA.
    • Rust and Swift: llvm-cov export -format=lcov branch records depend on how the code was instrumented. Rust branch coverage may need an unstable rustc flag and Swift may need extra swiftc options; this could mean no branch data for a rule set.
  3. Settle the test_outputs question: does a missing declared file fail plz 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.
  4. 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>.lcov next to its other results and the rule declares it in test_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/FNDA and BRDA from the JaCoCo report.
  • Written only when coverage is active; behaviour for plain plz test follows 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 BRDA taken 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 .lcov exists, is non-empty under cover, 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 = True upstream.

Acceptance criteria

  • For each rule set whose tool supports it, a <target>.lcov with lines, functions and branches is retained after plz cover, with repo-relative paths.
  • A rule set whose tool cannot emit branches or functions says so in its docs.
  • plz test without 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.

Activity

  1. changed the title [-]Coverage: keep the raw lcov (functions, branches) instead of flattening to lines[/-] [+]Coverage: export raw lcov (functions, branches) for annotated source views[/+] on Oct 3, 2026
  2. walterjgsp commented on Oct 3, 2026

    @walterjgsp
    ContributorAuthor

    Phase 0 results

    Each tool was run directly on a small branchy fixture: an if/else if chain, a short-circuit a && b, and a function that is never called. The tests covered -1, 5 and both(false, true). Versions: hermetic rustc 1.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

    Rule set Lines Functions Branches
    Rust DA FN/FNDA (start line, mangled name) None on stable. With the unstable -Zcoverage-options=branch (works on stable 1.85 only via RUSTC_BOOTSTRAP=1; still nightly-only in 1.97) BRDA records appear and match the fixture (for example BRDA:4,0,0,0 / BRDA:4,0,1,1 for n == 0)
    Swift DA FN/FNDA, including the never-called closure for the right side of && None: BRF:0, and I found no swiftc option for branch regions
    TypeScript, Vitest (V8) DA (brace-only lines excluded) FN/FNDA BRDA, correct for the fixture
    TypeScript, Deno DA (brace-only lines counted as executable) FN/FNDA BRDA, but arm order differs from Vitest (BRDA:12,2,0,0 vs BRDA:12,2,1,0)
    Kotlin (JaCoCo XML) per line mi/ci per <method> with start line and a METHOD counter per line mb/cb = missed and covered branch counts

    Notes:

    • JaCoCo has no hit counts, only covered or missed, so FNDA and BRDA would be synthesized as 0 or 1.
    • JaCoCo gives no method end line.
    • Branch semantics differ per tool: JaCoCo counts JVM-level branches (both has 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 (the else if body 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_outputs behaviour (probe target, Please 17.31.2)

    • A declared output is kept at plz-out/bin/<pkg>/<file> after the test runs. Under plz cover it holds what the test wrote.
    • A missing declared file does not fail the test; Please only logs a warning that it could not store it in the cache. So the runner does not strictly have to create an empty file, though doing so avoids the warning.
    • Please runs tests with set -u; scripts must use ${COVERAGE:-} and ${COVERAGE_FILE:-}.
    • A cached result is not tied to cover versus plain: after plz cover, a plain plz test was a cache hit and left the file untouched.
    • A cache hit does not recreate a file I deleted by hand. I could not test a cache hit on a clean tree, because plz clean also 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 run plz cover --rerun or read the file right after a fresh run.

    Consequences for the plan

    1. Functions work for every rule set today, from the lcov or JaCoCo data we already have. Phase 2 can ship them first.
    2. Branches by rule set:
      • TypeScript (both runners): real BRDA.
      • Kotlin: synthesized from JaCoCo counts. The data is coarse and has no counts.
      • Rust: opt-in nightly flag, or fall back to segments.
      • Swift: segments only.
    3. Rust and Swift: emit "partial line" information from the JSON export's segments instead of lcov BRDA. Either write lcov with synthesized BRDA records (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 synthesized BRDA so the consumer reads a single format, documented as a heuristic.
    4. Merging across runners: do not merge branch records by index when two runners cover the same file (Deno and Vitest disagree on arm order). Key branch records on line and arm index only within one report, or keep reports separate per target and let the consumer reconcile.
    5. Open item: how the Veritas consumer should treat a stale or cached output (see test_outputs notes above).
  3. walterjgsp commented on Oct 3, 2026

    @walterjgsp
    ContributorAuthor

    Status

    Phases 0 to 3 are done on the repository side.

    Phase Result
    0 spike findings in the comment above
    1 shared package #47 (tools/common/lcov)
    2 export ts #48, kotlin #49, rust #51, swift #52, plus shared region derivation #50
    3 contract #53, docs/coverage-lcov.md

    Each rule set keeps plz-out/bin/<package>/<name>.lcov with 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). VERSION and the tool hashes change when the release workflow runs.

    Remaining

    • Consumer (Veritas): read plz-out/bin/**/*.lcov, merge per file, classify lines and render the annotated snippet. Not part of this repository.
    • Go and Python: upstream rules, no lcov. Options: convert Go's block profile and coverage.py data in the consumer, or contribute an lcov export or branch = True upstream.
    • Open design points: Kotlin branch arms are anonymous (JaCoCo); Rust and Swift branches are synthesized from sub-line regions, so multi-line if/else shows as uncovered lines rather than partial ones; real branch regions need an unstable rustc flag and have no swiftc option yet.
  4. walterjgsp commented on Oct 3, 2026

    @walterjgsp
    ContributorAuthor

    Update: Go and Python (#60)

    Both languages can get the raw lcov report without forking go-rules or python-rules.

    Go. Please already keeps each target's raw coverage file after plz cover at plz-out/bin/<pkg>/.test_coverage_<name>; for go_test it is the Go cover profile. lcov.FromGoProfile and the gocover2lcov command convert it to lcov (lines, functions parsed from the source, partial-line branch records). Counts are 0/1 under mode: set; a && b is 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-cov recipe, verified by hand on python-rules v2.1.3, produces lcov with functions and branches using only existing python_test arguments (flags, env, data, test_outputs), with Please's own report unchanged. Two traps: COVERAGE_FILE is 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 as data with PYTHONPATH=.. The parser now accepts coverage.py's text branch arms. The recipe is documented in docs/coverage-lcov.md but is not packaged as a rule and not run by CI here.

    Still open

    • Python packaging. A python_test_lcov macro wrapping the recipe would need a home. A python language branch is awkward: the plugin name python clashes 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.
    • Consumer (Veritas): read plz-out/bin/**/*.lcov after cover --rerun; for Go convert .test_coverage_* first. Not part of this repository.
    • Real branch regions still need an unstable rustc flag and have no swiftc option.
  5. walterjgsp commented on Oct 3, 2026

    @walterjgsp
    ContributorAuthor

    Closing: everything on this repository's side is done and released.

    Delivered

    Not done, for follow-ups if wanted

    • Veritas implementation of the viewer (tracked in walterjgsp/veritas#20).
    • A Python CI check for the pytest-cov recipe.
    • Measuring the size of the extra output files.
    • A CI check for Please's hidden .test_coverage_<name> file name used by the Go route.
    • Real branch regions for Rust (needs an unstable rustc flag) and Swift (no swiftc option yet).
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

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions