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