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