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 },
230 status,
231 prune_reason: None,
232 killed_by: None,
233 covering_tests: match status {
234 MutantStatus::NoCoverage | MutantStatus::Pending | MutantStatus::Pruned => {
235 Vec::new()
236 }
237 _ => vec!["test/a.test.ts::t1".to_owned()],
238 },
239 static_mutant: false,
240 duration_ms: None,
241 test_reach: None,
242 retried: false,
243 cached: false,
244 type_error_codes: Vec::new(),
245 }
246 }
247
248 fn report(mutants: Vec<MutantResult>) -> MutationReport {
249 let mut counts = MutationStatusCounts::default();
250 let mut survived = Vec::new();
251 for result in &mutants {
252 counts.add(result.status);
253 if result.status == MutantStatus::Survived {
254 survived.push(result.location.mutant_id.clone());
255 }
256 }
257 MutationReport {
258 schema_version: MutationSchemaVersion::V1,
259 version: ToolVersion("0.0.0".to_owned()),
260 elapsed_ms: ElapsedMs(0),
261 completion: MutationCompletion::from_reasons(Vec::new()),
262 scope: MutationScopeOutput {
263 kind: MutationScopeKind::All,
264 base_ref: None,
265 },
266 runner: None,
267 cache: None,
268 summary: MutationSummary::from_results(&mutants),
269 functions: vec![MutationFunctionResult {
270 file: "src/a.ts".to_owned(),
271 name: Some("add".to_owned()),
272 line: Some(1),
273 end_line: Some(4),
274 in_diff: true,
275 cyclomatic: Some(1),
276 counts,
277 score: counts.score(),
278 survived,
279 }],
280 mutants,
281 no_test_files: false,
282 baseline_failures: Vec::new(),
283 skipped_files: Vec::new(),
284 unmatched_paths: Vec::new(),
285 unloaded_paths: Vec::new(),
286 worktree: MutationWorktreeCheck {
287 checked: true,
288 allow_dirty: false,
289 dirty_at_start: Vec::new(),
290 changed_paths: Vec::new(),
291 },
292 errors: Vec::new(),
293 timings: MutationTimings::default(),
294 files: Vec::new(),
295 verdicts: None,
296 typecheck: None,
297 }
298 }
299
300 fn document(keys: &[&str]) -> Vec<u8> {
301 let verdicts: Vec<serde_json::Value> = keys
302 .iter()
303 .map(|key| serde_json::json!({ "review_key": key, "verdict": "equivalent" }))
304 .collect();
305 serde_json::to_vec(&serde_json::json!({ "schema_version": "1", "verdicts": verdicts }))
306 .unwrap()
307 }
308
309 #[test]
310 fn equivalent_verdicts_leave_the_score_and_the_survivor_list() {
311 let mut report = report(vec![
312 mutant("m1", MutantStatus::Killed),
313 mutant("m2", MutantStatus::Survived),
314 mutant("m3", MutantStatus::Survived),
315 mutant("m4", MutantStatus::NoCoverage),
316 ]);
317 assert_eq!(report.summary.score, Some(1.0 / 3.0));
318 assert_eq!(report.summary.coverage, Some(0.75));
319
320 apply_mutation_verdicts(&mut report, &document(&["m2", "m4"])).unwrap();
321
322 let statuses: Vec<MutantStatus> = report.mutants.iter().map(|m| m.status).collect();
323 assert_eq!(
324 statuses,
325 vec![
326 MutantStatus::Killed,
327 MutantStatus::Equivalent,
328 MutantStatus::Survived,
329 MutantStatus::Equivalent
330 ]
331 );
332 assert_eq!(report.summary.counts.equivalent, 2);
333 assert_eq!(report.summary.counts.survived, 1);
334 assert_eq!(report.summary.counts.no_coverage, 0);
335 assert_eq!(report.summary.counts.total, 4);
336 assert_eq!(report.summary.score, Some(0.5));
337 assert_eq!(report.summary.coverage, Some(1.0));
338 let function = &report.functions[0];
339 assert_eq!(function.counts.equivalent, 2);
340 assert_eq!(function.counts.no_coverage, 0);
341 assert_eq!(function.survived, vec!["m3".to_owned()]);
342 assert_eq!(function.score, Some(0.5));
343 let join = report.verdicts.unwrap();
344 assert_eq!(join.applied, vec!["m2".to_owned(), "m4".to_owned()]);
345 assert!(join.unmatched.is_empty());
346 assert_eq!(join.verdicts_sha256.len(), 64);
347 }
348
349 #[test]
350 fn a_kill_contradicts_a_verdict_and_unknown_keys_stay_unmatched() {
351 let mut report = report(vec![
352 mutant("m1", MutantStatus::Killed),
353 mutant("m2", MutantStatus::Pending),
354 mutant("m3", MutantStatus::Survived),
355 ]);
356
357 apply_mutation_verdicts(&mut report, &document(&["gone", "m1", "m2"])).unwrap();
358
359 assert_eq!(report.mutants[0].status, MutantStatus::Killed);
360 assert_eq!(report.mutants[1].status, MutantStatus::Pending);
361 assert_eq!(report.mutants[2].status, MutantStatus::Survived);
362 assert_eq!(report.summary.score, Some(0.5));
363 let join = report.verdicts.unwrap();
364 assert!(join.applied.is_empty());
365 assert_eq!(join.contradicted, vec!["m1".to_owned()]);
366 assert_eq!(join.deferred, vec!["m2".to_owned()]);
367 assert_eq!(join.unmatched, vec!["gone".to_owned()]);
368 }
369
370 #[test]
371 fn malformed_and_duplicate_verdicts_fail_closed() {
372 let cases: [(&[u8], &str); 4] = [
373 (b"{", "invalid mutation verdict document"),
374 (
375 br#"{"schema_version":"1","verdicts":[{"review_key":"m1","verdict":"survived"}]}"#,
376 "invalid mutation verdict document",
377 ),
378 (
379 br#"{"schema_version":"1","verdicts":[{"review_key":" ","verdict":"equivalent"}]}"#,
380 "empty review_key",
381 ),
382 (&document(&["m1", "m1"]), "more than one time"),
383 ];
384 for (json, needle) in cases {
385 let checked = validate_mutation_verdicts(json).unwrap_err();
386 assert_eq!(checked.exit_code, 2);
387 assert!(checked.message.contains(needle), "{}", checked.message);
388 let mut report = report(vec![mutant("m1", MutantStatus::Survived)]);
389 let error = apply_mutation_verdicts(&mut report, json).unwrap_err();
390 assert_eq!(
391 error.code.as_deref(),
392 Some("FALLOW_MUTATION_VERDICTS_INVALID")
393 );
394 assert_eq!(error.exit_code, 2);
395 assert!(error.message.contains(needle), "{}", error.message);
396 assert_eq!(report.mutants[0].status, MutantStatus::Survived);
397 assert!(report.verdicts.is_none());
398 }
399 assert!(validate_mutation_verdicts(&document(&["m1", "m2"])).is_ok());
400 }
401}