diff --git a/Project.toml b/Project.toml index 7a18157..97af3d9 100644 --- a/Project.toml +++ b/Project.toml @@ -1,6 +1,6 @@ name = "TestShards" uuid = "acceef1d-f5e0-4fe4-a546-818dc56ce7b2" -version = "0.3.34" +version = "0.3.35" authors = ["sota shimozono "] [deps] diff --git a/src/coverage.jl b/src/coverage.jl index 06ee5b9..1ac4286 100644 --- a/src/coverage.jl +++ b/src/coverage.jl @@ -36,6 +36,89 @@ function line_totals(fs::AbstractVector{LcovFile}) return (sum(first, t; init=0), sum(last, t; init=0)) end +""" + counter_index(cov) -> String + +The COUNTED LINES of a Julia `.cov` file, as `","` — one per line, with a +`# lines ` header naming the source's line count. + +Julia writes `Foo.jl..cov` as a 9-character counter column, one space, and then **a +verbatim copy of the source line**. Measured on one 48-file shard payload of +`ParaLinearAlgebra.jl` (issue #74): 965,565 bytes, of which **815,592 (85.7 %) is that source +text**, and only 2,134 of 15,181 lines carry a counter at all — the rest are `-`. Since every +shard emits counters for the WHOLE tree rather than the files it touched, an 8-shard run ships +eight near-identical copies of the package's source through artifact storage. That exhausted an +organisation's Actions storage quota, which fails the run at `Shard labels`, before any test. + +The index drops both redundancies. Same payload: **15,049 bytes, 64.2× smaller**. + +Nothing is lost, because the text is already at the destination: `collect` runs +`actions/checkout` BEFORE `download-artifact` (it must — Codecov builds the file network from +the tree), so [`restore_counters`](@ref) rebuilds each `.cov` from the checkout. Verified on +that artifact: the embedded text is byte-identical to the repo file at the run's `head_sha` for +48 of 48 files, and the round trip reproduces the original `.cov` byte for byte for 48 of 48. +""" +function counter_index(cov::AbstractString) + io = IOBuffer() + n = 0 + for line in eachline(cov) + n += 1 + h = strip(SubString(line, 1, min(9, lastindex(line)))) + (isempty(h) || h == "-") && continue + println(io, n, ",", h) + end + return string("# lines ", n, "\n", String(take!(io))) +end + +# Rebuild `Foo.jl..cov` from an index and the source that is already checked out. The +# format is Julia's: `%9s` of the counter (or `-`), a space, then the source line verbatim. +# +# THE MISMATCH CHECK IS NOT DEFENSIVE, IT IS THE FAILURE MODE THIS INTRODUCES. A short or +# shifted `.cov` does not look broken to CoverageTools — it reports FEWER covered lines, which +# reads as a coverage drop rather than as a bug, and nothing announces it. That is the same +# shape as the silent 54.5%-for-94.8% this file's other docstring records, so it refuses by +# name instead: the index carries the line count the shard saw, and a source that disagrees +# with it, or is absent from the checkout, stops the merge. +function _restore_from_index(idx::AbstractString, src::AbstractString, out::AbstractString) + lines = readlines(idx) + hdr = findfirst(startswith("# lines "), lines) + hdr === nothing && throw( + ArgumentError( + "restore_counters: $(idx) has no `# lines ` header; it is not a counter index", + ), + ) + want = parse(Int, strip(SubString(lines[hdr], length("# lines ") + 1))) + isfile(src) || throw( + ArgumentError( + "restore_counters: $(idx) indexes $(src), which is not in the checkout. The " * + "counters are rebuilt against the working tree, so `collect` must check out the " * + "SAME revision the shards ran. Rebuilding without it would silently under-report.", + ), + ) + text = readlines(src) + length(text) == want || throw( + ArgumentError( + "restore_counters: $(src) has $(length(text)) lines but $(idx) was written " * + "against $(want). The checkout is not the revision the shard ran; a `.cov` built " * + "from it would shift every counter and read as a coverage drop, not as an error.", + ), + ) + hits = Dict{Int,String}() + for l in lines[(hdr + 1):end] + isempty(strip(l)) && continue + c = findfirst(==(','), l) + c === nothing && continue + hits[parse(Int, SubString(l, 1, c - 1))] = String(SubString(l, c + 1, lastindex(l))) + end + mkpath(dirname(out)) + open(out, "w") do io + for (i, t) in enumerate(text) + println(io, lpad(get(hits, i, "-"), 9), " ", t) + end + end + return out +end + """ restore_counters(parts, dest = ".") -> Vector{String} @@ -64,11 +147,21 @@ function restore_counters(parts::AbstractString, dest::AbstractString=".") isempty(shard) && continue for (root, _, files) in walkdir(entry) for f in files - endswith(f, ".cov") || continue + # `.cov.idx` is the compact form (issue #74); a real `.cov` still restores by + # copy, so a run whose shards uploaded before this change and whose `collect` + # runs after it is not a broken run. + isidx = endswith(f, ".cov.idx") + (isidx || endswith(f, ".cov")) || continue rel = relpath(joinpath(root, f), entry) - out = joinpath(dest, _tag_counter(rel, shard)) - mkpath(dirname(out)) - cp(joinpath(root, f), out; force=true) + out = joinpath(dest, _tag_counter(isidx ? chop(rel; tail=4) : rel, shard)) + if isidx + _restore_from_index( + joinpath(root, f), joinpath(dest, _index_source(rel)), out + ) + else + mkpath(dirname(out)) + cp(joinpath(root, f), out; force=true) + end push!(written, out) end end @@ -83,6 +176,13 @@ function _counter_shard(dir::AbstractString) return String(dir[(last(i) + 1):end]) end +"`src/Foo.jl.123.cov.idx` → `src/Foo.jl`: the source the index was written against." +function _index_source(rel::AbstractString) + base = chop(rel; tail=length(".cov.idx")) + i = findlast(==('.'), base) # strip the `.` Julia appends + return i === nothing ? base : String(SubString(base, 1, i - 1)) +end + "`src/Foo.jl.123.cov` → `src/Foo.jl.123-s3.cov`, which `Foo.jl.*.cov` still matches." function _tag_counter(rel::AbstractString, shard::AbstractString) return string(chop(rel; tail=length(".cov")), "-", shard, ".cov") diff --git a/test/core/test_coverage.jl b/test/core/test_coverage.jl index c7509fe..3e0e420 100644 --- a/test/core/test_coverage.jl +++ b/test/core/test_coverage.jl @@ -207,3 +207,101 @@ end @test TestShards._counter_shard("testshards-records") == "" @test TestShards._counter_shard("testshards-coverage-s12") == "s12" end + +# ── the compact counter index (issue #74) ──────────────────────────────────────────────────── +# +# Julia's `.cov` is a 9-char counter column, a space, and a VERBATIM COPY OF THE SOURCE LINE. +# Measured on a real 48-file shard payload: 85.7 % of the bytes are that copy, and only 14.1 % +# of lines carry a counter at all — so an 8-shard run uploads eight copies of the source tree, +# which is what exhausted an organisation's Actions storage quota. `collect` already checks the +# tree out before downloading, so the text never needed to travel. + +@testset "#74: the index keeps only counted lines, and rebuilds the .cov exactly" begin + d = mktempdir() + src = joinpath(d, "src") + mkpath(src) + # a source with covered lines, uncovered lines, and `-` lines that carry no counter + text = """ + module M + # a comment, which is not executable + f(x) = x + 1 + + g(y) = y * 2 + end + """ + write(joinpath(src, "M.jl"), text) + cov = joinpath(src, "M.jl.4242.cov") + counters = ["-", "-", " 7", "-", " 0", "-"] + open(cov, "w") do io + for (c, t) in zip(counters, split(text, '\n')[1:6]) + println(io, lpad(c, 9), " ", t) + end + end + + idx = TestShards.counter_index(cov) + # ONLY the counted lines survive — including the ZERO, which is the difference between + # "reached and never taken" and "not executable", and dropping it would inflate coverage. + @test occursin("# lines 6", idx) + @test occursin("3,7", idx) + @test occursin("5,0", idx) + @test !occursin("2,", idx) + @test length(idx) < length(read(cov, String)) ÷ 2 # measured 61.8× on the real payload + + # ROUND TRIP against the checkout, which is what `collect` does + parts = joinpath(d, "parts", "testshards-coverage-s3", "src") + mkpath(parts) + write(joinpath(parts, "M.jl.4242.cov.idx"), idx) + w = only(TestShards.restore_counters(joinpath(d, "parts"), d)) + @test w == joinpath(d, "src", "M.jl.4242-s3.cov") + @test read(w, String) == read(cov, String) # byte for byte +end + +@testset "#74: a checkout that cannot support the index REFUSES, loudly" begin + d = mktempdir() + mkpath(joinpath(d, "src")) + write(joinpath(d, "src", "M.jl"), "a = 1\nb = 2\n") + parts = joinpath(d, "parts", "testshards-coverage-s1", "src") + mkpath(parts) + + # (a) the source is not in the checkout at all + write(joinpath(parts, "Gone.jl.1.cov.idx"), "# lines 2\n1,3\n") + err = try + TestShards.restore_counters(joinpath(d, "parts"), d) + nothing + catch e + sprint(showerror, e) + end + @test err !== nothing + @test occursin("not in the checkout", err) + rm(joinpath(parts, "Gone.jl.1.cov.idx")) + + # (b) the source is there but is a DIFFERENT revision. This is the one that must not pass + # quietly: a shifted rebuild reports fewer covered lines, which reads as a coverage drop + # rather than as a bug, and nothing else in the pipeline would notice. + write(joinpath(parts, "M.jl.1.cov.idx"), "# lines 9\n1,3\n") + err2 = try + TestShards.restore_counters(joinpath(d, "parts"), d) + nothing + catch e + sprint(showerror, e) + end + @test err2 !== nothing + @test occursin("2 lines but", err2) && occursin("written", err2) + + # (c) a file that is not an index at all + write(joinpath(parts, "M.jl.1.cov.idx"), "1,3\n") + @test_throws ArgumentError TestShards.restore_counters(joinpath(d, "parts"), d) +end + +@testset "#74: a raw .cov still restores by copy, so a mid-upgrade run is not broken" begin + # Shards that uploaded before this change and a `collect` that runs after it must still + # merge — the two halves of one run can straddle a workflow bump. + d = mktempdir() + parts = joinpath(d, "parts", "testshards-coverage-s1", "src") + mkpath(parts) + write(joinpath(parts, "M.jl.1.cov"), " 5 a = 1\n") + dest = joinpath(d, "repo") + w = only(TestShards.restore_counters(joinpath(d, "parts"), dest)) + @test w == joinpath(dest, "src", "M.jl.1-s1.cov") + @test read(w, String) == " 5 a = 1\n" # copied, not rebuilt +end