Skip to main content

fallow_api/mutation/
report.rs

1//! Builds the public mutation envelopes from the plan and the schedule.
2
3use 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
20/// The kebab-case name of a serde enum value.
21fn 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
60/// The public test reach of a plan mutant.
61fn 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
77/// The skipped files of a plan.
78pub 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
88/// The plan mutants as public plan entries.
89pub 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
108/// The plan totals.
109pub 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
122/// The plan files, with no source text.
123pub 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
134/// The result of each plan mutant: the plan disposition, or the scheduler
135/// outcome of a planned mutant. A planned mutant with no outcome is
136/// `pending`. A pending mutant that the dry run never covered is
137/// `no_coverage`. A pending mutant that took the place of an uncovered
138/// planned mutant has the outcome of its run. A statically `no_coverage`
139/// mutant with an outcome is a static map miss, and the outcome of the
140/// dry-run map wins. Each `no_coverage`
141/// result gets the test reach of its plan mutant.
142pub 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
198/// The totals of `results`.
199pub fn summary(results: &[MutantResult]) -> MutationSummary {
200    MutationSummary::from_results(results)
201}
202
203/// A function group: the file, then the first line and span of the function.
204/// Code outside each function has no line and no span, so it sorts first.
205type FunctionKey = (String, Option<u32>, Option<(u32, u32)>);
206
207/// The results per enclosing function, by file, then line. Code outside each
208/// function comes first in its file.
209pub 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
246/// The facts that decide the completion of a run.
247pub 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    /// The end-of-run worktree check failed.
253    pub worktree_unchecked: bool,
254    /// The time budget ended before each survivor and timeout got its retry.
255    pub unretried: bool,
256    /// The `path_map` keys that the runner did not load in the dry run.
257    pub unloaded_paths: &'a [String],
258    /// The static test files that the runner does not list.
259    pub undiscovered_test_files: &'a [String],
260    /// The type check of the survivors failed.
261    pub typecheck_failed: bool,
262    /// The pending mutants that the scheduler ran in the place of an
263    /// uncovered planned mutant.
264    pub promoted: &'a [String],
265}
266
267/// The completion: `partial` when a planned mutant has no final status or a
268/// fact makes the survivor list inconclusive.
269pub 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
321/// True when the scheduler gave `mutant` a place inside the `--max-mutants`
322/// limit: a planned mutant, or a pending mutant in the place of an uncovered
323/// planned mutant.
324fn 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
332/// True when the runner did not load a file that has a scheduled mutant and a
333/// static test file. The static map says that a dry-run test loads such a
334/// file, so its `no_coverage` mutants are a load fault (`protocol.md`
335/// section 6), not a fact. A file that no static test reaches stays a fact.
336fn 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/// The error of a run in which no mutant got a test result while tests
356/// failed with no active mutant.
357///
358/// A mutant has a test result when it is `killed`, `timeout`, `survived` or
359/// `flaky`. Such a run measured nothing, because the failed tests could not
360/// detect a mutant. The error fires only when each other mutant is final
361/// (`no_coverage`, `runtime_error`, `compile_error`, `equivalent` or
362/// `pruned`). A `pending` mutant means that the time budget or
363/// `--max-mutants` stopped the run, so the baseline failure is not the cause.
364/// A run without a baseline failure and with only `no_coverage` mutants is a
365/// fact about the tests and gets no error. The error has exit code 2 and the
366/// code `FALLOW_MUTATION_BASELINE_FAILED`, the same as a dry run that fails
367/// outside each test file. It names the first failure and the first line of
368/// its message.
369#[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
405/// True when a mutant got a test result: `killed`, `timeout`, `survived` or
406/// `flaky`.
407const fn has_test_result(counts: &MutationStatusCounts) -> bool {
408    counts.killed + counts.timeout + counts.survived + counts.flaky > 0
409}
410
411/// The first line of a failure message that has text, trimmed.
412fn first_message_line(message: &str) -> Option<&str> {
413    message.lines().map(str::trim).find(|line| !line.is_empty())
414}
415
416/// The public baseline failures of a schedule.
417pub 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
434/// The public issues of a schedule.
435pub 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}