1use std::collections::BTreeSet;
10
11use fallow_output::{
12 MutantStatus, MutationReport, MutationStatusCounts, MutationSummary, MutationVerdictInput,
13 MutationVerdictJoin,
14};
15use rustc_hash::FxHashSet;
16use std::fmt::Write as _;
17
18use sha2::{Digest, Sha256};
19
20use crate::ProgrammaticError;
21
22pub const MAX_VERDICT_INPUT_BYTES: usize = 16 * 1024 * 1024;
24
25fn verdict_error(message: impl Into<String>) -> ProgrammaticError {
26 ProgrammaticError::new(message, 2)
27 .with_code("FALLOW_MUTATION_VERDICTS_INVALID")
28 .with_context("mutation.verdicts")
29}
30
31fn sha256_hex(bytes: &[u8]) -> String {
32 Sha256::digest(bytes)
33 .iter()
34 .fold(String::with_capacity(64), |mut hex, byte| {
35 let _ = write!(hex, "{byte:02x}");
36 hex
37 })
38}
39
40enum Effect {
42 Applied,
43 Contradicted,
44 Deferred,
45}
46
47const fn effect(status: MutantStatus) -> Effect {
48 match status {
49 MutantStatus::Survived
50 | MutantStatus::NoCoverage
51 | MutantStatus::Flaky
52 | MutantStatus::Equivalent => Effect::Applied,
53 MutantStatus::Killed | MutantStatus::Timeout | MutantStatus::CompileError => {
54 Effect::Contradicted
55 }
56 MutantStatus::Pending | MutantStatus::RuntimeError | MutantStatus::Pruned => {
57 Effect::Deferred
58 }
59 }
60}
61
62fn move_in_function(report: &mut MutationReport, index: usize, from: MutantStatus) {
65 let location = &report.mutants[index].location;
66 let key = (
67 location.file.as_str(),
68 location.function.as_deref(),
69 location.function_line,
70 );
71 let group = report.functions.iter_mut().find(|group| {
72 (group.file.as_str(), group.name.as_deref(), group.line) == key
73 && count_of(&group.counts, from) > 0
74 });
75 let Some(group) = group else {
76 return;
77 };
78 group.counts.remove(from);
79 group.counts.add(MutantStatus::Equivalent);
80 group.survived.retain(|id| *id != location.mutant_id);
81 group.score = group.counts.score();
82}
83
84const fn count_of(counts: &MutationStatusCounts, status: MutantStatus) -> usize {
85 match status {
86 MutantStatus::Killed => counts.killed,
87 MutantStatus::Timeout => counts.timeout,
88 MutantStatus::Survived => counts.survived,
89 MutantStatus::NoCoverage => counts.no_coverage,
90 MutantStatus::RuntimeError => counts.runtime_error,
91 MutantStatus::Flaky => counts.flaky,
92 MutantStatus::Pruned => counts.pruned,
93 MutantStatus::Pending => counts.pending,
94 MutantStatus::Equivalent => counts.equivalent,
95 MutantStatus::CompileError => counts.compile_error,
96 }
97}
98
99fn parse_verdicts(verdict_json: &[u8]) -> Result<MutationVerdictInput, ProgrammaticError> {
101 if verdict_json.len() > MAX_VERDICT_INPUT_BYTES {
102 return Err(verdict_error(
103 "the mutation verdict document is larger than 16 MiB",
104 ));
105 }
106 let input: MutationVerdictInput = serde_json::from_slice(verdict_json)
107 .map_err(|error| verdict_error(format!("invalid mutation verdict document: {error}")))?;
108 let mut keys = FxHashSet::default();
109 for verdict in &input.verdicts {
110 if verdict.review_key.trim().is_empty() {
111 return Err(verdict_error("a mutation verdict has an empty review_key"));
112 }
113 if !keys.insert(verdict.review_key.as_str()) {
114 return Err(verdict_error(format!(
115 "the review_key `{}` occurs more than one time",
116 verdict.review_key
117 )));
118 }
119 }
120 Ok(input)
121}
122
123pub fn validate_mutation_verdicts(verdict_json: &[u8]) -> Result<(), ProgrammaticError> {
132 parse_verdicts(verdict_json).map(|_| ())
133}
134
135pub fn apply_mutation_verdicts(
150 report: &mut MutationReport,
151 verdict_json: &[u8],
152) -> Result<(), ProgrammaticError> {
153 let input = parse_verdicts(verdict_json)?;
154 let keys: FxHashSet<&str> = input
155 .verdicts
156 .iter()
157 .map(|verdict| verdict.review_key.as_str())
158 .collect();
159
160 let mut applied = BTreeSet::new();
161 let mut contradicted = BTreeSet::new();
162 let mut deferred = BTreeSet::new();
163 let mut matched = FxHashSet::default();
164 for index in 0..report.mutants.len() {
165 let id = report.mutants[index].location.mutant_id.clone();
166 if !keys.contains(id.as_str()) {
167 continue;
168 }
169 matched.insert(id.clone());
170 let status = report.mutants[index].status;
171 match effect(status) {
172 Effect::Applied => {
173 if status != MutantStatus::Equivalent {
174 report.mutants[index].status = MutantStatus::Equivalent;
175 move_in_function(report, index, status);
176 }
177 applied.insert(id);
178 }
179 Effect::Contradicted => {
180 contradicted.insert(id);
181 }
182 Effect::Deferred => {
183 deferred.insert(id);
184 }
185 }
186 }
187 let unmatched: BTreeSet<String> = input
188 .verdicts
189 .iter()
190 .filter(|verdict| !matched.contains(&verdict.review_key))
191 .map(|verdict| verdict.review_key.clone())
192 .collect();
193
194 report.summary = MutationSummary::from_results(&report.mutants);
195 report.verdicts = Some(MutationVerdictJoin {
196 verdicts_sha256: sha256_hex(verdict_json),
197 applied: applied.into_iter().collect(),
198 contradicted: contradicted.into_iter().collect(),
199 deferred: deferred.into_iter().collect(),
200 unmatched: unmatched.into_iter().collect(),
201 });
202 Ok(())
203}
204
205#[cfg(test)]
206#[expect(clippy::unwrap_used, reason = "tests unwrap to keep assertions short")]
207mod tests {
208 use fallow_output::{
209 MutantLocation, MutantResult, MutationCompletion, MutationFunctionResult,
210 MutationSchemaVersion, MutationScopeKind, MutationScopeOutput, MutationSummary,
211 MutationTimings, MutationWorktreeCheck,
212 };
213 use fallow_types::envelope::{ElapsedMs, ToolVersion};
214
215 use super::*;
216
217 fn mutant(id: &str, status: MutantStatus) -> MutantResult {
218 MutantResult {
219 location: MutantLocation {
220 mutant_id: id.to_owned(),
221 file: "src/a.ts".to_owned(),
222 line: 2,
223 column: 3,
224 span: (10, 15),
225 operator: "arithmetic-operator".to_owned(),
226 function: Some("add".to_owned()),
227 function_line: Some(1),
228 rank: Some(1),
229 change: None,
230 },
231 status,
232 prune_reason: None,
233 killed_by: None,
234 covering_tests: match status {
235 MutantStatus::NoCoverage | MutantStatus::Pending | MutantStatus::Pruned => {
236 Vec::new()
237 }
238 _ => vec!["test/a.test.ts::t1".to_owned()],
239 },
240 static_mutant: false,
241 duration_ms: None,
242 test_reach: None,
243 retried: false,
244 attempts: Vec::new(),
245 cached: false,
246 type_error_codes: Vec::new(),
247 }
248 }
249
250 fn report(mutants: Vec<MutantResult>) -> MutationReport {
251 let mut counts = MutationStatusCounts::default();
252 let mut survived = Vec::new();
253 for result in &mutants {
254 counts.add(result.status);
255 if result.status == MutantStatus::Survived {
256 survived.push(result.location.mutant_id.clone());
257 }
258 }
259 MutationReport {
260 schema_version: MutationSchemaVersion::V1,
261 version: ToolVersion("0.0.0".to_owned()),
262 elapsed_ms: ElapsedMs(0),
263 completion: MutationCompletion::from_reasons(Vec::new()),
264 scope: MutationScopeOutput {
265 kind: MutationScopeKind::All,
266 base_ref: None,
267 },
268 runner: None,
269 cache: None,
270 summary: MutationSummary::from_results(&mutants),
271 functions: vec![MutationFunctionResult {
272 file: "src/a.ts".to_owned(),
273 name: Some("add".to_owned()),
274 line: Some(1),
275 end_line: Some(4),
276 in_diff: true,
277 cyclomatic: Some(1),
278 counts,
279 score: counts.score(),
280 survived,
281 }],
282 mutants,
283 no_test_files: false,
284 baseline_failures: Vec::new(),
285 skipped_files: Vec::new(),
286 unmatched_paths: Vec::new(),
287 unloaded_paths: Vec::new(),
288 worktree: MutationWorktreeCheck {
289 checked: true,
290 allow_dirty: false,
291 dirty_at_start: Vec::new(),
292 changed_paths: Vec::new(),
293 },
294 errors: Vec::new(),
295 timings: MutationTimings::default(),
296 files: Vec::new(),
297 verdicts: None,
298 typecheck: None,
299 }
300 }
301
302 fn document(keys: &[&str]) -> Vec<u8> {
303 let verdicts: Vec<serde_json::Value> = keys
304 .iter()
305 .map(|key| serde_json::json!({ "review_key": key, "verdict": "equivalent" }))
306 .collect();
307 serde_json::to_vec(&serde_json::json!({ "schema_version": "1", "verdicts": verdicts }))
308 .unwrap()
309 }
310
311 #[test]
312 fn equivalent_verdicts_leave_the_score_and_the_survivor_list() {
313 let mut report = report(vec![
314 mutant("m1", MutantStatus::Killed),
315 mutant("m2", MutantStatus::Survived),
316 mutant("m3", MutantStatus::Survived),
317 mutant("m4", MutantStatus::NoCoverage),
318 ]);
319 assert_eq!(report.summary.score, Some(1.0 / 3.0));
320 assert_eq!(report.summary.coverage, Some(0.75));
321
322 apply_mutation_verdicts(&mut report, &document(&["m2", "m4"])).unwrap();
323
324 let statuses: Vec<MutantStatus> = report.mutants.iter().map(|m| m.status).collect();
325 assert_eq!(
326 statuses,
327 vec![
328 MutantStatus::Killed,
329 MutantStatus::Equivalent,
330 MutantStatus::Survived,
331 MutantStatus::Equivalent
332 ]
333 );
334 assert_eq!(report.summary.counts.equivalent, 2);
335 assert_eq!(report.summary.counts.survived, 1);
336 assert_eq!(report.summary.counts.no_coverage, 0);
337 assert_eq!(report.summary.counts.total, 4);
338 assert_eq!(report.summary.score, Some(0.5));
339 assert_eq!(report.summary.coverage, Some(1.0));
340 let function = &report.functions[0];
341 assert_eq!(function.counts.equivalent, 2);
342 assert_eq!(function.counts.no_coverage, 0);
343 assert_eq!(function.survived, vec!["m3".to_owned()]);
344 assert_eq!(function.score, Some(0.5));
345 let join = report.verdicts.unwrap();
346 assert_eq!(join.applied, vec!["m2".to_owned(), "m4".to_owned()]);
347 assert!(join.unmatched.is_empty());
348 assert_eq!(join.verdicts_sha256.len(), 64);
349 }
350
351 #[test]
352 fn a_kill_contradicts_a_verdict_and_unknown_keys_stay_unmatched() {
353 let mut report = report(vec![
354 mutant("m1", MutantStatus::Killed),
355 mutant("m2", MutantStatus::Pending),
356 mutant("m3", MutantStatus::Survived),
357 ]);
358
359 apply_mutation_verdicts(&mut report, &document(&["gone", "m1", "m2"])).unwrap();
360
361 assert_eq!(report.mutants[0].status, MutantStatus::Killed);
362 assert_eq!(report.mutants[1].status, MutantStatus::Pending);
363 assert_eq!(report.mutants[2].status, MutantStatus::Survived);
364 assert_eq!(report.summary.score, Some(0.5));
365 let join = report.verdicts.unwrap();
366 assert!(join.applied.is_empty());
367 assert_eq!(join.contradicted, vec!["m1".to_owned()]);
368 assert_eq!(join.deferred, vec!["m2".to_owned()]);
369 assert_eq!(join.unmatched, vec!["gone".to_owned()]);
370 }
371
372 #[test]
373 fn malformed_and_duplicate_verdicts_fail_closed() {
374 let cases: [(&[u8], &str); 4] = [
375 (b"{", "invalid mutation verdict document"),
376 (
377 br#"{"schema_version":"1","verdicts":[{"review_key":"m1","verdict":"survived"}]}"#,
378 "invalid mutation verdict document",
379 ),
380 (
381 br#"{"schema_version":"1","verdicts":[{"review_key":" ","verdict":"equivalent"}]}"#,
382 "empty review_key",
383 ),
384 (&document(&["m1", "m1"]), "more than one time"),
385 ];
386 for (json, needle) in cases {
387 let checked = validate_mutation_verdicts(json).unwrap_err();
388 assert_eq!(checked.exit_code, 2);
389 assert!(checked.message.contains(needle), "{}", checked.message);
390 let mut report = report(vec![mutant("m1", MutantStatus::Survived)]);
391 let error = apply_mutation_verdicts(&mut report, json).unwrap_err();
392 assert_eq!(
393 error.code.as_deref(),
394 Some("FALLOW_MUTATION_VERDICTS_INVALID")
395 );
396 assert_eq!(error.exit_code, 2);
397 assert!(error.message.contains(needle), "{}", error.message);
398 assert_eq!(report.mutants[0].status, MutantStatus::Survived);
399 assert!(report.verdicts.is_none());
400 }
401 assert!(validate_mutation_verdicts(&document(&["m1", "m2"])).is_ok());
402 }
403}