Files
Jukka Aho 6f50c73660 feat(scripts): add coverage.jl
scripts/coverage.jl | 284 ++++++++++++++++++++++++++++++++++++++++++++++++++++  1 file changed, 284 insertions(+)
2026-05-09 16:30:28 +03:00

285 lines
9.9 KiB
Julia

#!/usr/bin/env julia
# SPDX-FileCopyrightText: 2015-2026 Jukka Aho
# SPDX-License-Identifier: MIT
#
# Run the JuliaFEM test suite under `--code-coverage=user`, summarise the
# resulting `.cov` files, and emit `lcov.info` for editor / CI tooling.
#
# Usage:
# julia scripts/coverage.jl # full pass: tests + summary
# julia scripts/coverage.jl --summary-only # skip tests, summarise existing .cov
# julia scripts/coverage.jl --threshold 95 # all src/ (incl. legacy files)
# julia scripts/coverage.jl --threshold-live 95 # shipped runtime only (see summarise_live)
# julia scripts/coverage.jl --legacy # tests with JULIAFEM_ENABLE_LEGACY=1
# julia scripts/coverage.jl --top 50 # show top-N least-covered files
#
# Side effects:
# - Writes `.cov` files next to every source file under `src/`.
# - Writes `coverage/lcov.info` for editor coverage gutters / Codecov.
# - Prints a per-file table sorted by uncovered LOC.
#
# Coverage tooling (Coverage.jl) lives in the dedicated `scripts/coverage/`
# environment so the package's runtime and test deps stay clean.
using Pkg
using Printf
using Logging
const ROOT = normpath(joinpath(@__DIR__, ".."))
const COV_ENV = joinpath(@__DIR__, "coverage")
const SRC_DIR = joinpath(ROOT, "src")
const LCOV_DIR = joinpath(ROOT, "coverage")
const LCOV_PATH = joinpath(LCOV_DIR, "lcov.info")
const COVERAGE_UUID = Base.UUID("a2441757-f6aa-5fb2-8edb-039e3f45d037")
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
mutable struct Args
summary_only::Bool
legacy::Bool
threshold::Union{Nothing,Float64}
threshold_live::Union{Nothing,Float64}
top_n::Int
end
function parse_args(argv::Vector{String})
a = Args(false, false, nothing, nothing, 25)
i = 1
while i length(argv)
arg = argv[i]
if arg == "--summary-only"
a.summary_only = true
elseif arg == "--legacy"
a.legacy = true
elseif arg == "--threshold"
i += 1
i length(argv) || error("--threshold expects a value")
a.threshold = parse(Float64, argv[i])
elseif startswith(arg, "--threshold=")
a.threshold = parse(Float64, split(arg, "=", limit=2)[2])
elseif arg == "--threshold-live"
i += 1
i length(argv) || error("--threshold-live expects a value")
a.threshold_live = parse(Float64, argv[i])
elseif startswith(arg, "--threshold-live=")
a.threshold_live = parse(Float64, split(arg, "=", limit=2)[2])
elseif arg == "--top"
i += 1
i length(argv) || error("--top expects a value")
a.top_n = parse(Int, argv[i])
elseif arg in ("-h", "--help")
print(stderr, help_text())
exit(0)
else
error("Unknown argument: $arg")
end
i += 1
end
return a
end
function help_text()
out = IOBuffer()
for line in eachline(@__FILE__)
startswith(line, "#") || break
println(out, line)
end
return String(take!(out))
end
# ---------------------------------------------------------------------------
# Test invocation
# ---------------------------------------------------------------------------
function clean_existing_coverage!(src_dir::AbstractString)
n = 0
for (root, _, files) in walkdir(src_dir)
for f in files
if endswith(f, ".cov")
rm(joinpath(root, f); force=true)
n += 1
end
end
end
return n
end
function run_tests_with_coverage(; legacy::Bool)
@info "Running test suite with coverage instrumentation" legacy
julia = Base.julia_cmd()
env = copy(ENV)
if legacy
env["JULIAFEM_ENABLE_LEGACY"] = "1"
else
delete!(env, "JULIAFEM_ENABLE_LEGACY")
end
cmd = setenv(
`$(julia) --project=$(ROOT) -e "using Pkg; Pkg.test(\"JuliaFEM\"; coverage=true)"`,
env,
)
return success(run(cmd; wait=true))
end
# ---------------------------------------------------------------------------
# Coverage gathering (loaded from the side env)
# ---------------------------------------------------------------------------
function load_coverage_pkg()
Pkg.activate(COV_ENV)
Pkg.instantiate()
return Base.require(Base.PkgId(COVERAGE_UUID, "Coverage"))
end
function summarise(Coverage; src_dir::AbstractString)
coverage = with_logger(Logging.NullLogger()) do
Base.invokelatest(Coverage.process_folder, src_dir)
end
coverage = Base.invokelatest(Coverage.merge_coverage_counts, coverage)
rows = Vector{NamedTuple{(:file, :covered, :lines, :pct, :uncovered),
Tuple{String,Int,Int,Float64,Int}}}()
total_covered = 0
total_lines = 0
for fc in coverage
rel = relpath(fc.filename, ROOT)
c, l = Base.invokelatest(Coverage.get_summary, fc)
total_covered += c
total_lines += l
pct = l == 0 ? 100.0 : 100 * c / l
push!(rows, (file=rel, covered=c, lines=l, pct=pct, uncovered=(l - c)))
end
return rows, total_covered, total_lines
end
function print_table(rows; top_n::Int)
sort!(rows; by=r -> (r.uncovered, -r.lines), rev=true)
n_show = min(top_n, length(rows))
println()
println("Files with the most uncovered lines (top $n_show of $(length(rows))):")
println()
@printf " %-60s %8s %8s %8s\n" "file" "covered" "total" "pct"
@printf " %-60s %8s %8s %8s\n" repeat("-", 60) repeat("-", 8) repeat("-", 8) repeat("-", 8)
for r in Iterators.take(rows, n_show)
@printf " %-60s %8d %8d %7.2f%%\n" trunc_left(r.file, 60) r.covered r.lines r.pct
end
println()
end
# Group rows by top-level src/ subdirectory and summarise.
function print_subdir_breakdown(rows)
groups = Dict{String,Tuple{Int,Int,Int}}() # subdir => (covered, lines, files)
for r in rows
# `r.file` looks like "src/<subdir>/...". Bucket by <subdir>.
parts = split(r.file, '/')
subdir = length(parts) 2 ? parts[2] : "<root>"
c, l, n = get(groups, subdir, (0, 0, 0))
groups[subdir] = (c + r.covered, l + r.lines, n + 1)
end
ordered = sort(collect(groups); by=g -> g.second[2], rev=true)
println("Coverage by src/ subdirectory:")
println()
@printf " %-22s %6s %8s %8s %8s\n" "subdir" "files" "covered" "total" "pct"
@printf " %-22s %6s %8s %8s %8s\n" repeat("-", 22) repeat("-", 6) repeat("-", 8) repeat("-", 8) repeat("-", 8)
for (subdir, (c, l, n)) in ordered
pct = l == 0 ? 100.0 : 100 * c / l
@printf " %-22s %6d %8d %8d %7.2f%%\n" subdir n c l pct
end
println()
end
# Live coverage excludes:
# - `src/legacy/` (only loaded with `JULIAFEM_ENABLE_LEGACY=1`)
# - Generator-only sources under `src/basis/` that are never `include`d from
# `JuliaFEM.jl` (they would otherwise show as 0% and distort the metric).
const LIVE_EXCLUDE_FILES = (
"src/basis/basis_generator.jl",
"src/basis/subs.jl",
"src/basis/vandermonde.jl",
)
function summarise_live(rows)
live = filter(rows) do r
startswith(r.file, "src/legacy/") && return false
r.file in LIVE_EXCLUDE_FILES && return false
return true
end
covered = sum(r -> r.covered, live; init=0)
lines = sum(r -> r.lines, live; init=0)
return covered, lines, length(live)
end
function trunc_left(s::AbstractString, n::Int)
length(s) n ? s : "" * s[end - n + 2:end]
end
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
function main(argv::Vector{String})
args = parse_args(argv)
if !args.summary_only
nclean = clean_existing_coverage!(SRC_DIR)
nclean > 0 && @info "Removed $nclean stale .cov files"
ok = run_tests_with_coverage(; legacy=args.legacy)
ok || error("Test suite failed; see output above.")
else
@info "--summary-only: reusing existing .cov files under $(SRC_DIR)"
end
@info "Loading Coverage.jl from $(COV_ENV)"
Coverage = load_coverage_pkg()
@info "Gathering coverage from $(SRC_DIR)"
rows, total_covered, total_lines = summarise(Coverage; src_dir=SRC_DIR)
isdir(LCOV_DIR) || mkpath(LCOV_DIR)
with_logger(Logging.NullLogger()) do
cov = Base.invokelatest(Coverage.process_folder, SRC_DIR)
Base.invokelatest(Coverage.LCOV.writefile, LCOV_PATH, cov)
end
@info "Wrote LCOV report" LCOV_PATH
print_table(rows; top_n=args.top_n)
print_subdir_breakdown(rows)
pct = total_lines == 0 ? 100.0 : 100 * total_covered / total_lines
n_files = length(rows)
n_uncovered_files = count(r -> r.covered == 0 && r.lines > 0, rows)
live_c, live_l, live_n = summarise_live(rows)
live_pct = live_l == 0 ? 100.0 : 100 * live_c / live_l
@printf "Files: %d total, %d with zero coverage\n" n_files n_uncovered_files
@printf "Lines: %d / %d covered\n" total_covered total_lines
@printf "Total coverage (all src/): %.2f%%\n" pct
@printf "Live coverage (excl legacy): %.2f%% (%d / %d lines, %d files)\n" live_pct live_c live_l live_n
println()
if args.threshold !== nothing
if pct < args.threshold
@error "Coverage $(round(pct, digits=2))% is below threshold $(args.threshold)%"
exit(2)
else
@info "Coverage $(round(pct, digits=2))% meets threshold $(args.threshold)%"
end
end
if args.threshold_live !== nothing
if live_pct < args.threshold_live
@error "Live coverage $(round(live_pct, digits=2))% is below threshold $(args.threshold_live)%"
exit(2)
else
@info "Live coverage $(round(live_pct, digits=2))% meets threshold $(args.threshold_live)%"
end
end
end
main(ARGS)