Skip to content

Commit 0d18582

Browse files
noahd1claude
andauthored
feat(coverage): add client-side coverage summing before upload (#2722)
## Summary - Add client-side merging of duplicate `FileCoverage` entries for the same source file path before uploading, gated behind `QLTY_COVERAGE_CLIENT_SIDE_SUMMING` env var - Summing algorithm matches the server-side spec exactly: truncate to min length, omit-wins (`-1` poisons), sum hit counts - Shared test vectors (`coverage_summation_spec.json`) copied from server repo ensure both implementations stay in sync ## Test plan - [x] All 26 spec expectations pass via `cargo test -p qlty-coverage sum` - [x] Unit tests for `sum_file_coverages`: no-dups passthrough, duplicates merged, summary recomputed, metadata preserved, mixed paths - [x] Full crate test suite passes (258 tests) - [x] `qlty fmt` and `qlty check --level=low` clean 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 10bbeb4 commit 0d18582

5 files changed

Lines changed: 428 additions & 9 deletions

File tree

qlty-coverage/src/publish.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ mod reader;
66
mod report;
77
mod results;
88
mod settings;
9+
mod summing;
910
mod upload;
1011

1112
pub use metrics::CoverageMetrics;

qlty-coverage/src/publish/metrics.rs

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
use crate::publish::summing::DeduplicatedCoverages;
12
use qlty_types::tests::v1::FileCoverage;
23
use serde::Serialize;
34
use std::collections::HashMap;
@@ -12,7 +13,27 @@ pub struct CoverageMetrics {
1213
}
1314

1415
impl CoverageMetrics {
15-
pub fn calculate(file_coverages: &[FileCoverage]) -> Self {
16+
pub fn from_deduplicated(coverages: &DeduplicatedCoverages) -> Self {
17+
let mut covered_lines = 0;
18+
let mut uncovered_lines = 0;
19+
let mut omitted_lines = 0;
20+
21+
for fc in coverages.as_slice() {
22+
for &hit in &fc.hits {
23+
if hit > 0 {
24+
covered_lines += 1;
25+
} else if hit == 0 {
26+
uncovered_lines += 1;
27+
} else {
28+
omitted_lines += 1;
29+
}
30+
}
31+
}
32+
33+
Self::from_counts(covered_lines, uncovered_lines, omitted_lines)
34+
}
35+
36+
pub fn calculate_with_combining(file_coverages: &[FileCoverage]) -> Self {
1637
// Group file coverages by path
1738
let mut path_hits_map: HashMap<String, Vec<Vec<i64>>> = HashMap::new();
1839

@@ -65,6 +86,10 @@ impl CoverageMetrics {
6586
}
6687
}
6788

89+
Self::from_counts(covered_lines, uncovered_lines, omitted_lines)
90+
}
91+
92+
fn from_counts(covered_lines: u64, uncovered_lines: u64, omitted_lines: u64) -> Self {
6893
let total_lines = covered_lines + uncovered_lines + omitted_lines;
6994
let coverable_lines = covered_lines + uncovered_lines;
7095

@@ -90,7 +115,7 @@ mod tests {
90115

91116
#[test]
92117
fn test_empty_coverage() {
93-
let metrics = CoverageMetrics::calculate(&[]);
118+
let metrics = CoverageMetrics::calculate_with_combining(&[]);
94119

95120
assert_eq!(metrics.covered_lines, 0);
96121
assert_eq!(metrics.uncovered_lines, 0);
@@ -107,7 +132,7 @@ mod tests {
107132
..Default::default()
108133
};
109134

110-
let metrics = CoverageMetrics::calculate(&[file_coverage]);
135+
let metrics = CoverageMetrics::calculate_with_combining(&[file_coverage]);
111136

112137
assert_eq!(metrics.covered_lines, 2);
113138
assert_eq!(metrics.uncovered_lines, 1);
@@ -130,7 +155,7 @@ mod tests {
130155
..Default::default()
131156
};
132157

133-
let metrics = CoverageMetrics::calculate(&[file_coverage1, file_coverage2]);
158+
let metrics = CoverageMetrics::calculate_with_combining(&[file_coverage1, file_coverage2]);
134159

135160
assert_eq!(metrics.covered_lines, 3);
136161
assert_eq!(metrics.uncovered_lines, 3);
@@ -153,7 +178,7 @@ mod tests {
153178
..Default::default()
154179
};
155180

156-
let metrics = CoverageMetrics::calculate(&[file_coverage1, file_coverage2]);
181+
let metrics = CoverageMetrics::calculate_with_combining(&[file_coverage1, file_coverage2]);
157182

158183
assert_eq!(metrics.covered_lines, 3);
159184
assert_eq!(metrics.uncovered_lines, 0);
@@ -176,7 +201,7 @@ mod tests {
176201
..Default::default()
177202
};
178203

179-
let metrics = CoverageMetrics::calculate(&[file_coverage1, file_coverage2]);
204+
let metrics = CoverageMetrics::calculate_with_combining(&[file_coverage1, file_coverage2]);
180205

181206
assert_eq!(metrics.covered_lines, 4);
182207
assert_eq!(metrics.uncovered_lines, 1);
@@ -193,7 +218,7 @@ mod tests {
193218
..Default::default()
194219
};
195220

196-
let metrics = CoverageMetrics::calculate(&[file_coverage]);
221+
let metrics = CoverageMetrics::calculate_with_combining(&[file_coverage]);
197222

198223
assert_eq!(metrics.covered_lines, 0);
199224
assert_eq!(metrics.uncovered_lines, 0);

qlty-coverage/src/publish/processor.rs

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
use crate::publish::{metrics::CoverageMetrics, Plan, Report, Results};
1+
use crate::publish::{
2+
metrics::CoverageMetrics, summing::sum_file_coverages, Plan, Report, Results,
3+
};
24
use anyhow::Result;
35
use qlty_types::tests::v1::FileCoverage;
46
use std::collections::HashSet;
@@ -63,10 +65,19 @@ impl Processor {
6365
}
6466
}
6567

66-
let totals = CoverageMetrics::calculate(&transformed_file_coverages);
6768
let ignored_paths_count =
6869
pre_transform_file_coverages_count - transformed_file_coverages.len();
6970

71+
let (totals, transformed_file_coverages) =
72+
if std::env::var("QLTY_COVERAGE_CLIENT_SIDE_SUMMING").is_ok() {
73+
let deduped = sum_file_coverages(transformed_file_coverages);
74+
let totals = CoverageMetrics::from_deduplicated(&deduped);
75+
(totals, deduped.into_inner())
76+
} else {
77+
let totals = CoverageMetrics::calculate_with_combining(&transformed_file_coverages);
78+
(totals, transformed_file_coverages)
79+
};
80+
7081
Ok(Report {
7182
metadata: self.plan.metadata.clone(),
7283
report_files,
Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
use qlty_types::tests::v1::{CoverageSummary, FileCoverage};
2+
use std::collections::HashMap;
3+
4+
pub struct DeduplicatedCoverages(Vec<FileCoverage>);
5+
6+
impl DeduplicatedCoverages {
7+
pub fn as_slice(&self) -> &[FileCoverage] {
8+
&self.0
9+
}
10+
11+
pub fn len(&self) -> usize {
12+
self.0.len()
13+
}
14+
15+
pub fn into_inner(self) -> Vec<FileCoverage> {
16+
self.0
17+
}
18+
}
19+
20+
fn merge_hits(existing: &mut Vec<i64>, other: &[i64]) {
21+
let min_len = existing.len().min(other.len());
22+
existing.truncate(min_len);
23+
24+
for i in 0..min_len {
25+
if existing[i] < 0 || other[i] < 0 {
26+
existing[i] = existing[i].min(other[i]);
27+
} else {
28+
existing[i] += other[i];
29+
}
30+
}
31+
}
32+
33+
fn compute_summary(hits: &[i64]) -> CoverageSummary {
34+
let mut covered: i64 = 0;
35+
let mut missed: i64 = 0;
36+
let mut omit: i64 = 0;
37+
38+
for &hit in hits {
39+
match hit {
40+
-1 => omit += 1,
41+
0 => missed += 1,
42+
_ => covered += 1,
43+
}
44+
}
45+
46+
CoverageSummary {
47+
covered,
48+
missed,
49+
omit,
50+
total: covered + missed + omit,
51+
}
52+
}
53+
54+
pub fn sum_file_coverages(file_coverages: Vec<FileCoverage>) -> DeduplicatedCoverages {
55+
let mut map: HashMap<String, FileCoverage> = HashMap::new();
56+
57+
for fc in file_coverages {
58+
match map.get_mut(&fc.path) {
59+
Some(existing) => {
60+
merge_hits(&mut existing.hits, &fc.hits);
61+
}
62+
None => {
63+
map.insert(fc.path.clone(), fc);
64+
}
65+
}
66+
}
67+
68+
DeduplicatedCoverages(
69+
map.into_values()
70+
.map(|mut fc| {
71+
fc.summary = Some(compute_summary(&fc.hits));
72+
fc
73+
})
74+
.collect(),
75+
)
76+
}
77+
78+
#[cfg(test)]
79+
mod tests {
80+
use super::*;
81+
82+
fn sum_hits(hits_arrays: &[Vec<i64>]) -> Vec<i64> {
83+
if hits_arrays.is_empty() {
84+
return vec![];
85+
}
86+
87+
let mut result = hits_arrays[0].clone();
88+
for other in &hits_arrays[1..] {
89+
merge_hits(&mut result, other);
90+
}
91+
result
92+
}
93+
94+
mod spec_tests {
95+
use super::*;
96+
use serde::Deserialize;
97+
98+
#[derive(Deserialize)]
99+
struct Spec {
100+
expectations: Vec<Expectation>,
101+
}
102+
103+
#[derive(Deserialize)]
104+
struct Expectation {
105+
id: String,
106+
inputs: Vec<Vec<i64>>,
107+
expected: Vec<i64>,
108+
}
109+
110+
#[test]
111+
fn test_all_spec_expectations() {
112+
let spec_json = include_str!("../../tests/fixtures/coverage_summation_spec.json");
113+
let spec: Spec = serde_json::from_str(spec_json).unwrap();
114+
115+
assert_eq!(spec.expectations.len(), 26);
116+
117+
for expectation in &spec.expectations {
118+
let result = sum_hits(&expectation.inputs);
119+
assert_eq!(
120+
result, expectation.expected,
121+
"failed for spec: {}",
122+
expectation.id
123+
);
124+
}
125+
}
126+
}
127+
128+
mod sum_hits_tests {
129+
use super::*;
130+
131+
#[test]
132+
fn zero_arrays() {
133+
assert_eq!(sum_hits(&[]), Vec::<i64>::new());
134+
}
135+
}
136+
137+
mod sum_file_coverages_tests {
138+
use super::*;
139+
140+
fn make_fc(path: &str, hits: Vec<i64>) -> FileCoverage {
141+
FileCoverage {
142+
path: path.to_string(),
143+
hits,
144+
..Default::default()
145+
}
146+
}
147+
148+
#[test]
149+
fn no_duplicates_passes_through() {
150+
let input = vec![make_fc("a.rs", vec![1, 0, -1]), make_fc("b.rs", vec![0, 1])];
151+
152+
let result = sum_file_coverages(input).into_inner();
153+
assert_eq!(result.len(), 2);
154+
155+
let a = result.iter().find(|fc| fc.path == "a.rs").unwrap();
156+
assert_eq!(a.hits, vec![1, 0, -1]);
157+
158+
let b = result.iter().find(|fc| fc.path == "b.rs").unwrap();
159+
assert_eq!(b.hits, vec![0, 1]);
160+
}
161+
162+
#[test]
163+
fn duplicates_merged() {
164+
let input = vec![
165+
make_fc("a.rs", vec![1, 0, -1]),
166+
make_fc("a.rs", vec![2, 1, -1]),
167+
];
168+
169+
let result = sum_file_coverages(input).into_inner();
170+
assert_eq!(result.len(), 1);
171+
assert_eq!(result[0].hits, vec![3, 1, -1]);
172+
}
173+
174+
#[test]
175+
fn summary_recomputed_after_merge() {
176+
let input = vec![
177+
make_fc("a.rs", vec![1, 0, -1, 0]),
178+
make_fc("a.rs", vec![0, 1, -1, 0]),
179+
];
180+
181+
let result = sum_file_coverages(input).into_inner();
182+
let summary = result[0].summary.unwrap();
183+
assert_eq!(summary.covered, 2);
184+
assert_eq!(summary.missed, 1);
185+
assert_eq!(summary.omit, 1);
186+
assert_eq!(summary.total, 4);
187+
}
188+
189+
#[test]
190+
fn metadata_preserved_from_first_entry() {
191+
let mut fc1 = make_fc("a.rs", vec![1]);
192+
fc1.build_id = "build-1".to_string();
193+
fc1.commit_sha = Some("abc123".to_string());
194+
195+
let fc2 = make_fc("a.rs", vec![2]);
196+
197+
let result = sum_file_coverages(vec![fc1, fc2]).into_inner();
198+
assert_eq!(result[0].build_id, "build-1");
199+
assert_eq!(result[0].commit_sha, Some("abc123".to_string()));
200+
}
201+
202+
#[test]
203+
fn mixed_duplicates_and_unique() {
204+
let input = vec![
205+
make_fc("a.rs", vec![1, 0]),
206+
make_fc("b.rs", vec![0, 1]),
207+
make_fc("a.rs", vec![0, 1]),
208+
];
209+
210+
let result = sum_file_coverages(input).into_inner();
211+
assert_eq!(result.len(), 2);
212+
213+
let a = result.iter().find(|fc| fc.path == "a.rs").unwrap();
214+
assert_eq!(a.hits, vec![1, 1]);
215+
216+
let b = result.iter().find(|fc| fc.path == "b.rs").unwrap();
217+
assert_eq!(b.hits, vec![0, 1]);
218+
}
219+
}
220+
}

0 commit comments

Comments
 (0)