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