1use std::collections::BTreeMap;
4use std::fmt::Write as _;
5
6use fallow_engine::module_graph::ExportTestReach;
7use fallow_engine::mutation::{MutantDisposition, MutationPlan, PlannedMutant, PruneReason};
8use fallow_output::{
9 MutantLocation, MutantResult, MutantStatus, MutantTestReach, MutationBaselineFailure,
10 MutationCompletion, MutationFunctionResult, MutationIncompleteReason, MutationPlanCountsOutput,
11 MutationPlanDisposition, MutationPlanFile, MutationPlanMutant, MutationReport,
12 MutationRunIssue, MutationSkippedFile, MutationStatusCounts, MutationSummary, TestReachClass,
13};
14use fallow_types::envelope::ToolVersion;
15use rustc_hash::FxHashSet;
16
17use super::scheduler::ScheduleOutcome;
18use crate::ProgrammaticError;
19
20fn kebab<T: serde::Serialize>(value: &T) -> String {
22 serde_json::to_value(value)
23 .ok()
24 .and_then(|value| value.as_str().map(str::to_owned))
25 .unwrap_or_default()
26}
27
28pub fn tool_version() -> ToolVersion {
29 ToolVersion(env!("CARGO_PKG_VERSION").to_owned())
30}
31
32fn location(mutant: &PlannedMutant) -> MutantLocation {
33 MutantLocation {
34 mutant_id: mutant.mutant.id.as_str().to_owned(),
35 file: mutant.file.clone(),
36 line: mutant.mutant.line,
37 column: mutant.mutant.column + 1,
38 span: mutant.mutant.span,
39 operator: mutant.mutant.operator.as_str().to_owned(),
40 function: mutant
41 .function
42 .as_ref()
43 .and_then(|function| function.name.clone()),
44 function_line: mutant.function.as_ref().map(|function| function.line),
45 rank: mutant.rank,
46 change: Some(fallow_output::MutantChange {
47 from: mutant.mutant.change.from.to_owned(),
48 to: mutant.mutant.change.to.to_owned(),
49 }),
50 }
51}
52
53fn prune_reason(mutant: &PlannedMutant) -> Option<String> {
54 match mutant.disposition {
55 MutantDisposition::Pruned(reason) => Some(kebab::<PruneReason>(&reason)),
56 _ => None,
57 }
58}
59
60fn test_reach(mutant: &PlannedMutant) -> Option<MutantTestReach> {
62 let reach = mutant.test_reach.as_ref()?;
63 Some(MutantTestReach {
64 reach: match reach.reach {
65 ExportTestReach::Direct => TestReachClass::Direct,
66 ExportTestReach::Transitive => TestReachClass::Transitive,
67 ExportTestReach::MockedOnly => TestReachClass::MockedOnly,
68 ExportTestReach::None => TestReachClass::None,
69 ExportTestReach::NoneFile => TestReachClass::NoneFile,
70 },
71 export_name: reach.export_name.clone(),
72 tests: reach.tests.clone(),
73 mocked_targets: reach.mocked_targets.clone(),
74 })
75}
76
77pub fn skipped_files(plan: &MutationPlan) -> Vec<MutationSkippedFile> {
79 plan.skipped_files
80 .iter()
81 .map(|file| MutationSkippedFile {
82 path: file.path.clone(),
83 reason: kebab(&file.reason),
84 })
85 .collect()
86}
87
88pub fn plan_mutants(plan: &MutationPlan) -> Vec<MutationPlanMutant> {
90 plan.mutants
91 .iter()
92 .map(|mutant| MutationPlanMutant {
93 location: location(mutant),
94 disposition: match mutant.disposition {
95 MutantDisposition::Planned => MutationPlanDisposition::Planned,
96 MutantDisposition::Pending => MutationPlanDisposition::Pending,
97 MutantDisposition::NoCoverage => MutationPlanDisposition::NoCoverage,
98 MutantDisposition::Pruned(_) => MutationPlanDisposition::Pruned,
99 },
100 prune_reason: prune_reason(mutant),
101 test_reach: (mutant.disposition == MutantDisposition::NoCoverage)
102 .then(|| test_reach(mutant))
103 .flatten(),
104 })
105 .collect()
106}
107
108pub const fn plan_counts(plan: &MutationPlan) -> MutationPlanCountsOutput {
110 MutationPlanCountsOutput {
111 total: plan.counts.total,
112 planned: plan.counts.planned,
113 pending: plan.counts.pending,
114 no_coverage: plan.counts.no_coverage,
115 pruned_dead_code: plan.counts.pruned.dead_code,
116 pruned_generated: plan.counts.pruned.generated,
117 pruned_side_effect_sink: plan.counts.pruned.side_effect_sink,
118 pruned_framework_config: plan.counts.pruned.framework_config,
119 }
120}
121
122pub fn plan_files(plan: &MutationPlan) -> Vec<MutationPlanFile> {
124 plan.files
125 .iter()
126 .map(|file| MutationPlanFile {
127 path: file.path.clone(),
128 source_sha256: file.source_sha256.clone(),
129 static_test_files: file.static_test_files.clone(),
130 })
131 .collect()
132}
133
134pub fn mutant_results(plan: &MutationPlan, outcome: Option<&ScheduleOutcome>) -> Vec<MutantResult> {
143 plan.mutants
144 .iter()
145 .map(|mutant| {
146 let mut result = mutant_result(mutant, outcome);
147 if result.status == MutantStatus::NoCoverage {
148 result.test_reach = test_reach(mutant);
149 }
150 result
151 })
152 .collect()
153}
154
155fn mutant_result(mutant: &PlannedMutant, outcome: Option<&ScheduleOutcome>) -> MutantResult {
156 let base = MutantResult {
157 location: location(mutant),
158 status: MutantStatus::Pending,
159 prune_reason: prune_reason(mutant),
160 killed_by: None,
161 covering_tests: Vec::new(),
162 static_mutant: false,
163 duration_ms: None,
164 test_reach: None,
165 retried: false,
166 attempts: Vec::new(),
167 cached: false,
168 type_error_codes: Vec::new(),
169 };
170 let scheduled = outcome.and_then(|outcome| outcome.results.get(mutant.mutant.id.as_str()));
171 match (mutant.disposition, scheduled) {
172 (MutantDisposition::Pruned(_), _) => MutantResult {
173 status: MutantStatus::Pruned,
174 ..base
175 },
176 (
177 MutantDisposition::Planned | MutantDisposition::Pending | MutantDisposition::NoCoverage,
178 Some(result),
179 ) => MutantResult {
180 status: result.status,
181 killed_by: result.killed_by.clone(),
182 covering_tests: result.covering_tests.clone(),
183 static_mutant: result.static_mutant,
184 duration_ms: result.duration_ms,
185 retried: result.retried,
186 attempts: result.attempts.clone(),
187 cached: result.cached,
188 ..base
189 },
190 (MutantDisposition::NoCoverage, None) => MutantResult {
191 status: MutantStatus::NoCoverage,
192 ..base
193 },
194 (MutantDisposition::Planned | MutantDisposition::Pending, _) => base,
195 }
196}
197
198pub fn summary(results: &[MutantResult]) -> MutationSummary {
200 MutationSummary::from_results(results)
201}
202
203type FunctionKey = (String, Option<u32>, Option<(u32, u32)>);
206
207pub fn function_results(
210 plan: &MutationPlan,
211 results: &[MutantResult],
212) -> Vec<MutationFunctionResult> {
213 let mut groups: BTreeMap<FunctionKey, MutationFunctionResult> = BTreeMap::new();
214 for (mutant, result) in plan.mutants.iter().zip(results) {
215 let function = mutant.function.as_ref();
216 let key = (
217 mutant.file.clone(),
218 function.map(|function| function.line),
219 function.map(|function| function.span),
220 );
221 let group = groups.entry(key).or_insert_with(|| MutationFunctionResult {
222 file: mutant.file.clone(),
223 name: function.and_then(|function| function.name.clone()),
224 line: function.map(|function| function.line),
225 end_line: function.map(|function| function.end_line),
226 in_diff: function.is_some_and(|function| function.in_diff),
227 cyclomatic: function.and_then(|function| function.cyclomatic),
228 counts: MutationStatusCounts::default(),
229 score: None,
230 survived: Vec::new(),
231 });
232 group.counts.add(result.status);
233 if result.status == MutantStatus::Survived {
234 group.survived.push(result.location.mutant_id.clone());
235 }
236 }
237 groups
238 .into_values()
239 .map(|mut group| {
240 group.score = group.counts.score();
241 group
242 })
243 .collect()
244}
245
246pub struct CompletionFacts<'a> {
248 pub plan: &'a MutationPlan,
249 pub results: &'a [MutantResult],
250 pub baseline_failures: &'a [MutationBaselineFailure],
251 pub worktree_changed: bool,
252 pub worktree_unchecked: bool,
254 pub unretried: bool,
256 pub unloaded_paths: &'a [String],
258 pub undiscovered_test_files: &'a [String],
260 pub typecheck_failed: bool,
262 pub promoted: &'a [String],
265}
266
267pub fn completion(facts: &CompletionFacts<'_>) -> MutationCompletion {
270 let mut reasons = Vec::new();
271 if facts
272 .results
273 .iter()
274 .any(|result| result.status == MutantStatus::RuntimeError)
275 {
276 reasons.push(MutationIncompleteReason::RuntimeError);
277 }
278 let promoted: FxHashSet<&str> = facts.promoted.iter().map(String::as_str).collect();
279 let mut limit_pending = false;
280 let mut budget_pending = false;
281 for (mutant, result) in facts.plan.mutants.iter().zip(facts.results) {
282 if result.status != MutantStatus::Pending {
283 continue;
284 }
285 if is_scheduled(mutant, &promoted) {
286 budget_pending = true;
287 } else if mutant.disposition == MutantDisposition::Pending {
288 limit_pending = true;
289 }
290 }
291 if limit_pending {
292 reasons.push(MutationIncompleteReason::MutantLimit);
293 }
294 if budget_pending || facts.unretried {
295 reasons.push(MutationIncompleteReason::TimeBudget);
296 }
297 if !facts.plan.skipped_files.is_empty() {
298 reasons.push(MutationIncompleteReason::SkippedFiles);
299 }
300 if has_unloaded_reached_file(facts.plan, facts.unloaded_paths, &promoted) {
301 reasons.push(MutationIncompleteReason::UnloadedFiles);
302 }
303 if !facts.undiscovered_test_files.is_empty() {
304 reasons.push(MutationIncompleteReason::TestFilesNotDiscovered);
305 }
306 if !facts.baseline_failures.is_empty() {
307 reasons.push(MutationIncompleteReason::BaselineFailure);
308 }
309 if facts.worktree_changed {
310 reasons.push(MutationIncompleteReason::WorktreeChanged);
311 }
312 if facts.worktree_unchecked {
313 reasons.push(MutationIncompleteReason::WorktreeUnchecked);
314 }
315 if facts.typecheck_failed {
316 reasons.push(MutationIncompleteReason::TypecheckFailed);
317 }
318 MutationCompletion::from_reasons(reasons)
319}
320
321fn is_scheduled(mutant: &PlannedMutant, promoted: &FxHashSet<&str>) -> bool {
325 match mutant.disposition {
326 MutantDisposition::Planned => true,
327 MutantDisposition::Pending => promoted.contains(mutant.mutant.id.as_str()),
328 MutantDisposition::NoCoverage | MutantDisposition::Pruned(_) => false,
329 }
330}
331
332fn has_unloaded_reached_file(
337 plan: &MutationPlan,
338 unloaded_paths: &[String],
339 promoted: &FxHashSet<&str>,
340) -> bool {
341 if unloaded_paths.is_empty() {
342 return false;
343 }
344 let unloaded: FxHashSet<&str> = unloaded_paths.iter().map(String::as_str).collect();
345 plan.files.iter().any(|file| {
346 unloaded.contains(file.path.as_str())
347 && !file.static_test_files.is_empty()
348 && plan
349 .mutants
350 .iter()
351 .any(|mutant| mutant.file == file.path && is_scheduled(mutant, promoted))
352 })
353}
354
355#[must_use]
370pub fn baseline_blocked_run_error(report: &MutationReport) -> Option<ProgrammaticError> {
371 let counts = &report.summary.counts;
372 if has_test_result(counts) || counts.pending > 0 {
373 return None;
374 }
375 let first = report.baseline_failures.first()?;
376 let name = first
377 .test_id
378 .as_deref()
379 .or(first.file.as_deref())
380 .unwrap_or("a test file");
381 let mut message = format!(
382 "no mutant has a test result, because the test suite fails with no active mutant: {name}"
383 );
384 if let Some(line) = first.message.as_deref().and_then(first_message_line) {
385 message.push_str(": ");
386 message.push_str(line);
387 }
388 let others = report.baseline_failures.len() - 1;
389 if others > 0 {
390 let noun = if others == 1 { "failure" } else { "failures" };
391 let _ = write!(
392 message,
393 " ({others} more {noun}, see `baseline_failures` in the JSON report)"
394 );
395 }
396 Some(
397 ProgrammaticError::new(message, 2)
398 .with_code("FALLOW_MUTATION_BASELINE_FAILED")
399 .with_help(
400 "run the tests with the runner directly and make them pass, for example with the environment variables that the package test script sets, then run fallow mutation again",
401 ),
402 )
403}
404
405const fn has_test_result(counts: &MutationStatusCounts) -> bool {
408 counts.killed + counts.timeout + counts.survived + counts.flaky > 0
409}
410
411fn first_message_line(message: &str) -> Option<&str> {
413 message.lines().map(str::trim).find(|line| !line.is_empty())
414}
415
416pub fn baseline_failures(outcome: Option<&ScheduleOutcome>) -> Vec<MutationBaselineFailure> {
418 outcome
419 .map(|outcome| {
420 outcome
421 .baseline_failures
422 .iter()
423 .map(|failure| MutationBaselineFailure {
424 file: failure.file.clone(),
425 test_id: failure.test_id.clone(),
426 message: failure.message.clone(),
427 mutant_ids: failure.mutant_ids.clone(),
428 })
429 .collect()
430 })
431 .unwrap_or_default()
432}
433
434pub fn issues(outcome: Option<&ScheduleOutcome>) -> Vec<MutationRunIssue> {
436 outcome
437 .map(|outcome| {
438 outcome
439 .issues
440 .iter()
441 .map(|issue| MutationRunIssue {
442 code: issue.code.to_owned(),
443 message: issue.message.clone(),
444 mutant_id: issue.mutant_id.clone(),
445 stderr_tail: issue.stderr_tail.clone(),
446 })
447 .collect()
448 })
449 .unwrap_or_default()
450}
451
452#[cfg(test)]
453mod tests {
454 use super::super::scheduler::{MutantOutcome, ScheduleTimings};
455 use super::*;
456 use fallow_engine::mutation::{
457 FunctionFacts, Mutant, MutantChange, MutantId, MutationPlanCounts, Operator, PlannedFile,
458 PruneCounts, SkipReason, SkippedFile,
459 };
460 use fallow_output::MutationCompletionStatus;
461 use rustc_hash::FxHashMap;
462
463 fn function(name: &str, line: u32) -> FunctionFacts {
464 FunctionFacts {
465 name: Some(name.to_owned()),
466 span: (line * 100, line * 100 + 50),
467 line,
468 end_line: line + 3,
469 cyclomatic: Some(2),
470 in_diff: true,
471 }
472 }
473
474 fn planned(
475 id: &str,
476 rank: Option<u32>,
477 disposition: MutantDisposition,
478 function: Option<FunctionFacts>,
479 ) -> PlannedMutant {
480 PlannedMutant {
481 mutant: Mutant {
482 id: MutantId(id.to_owned()),
483 operator: Operator::ArithmeticOperator,
484 span: (10, 15),
485 line: 2,
486 column: 9,
487 original: "a + b".to_owned(),
488 replacement: "a - b".to_owned(),
489 enclosing_function: None,
490 change: MutantChange::new("+", "-"),
491 },
492 file: "src/a.ts".to_owned(),
493 rank,
494 disposition,
495 function,
496 test_reach: Some(fallow_engine::mutation::MutantTestReach {
497 reach: ExportTestReach::MockedOnly,
498 export_name: Some("add".to_owned()),
499 tests: vec!["test/a.test.ts".to_owned()],
500 mocked_targets: vec!["src/a.ts".to_owned()],
501 }),
502 }
503 }
504
505 fn plan(mutants: Vec<PlannedMutant>, pending: usize) -> MutationPlan {
506 MutationPlan {
507 files: vec![PlannedFile {
508 path: "src/a.ts".to_owned(),
509 source_sha256: "ab".repeat(32),
510 instrumented_code: "SECRET SOURCE".to_owned(),
511 static_test_files: vec!["test/a.test.ts".to_owned()],
512 }],
513 counts: MutationPlanCounts {
514 total: mutants.len(),
515 pending,
516 pruned: PruneCounts::default(),
517 ..MutationPlanCounts::default()
518 },
519 mutants,
520 test_files: vec!["test/a.test.ts".to_owned()],
521 reach_files: Vec::new(),
522 test_file_reach: BTreeMap::new(),
523 support_files: Vec::new(),
524 no_test_files: false,
525 framework_config_checked: false,
526 skipped_files: Vec::new(),
527 unmatched_paths: Vec::new(),
528 }
529 }
530
531 fn outcome(results: &[(&str, MutantStatus)]) -> ScheduleOutcome {
532 ScheduleOutcome {
533 ready: None,
534 results: results
535 .iter()
536 .map(|(id, status)| {
537 (
538 (*id).to_owned(),
539 MutantOutcome {
540 status: *status,
541 killed_by: (*status == MutantStatus::Killed)
542 .then(|| "test/a.test.ts::t1".to_owned()),
543 covering_tests: vec!["test/a.test.ts::t1".to_owned()],
544 static_mutant: false,
545 duration_ms: Some(4),
546 retried: matches!(
547 *status,
548 MutantStatus::Survived | MutantStatus::Timeout
549 ),
550 attempts: Vec::new(),
551 cached: false,
552 },
553 )
554 })
555 .collect::<FxHashMap<_, _>>(),
556 baseline_failures: Vec::new(),
557 unloaded_paths: Vec::new(),
558 undiscovered_test_files: Vec::new(),
559 issues: Vec::new(),
560 unretried: Vec::new(),
561 timings: ScheduleTimings::default(),
562 workers: 1,
563 cache_keys: FxHashMap::default(),
564 promoted: Vec::new(),
565 }
566 }
567
568 #[test]
569 fn a_pending_mutant_that_the_dry_run_never_hit_is_no_coverage() {
570 let plan = plan(
571 vec![
572 planned("m1", Some(1), MutantDisposition::Planned, None),
573 planned("m2", Some(2), MutantDisposition::Pending, None),
574 planned("m3", Some(3), MutantDisposition::Pending, None),
575 ],
576 2,
577 );
578 let mut outcome = outcome(&[
579 ("m1", MutantStatus::Killed),
580 ("m3", MutantStatus::NoCoverage),
581 ]);
582 if let Some(result) = outcome.results.get_mut("m3") {
583 result.covering_tests.clear();
584 }
585 let results = mutant_results(&plan, Some(&outcome));
586 let statuses: Vec<MutantStatus> = results.iter().map(|result| result.status).collect();
587 assert_eq!(
588 statuses,
589 vec![
590 MutantStatus::Killed,
591 MutantStatus::Pending,
592 MutantStatus::NoCoverage,
593 ]
594 );
595 let summary = summary(&results);
596 assert_eq!(summary.coverage, Some(2.0 / 3.0));
597 }
598
599 #[test]
600 fn a_promoted_pending_mutant_reports_its_run_and_the_limit_cuts_nothing() {
601 let plan = plan(
602 vec![
603 planned("m1", Some(1), MutantDisposition::Planned, None),
604 planned("m2", Some(2), MutantDisposition::Pending, None),
605 planned("m3", Some(3), MutantDisposition::Pending, None),
606 ],
607 2,
608 );
609 let reasons = |m2: MutantStatus| {
610 let mut outcome = outcome(&[
611 ("m1", MutantStatus::NoCoverage),
612 ("m2", m2),
613 ("m3", MutantStatus::NoCoverage),
614 ]);
615 outcome.promoted = vec!["m2".to_owned()];
616 let results = mutant_results(&plan, Some(&outcome));
617 let statuses: Vec<MutantStatus> = results.iter().map(|result| result.status).collect();
618 assert_eq!(
619 statuses,
620 vec![MutantStatus::NoCoverage, m2, MutantStatus::NoCoverage]
621 );
622 completion(&CompletionFacts {
623 plan: &plan,
624 results: &results,
625 baseline_failures: &[],
626 worktree_changed: false,
627 worktree_unchecked: false,
628 unretried: false,
629 unloaded_paths: &[],
630 undiscovered_test_files: &[],
631 typecheck_failed: false,
632 promoted: &outcome.promoted,
633 })
634 .reasons
635 };
636 assert_eq!(
637 reasons(MutantStatus::Killed),
638 Vec::<MutationIncompleteReason>::new(),
639 "each covered mutant ran, so the limit cut nothing"
640 );
641 assert_eq!(
642 reasons(MutantStatus::Pending),
643 vec![MutationIncompleteReason::TimeBudget],
644 "a promoted mutant that did not run before the deadline is a time budget miss"
645 );
646 }
647
648 #[test]
649 fn only_a_no_coverage_mutant_carries_its_test_reach() {
650 let plan = plan(
651 vec![
652 planned("m1", Some(1), MutantDisposition::Planned, None),
653 planned("m2", Some(2), MutantDisposition::Pending, None),
654 planned("m3", None, MutantDisposition::NoCoverage, None),
655 planned("m4", Some(3), MutantDisposition::Pending, None),
656 ],
657 2,
658 );
659 let mut outcome = outcome(&[
660 ("m1", MutantStatus::Killed),
661 ("m2", MutantStatus::NoCoverage),
662 ]);
663 if let Some(result) = outcome.results.get_mut("m2") {
664 result.covering_tests.clear();
665 }
666 let reach = |results: &[MutantResult]| -> Vec<(MutantStatus, Option<TestReachClass>)> {
667 results
668 .iter()
669 .map(|result| {
670 (
671 result.status,
672 result.test_reach.as_ref().map(|reach| reach.reach),
673 )
674 })
675 .collect()
676 };
677 let results = mutant_results(&plan, Some(&outcome));
678 let mocked = Some(TestReachClass::MockedOnly);
679 assert_eq!(
680 reach(&results),
681 vec![
682 (MutantStatus::Killed, None),
683 (MutantStatus::NoCoverage, mocked),
684 (MutantStatus::NoCoverage, mocked),
685 (MutantStatus::Pending, None),
686 ]
687 );
688 let entry = results[2].test_reach.as_ref().expect("a test reach");
689 assert_eq!(entry.export_name.as_deref(), Some("add"));
690 assert_eq!(entry.mocked_targets, vec!["src/a.ts"]);
691 let value = serde_json::to_value(&results[2]).expect("serializes");
692 assert_eq!(value["test_reach"]["reach"], "mocked-only");
693 assert!(serde_json::to_value(&results[0]).expect("serializes")["test_reach"].is_null());
694
695 let entries = plan_mutants(&plan);
696 let plan_reach: Vec<Option<TestReachClass>> = entries
697 .iter()
698 .map(|entry| entry.test_reach.as_ref().map(|reach| reach.reach))
699 .collect();
700 assert_eq!(plan_reach, vec![None, None, mocked, None]);
701 }
702
703 #[test]
704 fn plan_dispositions_and_schedule_outcomes_merge_into_one_status_each() {
705 let plan = plan(
706 vec![
707 planned(
708 "m1",
709 Some(1),
710 MutantDisposition::Planned,
711 Some(function("add", 1)),
712 ),
713 planned(
714 "m2",
715 Some(2),
716 MutantDisposition::Planned,
717 Some(function("add", 1)),
718 ),
719 planned("m3", Some(3), MutantDisposition::Planned, None),
720 planned("m4", Some(4), MutantDisposition::Pending, None),
721 planned(
722 "m5",
723 None,
724 MutantDisposition::NoCoverage,
725 Some(function("sub", 9)),
726 ),
727 planned(
728 "m6",
729 None,
730 MutantDisposition::Pruned(PruneReason::SideEffectSink),
731 Some(function("sub", 9)),
732 ),
733 ],
734 1,
735 );
736 let outcome = outcome(&[("m1", MutantStatus::Killed), ("m2", MutantStatus::Survived)]);
737 let results = mutant_results(&plan, Some(&outcome));
738 let statuses: Vec<MutantStatus> = results.iter().map(|result| result.status).collect();
739 assert_eq!(
740 statuses,
741 vec![
742 MutantStatus::Killed,
743 MutantStatus::Survived,
744 MutantStatus::Pending,
745 MutantStatus::Pending,
746 MutantStatus::NoCoverage,
747 MutantStatus::Pruned,
748 ]
749 );
750 assert_eq!(results[0].killed_by.as_deref(), Some("test/a.test.ts::t1"));
751 assert_eq!(results[0].location.column, 10, "the column is 1-based");
752 assert_eq!(results[5].prune_reason.as_deref(), Some("side-effect-sink"));
753 assert_eq!(
754 results
755 .iter()
756 .map(|result| result.retried)
757 .collect::<Vec<_>>(),
758 vec![false, true, false, false, false, false],
759 "only the retried survivor carries the flag"
760 );
761
762 let summary = summary(&results);
763 assert_eq!(summary.counts.total, 6);
764 assert_eq!(
765 summary.score,
766 Some(0.5),
767 "killed / (killed + survived); no_coverage stays out"
768 );
769 assert_eq!(
770 summary.coverage,
771 Some(0.8),
772 "covered / (covered + no_coverage); the pending mutants count as covered"
773 );
774
775 let completion = completion(&CompletionFacts {
776 plan: &plan,
777 results: &results,
778 baseline_failures: &[],
779 worktree_changed: false,
780 worktree_unchecked: false,
781 unretried: false,
782 unloaded_paths: &[],
783 undiscovered_test_files: &[],
784 typecheck_failed: false,
785 promoted: &[],
786 });
787 assert_eq!(
788 completion.reasons,
789 vec![
790 MutationIncompleteReason::MutantLimit,
791 MutationIncompleteReason::TimeBudget
792 ],
793 "m3 is planned with no outcome, m4 is after the limit"
794 );
795 }
796
797 #[test]
798 fn functions_roll_up_by_file_and_span_with_their_survivors() {
799 let plan = plan(
800 vec![
801 planned(
802 "m1",
803 Some(1),
804 MutantDisposition::Planned,
805 Some(function("add", 1)),
806 ),
807 planned(
808 "m2",
809 Some(2),
810 MutantDisposition::Planned,
811 Some(function("add", 1)),
812 ),
813 planned("m3", Some(3), MutantDisposition::Planned, None),
814 planned(
815 "m4",
816 Some(4),
817 MutantDisposition::Planned,
818 Some(function("sub", 9)),
819 ),
820 ],
821 0,
822 );
823 let outcome = outcome(&[
824 ("m1", MutantStatus::Killed),
825 ("m2", MutantStatus::Survived),
826 ("m3", MutantStatus::Timeout),
827 ("m4", MutantStatus::Survived),
828 ]);
829 let results = mutant_results(&plan, Some(&outcome));
830 let functions = function_results(&plan, &results);
831 type Shape<'a> = (
832 Option<&'a str>,
833 Option<u32>,
834 usize,
835 Option<f64>,
836 Vec<&'a str>,
837 );
838 let shape: Vec<Shape<'_>> = functions
839 .iter()
840 .map(|function| {
841 (
842 function.name.as_deref(),
843 function.line,
844 function.counts.total,
845 function.score,
846 function.survived.iter().map(String::as_str).collect(),
847 )
848 })
849 .collect();
850 assert_eq!(
851 shape,
852 vec![
853 (None, None, 1, Some(1.0), vec![]),
854 (Some("add"), Some(1), 2, Some(0.5), vec!["m2"]),
855 (Some("sub"), Some(9), 1, Some(0.0), vec!["m4"]),
856 ]
857 );
858 assert!(functions[1].in_diff);
859 assert_eq!(functions[1].end_line, Some(4));
860 assert_eq!(
861 completion(&CompletionFacts {
862 plan: &plan,
863 results: &results,
864 baseline_failures: &[],
865 worktree_changed: false,
866 worktree_unchecked: false,
867 unretried: false,
868 unloaded_paths: &[],
869 undiscovered_test_files: &[],
870 typecheck_failed: false,
871 promoted: &[],
872 })
873 .reasons,
874 Vec::<MutationIncompleteReason>::new()
875 );
876 }
877
878 #[test]
879 fn runtime_errors_skipped_files_baseline_failures_and_worktree_changes_make_a_run_partial() {
880 let mut plan = plan(
881 vec![planned("m1", Some(1), MutantDisposition::Planned, None)],
882 0,
883 );
884 plan.skipped_files.push(SkippedFile {
885 path: "src/big.ts".to_owned(),
886 reason: SkipReason::NotAnalyzed,
887 });
888 let outcome = outcome(&[("m1", MutantStatus::RuntimeError)]);
889 let results = mutant_results(&plan, Some(&outcome));
890 let failure = MutationBaselineFailure {
891 file: Some("test/a.test.ts".to_owned()),
892 test_id: None,
893 message: Some("boom".to_owned()),
894 mutant_ids: Vec::new(),
895 };
896 let completion = completion(&CompletionFacts {
897 plan: &plan,
898 results: &results,
899 baseline_failures: &[failure],
900 worktree_changed: true,
901 worktree_unchecked: false,
902 unretried: false,
903 unloaded_paths: &[],
904 undiscovered_test_files: &[],
905 typecheck_failed: false,
906 promoted: &[],
907 });
908 assert_eq!(
909 completion.reasons,
910 vec![
911 MutationIncompleteReason::RuntimeError,
912 MutationIncompleteReason::SkippedFiles,
913 MutationIncompleteReason::BaselineFailure,
914 MutationIncompleteReason::WorktreeChanged,
915 ]
916 );
917 assert_eq!(skipped_files(&plan)[0].reason, "not-analyzed");
918 }
919
920 #[test]
921 fn an_unretried_mutant_and_an_unchecked_worktree_make_a_run_partial() {
922 let plan = plan(
923 vec![planned("m1", Some(1), MutantDisposition::Planned, None)],
924 0,
925 );
926 let outcome = outcome(&[("m1", MutantStatus::Survived)]);
927 let results = mutant_results(&plan, Some(&outcome));
928 let completion = completion(&CompletionFacts {
929 plan: &plan,
930 results: &results,
931 baseline_failures: &[],
932 worktree_changed: false,
933 worktree_unchecked: true,
934 unretried: true,
935 unloaded_paths: &[],
936 undiscovered_test_files: &[],
937 typecheck_failed: false,
938 promoted: &[],
939 });
940 assert_eq!(
941 completion.reasons,
942 vec![
943 MutationIncompleteReason::TimeBudget,
944 MutationIncompleteReason::WorktreeUnchecked,
945 ]
946 );
947 }
948
949 #[test]
950 fn an_unloaded_file_that_a_dry_run_test_reaches_makes_a_run_partial() {
951 let mut plan = plan(
952 vec![planned("m1", Some(1), MutantDisposition::Planned, None)],
953 0,
954 );
955 plan.files.push(PlannedFile {
956 path: "src/b.ts".to_owned(),
957 source_sha256: "cd".repeat(32),
958 instrumented_code: String::new(),
959 static_test_files: Vec::new(),
960 });
961 let results = mutant_results(&plan, Some(&outcome(&[("m1", MutantStatus::NoCoverage)])));
962 let reasons = |unloaded: &[String]| {
963 completion(&CompletionFacts {
964 plan: &plan,
965 results: &results,
966 baseline_failures: &[],
967 worktree_changed: false,
968 worktree_unchecked: false,
969 unretried: false,
970 unloaded_paths: unloaded,
971 undiscovered_test_files: &[],
972 typecheck_failed: false,
973 promoted: &[],
974 })
975 .reasons
976 };
977 assert_eq!(
978 reasons(&["src/a.ts".to_owned()]),
979 vec![MutationIncompleteReason::UnloadedFiles],
980 "a static test reaches src/a.ts, so the runner had to load it"
981 );
982 assert_eq!(
983 reasons(&["src/b.ts".to_owned()]),
984 Vec::new(),
985 "no static test reaches src/b.ts, so it is a fact, not a fault"
986 );
987 }
988
989 #[test]
990 fn a_static_test_file_that_the_runner_does_not_list_makes_a_run_partial() {
991 let plan = plan(
992 vec![planned("m1", Some(1), MutantDisposition::Planned, None)],
993 0,
994 );
995 let results = mutant_results(&plan, Some(&outcome(&[("m1", MutantStatus::Survived)])));
996 let completion_with = |undiscovered: &[String]| {
997 completion(&CompletionFacts {
998 plan: &plan,
999 results: &results,
1000 baseline_failures: &[],
1001 worktree_changed: false,
1002 worktree_unchecked: false,
1003 unretried: false,
1004 unloaded_paths: &[],
1005 undiscovered_test_files: undiscovered,
1006 typecheck_failed: false,
1007 promoted: &[],
1008 })
1009 };
1010 let partial = completion_with(&["test/integration.test.ts".to_owned()]);
1011 assert_eq!(partial.status, MutationCompletionStatus::Partial);
1012 assert_eq!(
1013 partial.reasons,
1014 vec![MutationIncompleteReason::TestFilesNotDiscovered],
1015 "a test that never ran can kill the survivor, so the survivor list is not conclusive"
1016 );
1017 assert_eq!(
1018 completion_with(&[]).status,
1019 MutationCompletionStatus::Complete
1020 );
1021 }
1022
1023 #[test]
1024 fn a_static_map_miss_takes_the_schedule_outcome() {
1025 let plan = plan(
1026 vec![
1027 planned("m1", None, MutantDisposition::NoCoverage, None),
1028 planned("m2", None, MutantDisposition::NoCoverage, None),
1029 ],
1030 0,
1031 );
1032 let results = mutant_results(&plan, Some(&outcome(&[("m1", MutantStatus::Killed)])));
1033 assert_eq!(results[0].status, MutantStatus::Killed);
1034 assert_eq!(results[0].killed_by.as_deref(), Some("test/a.test.ts::t1"));
1035 assert_eq!(results[1].status, MutantStatus::NoCoverage);
1036 }
1037
1038 #[test]
1039 fn a_cached_result_is_marked_in_the_report_and_counts_like_a_run() {
1040 let plan = plan(
1041 vec![
1042 planned("m1", Some(1), MutantDisposition::Planned, None),
1043 planned("m2", Some(2), MutantDisposition::Planned, None),
1044 ],
1045 0,
1046 );
1047 let mut outcome = outcome(&[("m1", MutantStatus::Killed), ("m2", MutantStatus::Survived)]);
1048 if let Some(result) = outcome.results.get_mut("m1") {
1049 result.cached = true;
1050 }
1051 let results = mutant_results(&plan, Some(&outcome));
1052 assert!(results[0].cached);
1053 assert!(!results[1].cached);
1054 assert_eq!(summary(&results).score, Some(0.5));
1055 }
1056
1057 #[test]
1058 fn plan_output_parts_carry_no_source_text() {
1059 let plan = plan(
1060 vec![planned(
1061 "m1",
1062 Some(1),
1063 MutantDisposition::Pruned(PruneReason::FrameworkConfig),
1064 None,
1065 )],
1066 0,
1067 );
1068 let files = serde_json::to_string(&plan_files(&plan)).expect("json");
1069 let mutants = serde_json::to_string(&plan_mutants(&plan)).expect("json");
1070 assert!(
1071 mutants.contains(r#""change":{"from":"+","to":"-"}"#),
1072 "{mutants}"
1073 );
1074 for text in [&files, &mutants] {
1075 assert!(!text.contains("SECRET SOURCE"));
1076 assert!(!text.contains("a + b"));
1077 assert!(!text.contains("a - b"));
1078 }
1079 assert!(mutants.contains(r#""disposition":"pruned""#), "{mutants}");
1080 assert!(
1081 mutants.contains(r#""prune_reason":"framework-config""#),
1082 "{mutants}"
1083 );
1084 assert!(files.contains("test/a.test.ts"), "{files}");
1085 }
1086
1087 #[test]
1088 fn results_carry_the_change_without_project_text() {
1089 let mut logical = planned("m1", Some(1), MutantDisposition::Planned, None);
1090 logical.mutant.operator = Operator::LogicalOperator;
1091 logical.mutant.original = "??".to_owned();
1092 logical.mutant.replacement = "&&".to_owned();
1093 logical.mutant.change = MutantChange::new("??", "&&");
1094 let mut string = planned("m2", Some(2), MutantDisposition::Planned, None);
1095 string.mutant.operator = Operator::StringLiteral;
1096 string.mutant.original = "\"SECRET SOURCE\"".to_owned();
1097 string.mutant.replacement = "\"\"".to_owned();
1098 string.mutant.change = MutantChange::new(MutantChange::STRING, "\"\"");
1099 let plan = plan(vec![logical, string], 2);
1100 let value = serde_json::to_value(mutant_results(&plan, None)).expect("json");
1101 assert_eq!(
1102 value[0]["change"],
1103 serde_json::json!({"from": "??", "to": "&&"})
1104 );
1105 assert_eq!(
1106 value[1]["change"],
1107 serde_json::json!({"from": "<string>", "to": "\"\""})
1108 );
1109 assert!(!value.to_string().contains("SECRET SOURCE"), "{value}");
1110 }
1111}