Skip to main content

fallow_output/
mutation.rs

1//! Independent JSON contracts for mutation testing.
2//!
3//! A mutation report never contains source text, so a report can leave the
4//! machine without the code of the project. A mutant is identified by its id, file, position and operator.
5//! The `inspect` command shows the code of one mutant.
6
7use fallow_types::envelope::{ElapsedMs, ToolVersion};
8use serde::{Deserialize, Serialize};
9
10use crate::audit_test_reach::TestReachClass;
11use crate::root_envelopes::serialize_named_json_output;
12
13/// Current mutation report schema version.
14pub const MUTATION_SCHEMA_VERSION: u32 = 1;
15/// Current mutation plan schema version.
16pub const MUTATION_PLAN_SCHEMA_VERSION: u32 = 1;
17/// Current mutation inspect schema version.
18pub const MUTATION_INSPECT_SCHEMA_VERSION: u32 = 1;
19
20/// Version singleton for a mutation report.
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
22#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
23pub enum MutationSchemaVersion {
24    /// Initial independent contract.
25    #[serde(rename = "1")]
26    V1,
27}
28
29/// Version singleton for a mutation plan.
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
31#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
32pub enum MutationPlanSchemaVersion {
33    /// Initial independent contract.
34    #[serde(rename = "1")]
35    V1,
36}
37
38/// The result of one mutant.
39#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
40#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
41#[serde(rename_all = "snake_case")]
42pub enum MutantStatus {
43    /// A covering test failed.
44    Killed,
45    /// The run passed its timeout. A retry in a new companion with twice the
46    /// time also timed out, when the time budget allowed the retry. The
47    /// mutant counts as detected.
48    Timeout,
49    /// Each covering test passed, also in a retry in a new companion when
50    /// the time budget allowed the retry.
51    Survived,
52    /// No test reaches the mutant.
53    NoCoverage,
54    /// The runner failed for a reason outside the tests.
55    RuntimeError,
56    /// The second run in a new companion changed whether the tests detect
57    /// the mutant.
58    Flaky,
59    /// A static rule removed the mutant.
60    Pruned,
61    /// The mutant did not run: the mutant limit or the time budget ended first.
62    Pending,
63    /// A verdict marks the mutant as equivalent to the original code: no
64    /// test can detect it.
65    Equivalent,
66    /// Each covering test passed, but the mutated file does not type-check.
67    /// Only a run with the type check of survivors gives this status. The
68    /// mutant does not count in the score.
69    CompileError,
70}
71
72/// The number of mutants for each status, and the mutation score.
73#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
74#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
75pub struct MutationStatusCounts {
76    /// Each mutant.
77    pub total: usize,
78    /// Killed mutants.
79    pub killed: usize,
80    /// Mutants that passed their timeout.
81    pub timeout: usize,
82    /// Mutants that each covering test let pass.
83    pub survived: usize,
84    /// Mutants that no test reaches.
85    pub no_coverage: usize,
86    /// Mutants with a runner fault.
87    pub runtime_error: usize,
88    /// Mutants where the second run in a new companion changed whether the
89    /// tests detect the mutant.
90    pub flaky: usize,
91    /// Mutants that a static rule removed.
92    pub pruned: usize,
93    /// Mutants that did not run.
94    pub pending: usize,
95    /// Mutants that a verdict marks as equivalent.
96    pub equivalent: usize,
97    /// Survived mutants that do not type-check. A report from a version
98    /// before `--typecheck` has no such field, so it reads as 0.
99    #[serde(default)]
100    pub compile_error: usize,
101}
102
103impl MutationStatusCounts {
104    /// The mutants that ran against the tests: `killed`, `timeout`,
105    /// `survived`, `runtime_error`, `flaky` and `compile_error`.
106    #[must_use]
107    pub const fn executed(&self) -> usize {
108        self.killed
109            + self.timeout
110            + self.survived
111            + self.runtime_error
112            + self.flaky
113            + self.compile_error
114    }
115
116    /// Adds one mutant with `status`.
117    pub const fn add(&mut self, status: MutantStatus) {
118        self.total += 1;
119        match status {
120            MutantStatus::Killed => self.killed += 1,
121            MutantStatus::Timeout => self.timeout += 1,
122            MutantStatus::Survived => self.survived += 1,
123            MutantStatus::NoCoverage => self.no_coverage += 1,
124            MutantStatus::RuntimeError => self.runtime_error += 1,
125            MutantStatus::Flaky => self.flaky += 1,
126            MutantStatus::Pruned => self.pruned += 1,
127            MutantStatus::Pending => self.pending += 1,
128            MutantStatus::Equivalent => self.equivalent += 1,
129            MutantStatus::CompileError => self.compile_error += 1,
130        }
131    }
132
133    /// Removes one mutant with `status`. The count never goes below zero.
134    pub const fn remove(&mut self, status: MutantStatus) {
135        let count = match status {
136            MutantStatus::Killed => &mut self.killed,
137            MutantStatus::Timeout => &mut self.timeout,
138            MutantStatus::Survived => &mut self.survived,
139            MutantStatus::NoCoverage => &mut self.no_coverage,
140            MutantStatus::RuntimeError => &mut self.runtime_error,
141            MutantStatus::Flaky => &mut self.flaky,
142            MutantStatus::Pruned => &mut self.pruned,
143            MutantStatus::Pending => &mut self.pending,
144            MutantStatus::Equivalent => &mut self.equivalent,
145            MutantStatus::CompileError => &mut self.compile_error,
146        };
147        if *count > 0 {
148            *count -= 1;
149            self.total -= 1;
150        }
151    }
152
153    /// The mutation score over the executed mutants: `(killed + timeout) /
154    /// (killed + timeout + survived)`, from 0 to 1. `None` when no mutant
155    /// ran to a tested result.
156    ///
157    /// `no_coverage` mutants stay out of the score;
158    /// [`MutationSummary::coverage`] reports them. Runtime errors, flaky, pruned,
159    /// pending, equivalent and compile-error mutants never count.
160    #[must_use]
161    #[expect(
162        clippy::cast_precision_loss,
163        reason = "mutant counts are far below 2^52"
164    )]
165    pub fn score(&self) -> Option<f64> {
166        let detected = self.killed + self.timeout;
167        let executed = detected + self.survived;
168        (executed > 0).then(|| detected as f64 / executed as f64)
169    }
170}
171
172/// Whether the result is conclusive.
173#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
174#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
175#[serde(rename_all = "snake_case")]
176pub enum MutationCompletionStatus {
177    /// Each planned mutant has a final status. An empty survivor list is
178    /// conclusive.
179    Complete,
180    /// At least one planned mutant has no final status.
181    Partial,
182}
183
184/// Why a run is partial.
185#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
186#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
187#[serde(rename_all = "kebab-case")]
188pub enum MutationIncompleteReason {
189    /// At least one mutant has the status `runtime_error`.
190    RuntimeError,
191    /// The mutant limit left covered mutants `pending`.
192    MutantLimit,
193    /// The time budget ended before each mutant ran.
194    TimeBudget,
195    /// The plan did not instrument at least one selected file.
196    SkippedFiles,
197    /// The runner never loaded at least one instrumented file that has a
198    /// planned mutant and a static test file.
199    UnloadedFiles,
200    /// A test or a test file failed before any mutant was active.
201    BaselineFailure,
202    /// The run changed the git worktree.
203    WorktreeChanged,
204    /// The type check of the survivors failed, so a survivor can be a
205    /// mutant that does not type-check.
206    TypecheckFailed,
207    /// The end-of-run worktree check failed, so stray writes are unknown.
208    WorktreeUnchecked,
209}
210
211/// The completion status of a run.
212#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
213#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
214pub struct MutationCompletion {
215    /// `complete` or `partial`.
216    pub status: MutationCompletionStatus,
217    /// Why the run is partial, sorted. Empty for a complete run.
218    pub reasons: Vec<MutationIncompleteReason>,
219}
220
221impl MutationCompletion {
222    /// A completion with `reasons`. The status is `complete` only when the
223    /// list is empty.
224    #[must_use]
225    pub fn from_reasons(mut reasons: Vec<MutationIncompleteReason>) -> Self {
226        reasons.sort_unstable();
227        reasons.dedup();
228        let status = if reasons.is_empty() {
229            MutationCompletionStatus::Complete
230        } else {
231            MutationCompletionStatus::Partial
232        };
233        Self { status, reasons }
234    }
235}
236
237/// Which code a run mutates.
238#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
239#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
240#[serde(rename_all = "snake_case")]
241pub enum MutationScopeKind {
242    /// The added lines against a base ref.
243    Changed,
244    /// Each source file of the project.
245    All,
246    /// The files at the given paths.
247    Paths,
248}
249
250/// The scope of a plan or a run.
251#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
252#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
253pub struct MutationScopeOutput {
254    /// The kind of scope.
255    pub kind: MutationScopeKind,
256    /// The base ref of a `changed` scope.
257    #[serde(default, skip_serializing_if = "Option::is_none")]
258    pub base_ref: Option<String>,
259}
260
261/// The JavaScript runtime that ran the companion.
262#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
263#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
264pub struct MutationRuntimeOutput {
265    /// `node` or `bun`.
266    pub name: String,
267    /// The version that the runtime reports.
268    pub version: String,
269}
270
271/// The test runner of a run.
272#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
273#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
274pub struct MutationRunnerOutput {
275    /// The runner name, for example `vitest`.
276    pub name: String,
277    /// The runner version from the project.
278    pub version: String,
279    /// The wire protocol version of the companion.
280    pub protocol_version: u32,
281    /// The runtime of the companion.
282    pub runtime: MutationRuntimeOutput,
283    /// The number of companion processes that ran mutants.
284    pub concurrency: usize,
285    /// Facts of the runner setup that limit the result, kebab-case.
286    /// `typecheck-not-run`: the project config enables the Vitest type-check
287    /// pass, and the run did not execute it, so a type-level kill is not
288    /// counted. Absent when the runner states no fact.
289    #[serde(default, skip_serializing_if = "Vec::is_empty")]
290    pub notes: Vec<String>,
291}
292
293/// The source position of a mutant. No source text.
294#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
295#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
296pub struct MutantLocation {
297    /// The stable mutant id.
298    pub mutant_id: String,
299    /// The root-relative path, with forward slashes.
300    pub file: String,
301    /// The 1-based line.
302    pub line: u32,
303    /// The 1-based column.
304    pub column: u32,
305    /// The byte span in the original file.
306    pub span: (u32, u32),
307    /// The operator name, kebab-case.
308    pub operator: String,
309    /// The name of the enclosing function.
310    #[serde(default, skip_serializing_if = "Option::is_none")]
311    pub function: Option<String>,
312    /// The 1-based first line of the enclosing function.
313    #[serde(default, skip_serializing_if = "Option::is_none")]
314    pub function_line: Option<u32>,
315    /// The 1-based execution rank of a planned mutant.
316    #[serde(default, skip_serializing_if = "Option::is_none")]
317    pub rank: Option<u32>,
318}
319
320/// One mutant of a run.
321#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
322#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
323pub struct MutantResult {
324    /// The mutant and its position.
325    #[serde(flatten)]
326    pub location: MutantLocation,
327    /// The final status.
328    pub status: MutantStatus,
329    /// The static rule of a `pruned` mutant, kebab-case.
330    #[serde(default, skip_serializing_if = "Option::is_none")]
331    pub prune_reason: Option<String>,
332    /// The test that killed the mutant in this run. Absent for a kill by an
333    /// error outside a test.
334    ///
335    /// This is evidence of one run, not a stable fact. A run stops at the
336    /// first failing test, and it runs the fastest covering test first, so a
337    /// second run on the same code can name another covering test. No
338    /// fingerprint or baseline comparison uses this field.
339    #[serde(default, skip_serializing_if = "Option::is_none")]
340    pub killed_by: Option<String>,
341    /// The tests that reached the mutant in the dry run, sorted by test id.
342    #[serde(default, skip_serializing_if = "Vec::is_empty")]
343    pub covering_tests: Vec<String>,
344    /// True when the mutant runs while a module loads, outside each test.
345    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
346    pub static_mutant: bool,
347    /// The duration of one run in milliseconds. A `killed` mutant after a
348    /// retry reports the retry. Each other status reports the first run.
349    #[serde(default, skip_serializing_if = "Option::is_none")]
350    pub duration_ms: Option<u64>,
351    /// How tests reach the code of the mutant in the module graph. Only a
352    /// `no_coverage` mutant has this field.
353    #[serde(default, skip_serializing_if = "Option::is_none")]
354    pub test_reach: Option<MutantTestReach>,
355    /// True when the status comes from a first run and a retry in a new
356    /// companion. A `survived` or `timeout` mutant without this field got no
357    /// retry, because the time budget ended first.
358    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
359    pub retried: bool,
360    /// True when the status comes from the result cache of an earlier run.
361    /// The mutant did not run in this run. `--no-cache` runs each mutant.
362    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
363    pub cached: bool,
364    /// The TypeScript diagnostic codes of the new errors of a
365    /// `compile_error` mutant, sorted, at most 8.
366    #[serde(default, skip_serializing_if = "Vec::is_empty")]
367    pub type_error_codes: Vec<u32>,
368}
369
370/// How tests reach the code of one mutant in the module graph. The class
371/// gives a static reason why no test covers the mutant.
372///
373/// A mutant in the top-level statement that declares a value export gets the
374/// class of that export. A mutant outside each such statement gets the class
375/// of its file: `none-file` when no test loads the file, `mocked-only` when no
376/// test loads the file and a mock explains an export of the file, and `none`
377/// when a test loads the file.
378#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
379#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
380pub struct MutantTestReach {
381    /// The reach class. The same open set as the test reach of `audit`.
382    pub reach: TestReachClass,
383    /// The export whose declaration holds the mutant. Absent for a mutant
384    /// outside each value export declaration.
385    #[serde(default, skip_serializing_if = "Option::is_none")]
386    pub export_name: Option<String>,
387    /// For `mocked-only`: the test files whose references a mock removed,
388    /// sorted.
389    #[serde(default, skip_serializing_if = "Vec::is_empty")]
390    pub tests: Vec<String>,
391    /// For `mocked-only`: the mocked modules on the reference paths, sorted.
392    /// A mocked module can be a barrel in front of the file of the mutant.
393    #[serde(default, skip_serializing_if = "Vec::is_empty")]
394    pub mocked_targets: Vec<String>,
395}
396
397/// The result cache facts of a run.
398#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
399#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
400pub struct MutationCacheOutput {
401    /// The mutants whose status came from the cache. They did not run.
402    pub hits: usize,
403    /// The results that this run added to the cache.
404    pub stored: usize,
405    /// The test inputs that the cache key does not cover: modules that a
406    /// test loads through a dynamic import that the module graph does not
407    /// resolve (`dynamic-imports`), and files that a test reads
408    /// (`file-reads`). A change to them does not invalidate a cached result.
409    pub not_keyed: Vec<String>,
410}
411
412/// The results of the mutants of one function.
413#[derive(Debug, Clone, PartialEq, Serialize)]
414#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
415pub struct MutationFunctionResult {
416    /// The root-relative path, with forward slashes.
417    pub file: String,
418    /// The function name. `null` for code outside each function, and for an
419    /// anonymous function.
420    pub name: Option<String>,
421    /// The 1-based first line. `null` for code outside each function.
422    pub line: Option<u32>,
423    /// The 1-based last line. `null` for code outside each function.
424    pub end_line: Option<u32>,
425    /// True when an added line of the diff is in the function.
426    pub in_diff: bool,
427    /// The cyclomatic complexity, when the analysis measured the function.
428    #[serde(default, skip_serializing_if = "Option::is_none")]
429    pub cyclomatic: Option<u16>,
430    /// The number of mutants for each status.
431    pub counts: MutationStatusCounts,
432    /// The mutation score of the function.
433    pub score: Option<f64>,
434    /// The ids of the survived mutants, in rank order.
435    #[serde(default, skip_serializing_if = "Vec::is_empty")]
436    pub survived: Vec<String>,
437}
438
439/// The totals of a run.
440#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
441#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
442pub struct MutationSummary {
443    /// The number of mutants for each status.
444    pub counts: MutationStatusCounts,
445    /// The mutation score over the executed mutants, a fraction from 0 to 1:
446    /// `(killed + timeout) / (killed + timeout + survived)`. `null` when no
447    /// mutant ran to a tested result. `--min-score` and `mutation.minScore`
448    /// compare this value in percent.
449    pub score: Option<f64>,
450    /// The share of mutants that a test reaches, a fraction from 0 to 1:
451    /// `covered / (covered + no_coverage)` over the whole plan. `covered`
452    /// counts each executed mutant with a covering test and each `pending`
453    /// mutant that a passing dry-run test covers. A `pending` mutant that no
454    /// dry-run test covers is `no_coverage`, so a run that `--max-mutants`
455    /// caps reports the coverage of the full run. `equivalent` and `pruned`
456    /// mutants never count. `null` when no mutant is covered or unreached.
457    pub coverage: Option<f64>,
458}
459
460impl MutationSummary {
461    /// The totals, the score and the coverage of `results`.
462    #[must_use]
463    pub fn from_results(results: &[MutantResult]) -> Self {
464        let mut counts = MutationStatusCounts::default();
465        let (mut covered, mut uncovered) = (0usize, 0usize);
466        for result in results {
467            counts.add(result.status);
468            match result.status {
469                MutantStatus::NoCoverage => uncovered += 1,
470                MutantStatus::Pending => covered += 1,
471                MutantStatus::Killed
472                | MutantStatus::Timeout
473                | MutantStatus::Survived
474                | MutantStatus::RuntimeError
475                | MutantStatus::Flaky
476                | MutantStatus::CompileError
477                    if !result.covering_tests.is_empty() || result.static_mutant =>
478                {
479                    covered += 1;
480                }
481                _ => {}
482            }
483        }
484        Self {
485            counts,
486            score: counts.score(),
487            coverage: ratio(covered, covered + uncovered),
488        }
489    }
490}
491
492#[expect(
493    clippy::cast_precision_loss,
494    reason = "mutant counts are far below 2^52"
495)]
496fn ratio(part: usize, whole: usize) -> Option<f64> {
497    (whole > 0).then(|| part as f64 / whole as f64)
498}
499
500/// A test or a test file that failed with no active mutant. Its tests do not
501/// decide a mutant status.
502#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
503#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
504pub struct MutationBaselineFailure {
505    /// The test file, root-relative. `null` for an error outside each test
506    /// file.
507    pub file: Option<String>,
508    /// The test id, for a failed test.
509    #[serde(default, skip_serializing_if = "Option::is_none")]
510    pub test_id: Option<String>,
511    /// The error message, for an error outside a test, or the reason for a
512    /// test that did not pass in the dry run of a restarted companion.
513    #[serde(default, skip_serializing_if = "Option::is_none")]
514    pub message: Option<String>,
515    /// The mutants that ran without this test, because the test did not pass
516    /// in the dry run of a restarted companion. Each of them ran with its
517    /// other covering tests. Absent for a failure of the first dry run.
518    #[serde(default, skip_serializing_if = "Vec::is_empty")]
519    pub mutant_ids: Vec<String>,
520}
521
522/// A selected file that the plan did not instrument.
523#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
524#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
525pub struct MutationSkippedFile {
526    /// The root-relative path, with forward slashes.
527    pub path: String,
528    /// Why the plan skipped the file, kebab-case.
529    pub reason: String,
530}
531
532/// The git worktree check of a run: a run refuses a dirty worktree unless
533/// `--allow-dirty` is set, and it compares the worktree after the run.
534#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
535#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
536pub struct MutationWorktreeCheck {
537    /// False when the root is not in a git worktree, so no check ran.
538    pub checked: bool,
539    /// True when the caller allowed a dirty worktree.
540    pub allow_dirty: bool,
541    /// The paths that were dirty before the run, sorted.
542    #[serde(default, skip_serializing_if = "Vec::is_empty")]
543    pub dirty_at_start: Vec<String>,
544    /// The paths that the run changed, sorted. A non-empty list is an error.
545    pub changed_paths: Vec<String>,
546}
547
548/// An error of a run that did not stop the run.
549#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
550#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
551pub struct MutationRunIssue {
552    /// A stable kebab-case code: `runtime-error`, `static-map-miss`,
553    /// `test-files-not-discovered`, `typecheck-failed`, `worktree-changed`
554    /// or `worktree-check-failed`.
555    pub code: String,
556    /// A description.
557    pub message: String,
558    /// The mutant of the issue.
559    #[serde(default, skip_serializing_if = "Option::is_none")]
560    pub mutant_id: Option<String>,
561    /// The last part of the companion stderr.
562    #[serde(default, skip_serializing_if = "Option::is_none")]
563    pub stderr_tail: Option<String>,
564}
565
566/// The wall time of each phase, in milliseconds.
567#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)]
568#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
569pub struct MutationTimings {
570    /// The analysis and the plan.
571    pub plan_ms: u64,
572    /// The companion install, the run directory, and the first `init`.
573    pub setup_ms: u64,
574    /// The dry run.
575    pub dry_run_ms: u64,
576    /// The mutant runs.
577    pub mutants_ms: u64,
578    /// The second runs of survived and timed-out mutants.
579    pub retry_ms: u64,
580    /// The type check of the survivors. Zero when the run did not check
581    /// them.
582    pub typecheck_ms: u64,
583}
584
585/// Why the type check did not check a survivor.
586#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
587#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
588#[serde(rename_all = "kebab-case")]
589pub enum MutationTypecheckSkipReason {
590    /// No TypeScript project includes the file.
591    NoProject,
592    /// The checker does not check the file: a `// @ts-nocheck` comment, or a
593    /// JavaScript file without `checkJs`.
594    NotTypeChecked,
595    /// The file changed after the plan.
596    SourceChanged,
597    /// The file does not exist.
598    FileMissing,
599    /// The checker cannot read the file type: a Vue, Svelte or Astro
600    /// component file.
601    UnsupportedFile,
602}
603
604/// The number of survivors with one skip reason.
605#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
606#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
607pub struct MutationTypecheckSkip {
608    /// Why the type check did not check these survivors.
609    pub reason: MutationTypecheckSkipReason,
610    /// The number of survivors.
611    pub count: usize,
612}
613
614/// The type check of the survivors (`--typecheck`).
615#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
616#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
617pub struct MutationTypecheckOutput {
618    /// True when the type check gave a result for each survivor that it
619    /// received. False when the companion failed; the survivors then keep
620    /// the status `survived`.
621    pub completed: bool,
622    /// The survivors that the type check received.
623    pub survivors: usize,
624    /// The survivors that the checker checked.
625    pub checked: usize,
626    /// The checked survivors that do not type-check. They have the status
627    /// `compile_error`.
628    pub compile_errors: usize,
629    /// The survivors that the checker did not check, by reason.
630    #[serde(default, skip_serializing_if = "Vec::is_empty")]
631    pub skipped: Vec<MutationTypecheckSkip>,
632}
633
634/// The envelope of `fallow mutation`.
635#[derive(Debug, Clone, PartialEq, Serialize)]
636#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
637pub struct MutationReport {
638    /// Independent schema version.
639    pub schema_version: MutationSchemaVersion,
640    /// The Fallow version.
641    pub version: ToolVersion,
642    /// The total wall time.
643    pub elapsed_ms: ElapsedMs,
644    /// Whether the result is conclusive.
645    pub completion: MutationCompletion,
646    /// The mutated code.
647    pub scope: MutationScopeOutput,
648    /// The runner. Absent when no companion started, for example when no
649    /// test file reaches a mutant.
650    #[serde(default, skip_serializing_if = "Option::is_none")]
651    pub runner: Option<MutationRunnerOutput>,
652    /// The result cache. Absent with `--no-cache`, and when no companion
653    /// started.
654    #[serde(default, skip_serializing_if = "Option::is_none")]
655    pub cache: Option<MutationCacheOutput>,
656    /// The totals.
657    pub summary: MutationSummary,
658    /// Each mutant, ranked mutants first.
659    pub mutants: Vec<MutantResult>,
660    /// The results per enclosing function, by file and line.
661    pub functions: Vec<MutationFunctionResult>,
662    /// True when the project has no test files.
663    pub no_test_files: bool,
664    /// The tests and test files that failed with no active mutant.
665    pub baseline_failures: Vec<MutationBaselineFailure>,
666    /// The selected files that the plan did not instrument.
667    pub skipped_files: Vec<MutationSkippedFile>,
668    /// The paths of a `paths` scope that select no file.
669    pub unmatched_paths: Vec<String>,
670    /// The instrumented files that the runner never loaded.
671    pub unloaded_paths: Vec<String>,
672    /// The git worktree check.
673    pub worktree: MutationWorktreeCheck,
674    /// The errors that did not stop the run.
675    pub errors: Vec<MutationRunIssue>,
676    /// The wall time of each phase.
677    pub timings: MutationTimings,
678    /// The mutated files with their SHA-256 digest at plan time, sorted by
679    /// path. `fallow mutation inspect` compares the digest with the current
680    /// file.
681    pub files: Vec<MutationPlanFile>,
682    /// The join with a verdict document. Absent when the run read no verdict
683    /// document (`--verdicts` not set).
684    #[serde(default, skip_serializing_if = "Option::is_none")]
685    pub verdicts: Option<MutationVerdictJoin>,
686    /// The type check of the survivors. Absent when the run did not check
687    /// them (`--typecheck` not set).
688    #[serde(default, skip_serializing_if = "Option::is_none")]
689    pub typecheck: Option<MutationTypecheckOutput>,
690}
691
692/// Version singleton for a mutation verdict document.
693#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
694#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
695pub enum MutationVerdictSchemaVersion {
696    /// Initial independent contract.
697    #[serde(rename = "1")]
698    V1,
699}
700
701/// The judgment of a mutation verdict.
702#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
703#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
704#[serde(rename_all = "kebab-case")]
705pub enum MutationVerdictKind {
706    /// The mutant behaves the same as the original code, so no test can kill
707    /// it. It gets the status `equivalent` and leaves the score.
708    Equivalent,
709}
710
711/// One reviewed mutant.
712#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
713#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
714#[serde(deny_unknown_fields)]
715pub struct MutationVerdict {
716    /// The review key of the mutant. It is the `mutant_id`: the id hashes the
717    /// path, the text of the enclosing function, the relative span, the
718    /// operator and the replacement, so it stays the same until the function
719    /// changes.
720    pub review_key: String,
721    /// The judgment.
722    pub verdict: MutationVerdictKind,
723    /// A short reason, for human review.
724    #[serde(default, skip_serializing_if = "Option::is_none")]
725    pub note: Option<String>,
726}
727
728/// A versioned verdict document, authored by a person or an agent, for
729/// `fallow mutation --verdicts <file>`.
730#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
731#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
732#[serde(deny_unknown_fields)]
733pub struct MutationVerdictInput {
734    /// Independent verdict document schema version.
735    pub schema_version: MutationVerdictSchemaVersion,
736    /// The verdicts. Each `review_key` occurs one time.
737    pub verdicts: Vec<MutationVerdict>,
738}
739
740/// The result of the join of a run with a verdict document.
741#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
742#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
743pub struct MutationVerdictJoin {
744    /// The SHA-256 digest of the verdict document, lowercase hex.
745    pub verdicts_sha256: String,
746    /// The mutants that got the status `equivalent`, sorted.
747    pub applied: Vec<String>,
748    /// The verdicts that a test contradicts, sorted: the mutant was killed or
749    /// timed out, so it is not equivalent. The status stays.
750    pub contradicted: Vec<String>,
751    /// The verdicts for mutants with no tested result (`pending`,
752    /// `runtime_error` or `pruned`), sorted. The status stays.
753    pub deferred: Vec<String>,
754    /// The verdicts that match no mutant of this run, sorted. A verdict
755    /// document can hold keys for code outside the scope of the run, and an
756    /// edit of a function gives its mutants new keys.
757    pub unmatched: Vec<String>,
758}
759
760/// The status of a mutant in a plan.
761#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
762#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
763#[serde(rename_all = "snake_case")]
764pub enum MutationPlanDisposition {
765    /// A run executes the mutant.
766    Planned,
767    /// The mutant is after the mutant limit. The plan limit comes before
768    /// the coverage is known: a run executes a pending mutant in the place
769    /// of a planned mutant that no test covers.
770    Pending,
771    /// No test file reaches the file of the mutant.
772    NoCoverage,
773    /// A static rule removes the mutant.
774    Pruned,
775}
776
777/// One mutant of a plan.
778#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
779#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
780pub struct MutationPlanMutant {
781    /// The mutant and its position.
782    #[serde(flatten)]
783    pub location: MutantLocation,
784    /// The status in the plan.
785    pub disposition: MutationPlanDisposition,
786    /// The static rule of a `pruned` mutant, kebab-case.
787    #[serde(default, skip_serializing_if = "Option::is_none")]
788    pub prune_reason: Option<String>,
789    /// How tests reach the code of the mutant in the module graph. Only a
790    /// `no_coverage` mutant has this field.
791    #[serde(default, skip_serializing_if = "Option::is_none")]
792    pub test_reach: Option<MutantTestReach>,
793}
794
795/// One instrumented file of a plan.
796#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
797#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
798pub struct MutationPlanFile {
799    /// The root-relative path, with forward slashes.
800    pub path: String,
801    /// The SHA-256 digest of the file, lowercase hex.
802    pub source_sha256: String,
803    /// The test files that reach the file in the module graph, sorted.
804    pub static_test_files: Vec<String>,
805}
806
807/// The totals of a plan.
808#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)]
809#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
810pub struct MutationPlanCountsOutput {
811    /// Each mutant.
812    pub total: usize,
813    /// Planned mutants.
814    pub planned: usize,
815    /// Mutants after the mutant limit.
816    pub pending: usize,
817    /// Mutants that no test file reaches.
818    pub no_coverage: usize,
819    /// Mutants in unused files.
820    pub pruned_dead_code: usize,
821    /// Mutants in generated files.
822    pub pruned_generated: usize,
823    /// Mutants near a file system, process, network or database sink.
824    pub pruned_side_effect_sink: usize,
825    /// Mutants in framework config exports.
826    pub pruned_framework_config: usize,
827}
828
829/// The envelope of `fallow mutation --plan`. It runs no user code.
830#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
831#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
832pub struct MutationPlanOutput {
833    /// Independent schema version.
834    pub schema_version: MutationPlanSchemaVersion,
835    /// The Fallow version.
836    pub version: ToolVersion,
837    /// The total wall time.
838    pub elapsed_ms: ElapsedMs,
839    /// The mutated code.
840    pub scope: MutationScopeOutput,
841    /// The totals.
842    pub counts: MutationPlanCountsOutput,
843    /// Each mutant, ranked mutants first.
844    pub mutants: Vec<MutationPlanMutant>,
845    /// The instrumented files.
846    pub files: Vec<MutationPlanFile>,
847    /// The union of the static test files, sorted.
848    pub test_files: Vec<String>,
849    /// True when the project has no test files.
850    pub no_test_files: bool,
851    /// True when the plan applied the `framework-config` prune rule.
852    pub framework_config_checked: bool,
853    /// The selected files that the plan did not instrument.
854    pub skipped_files: Vec<MutationSkippedFile>,
855    /// The paths of a `paths` scope that select no file.
856    pub unmatched_paths: Vec<String>,
857}
858
859/// Version singleton for a mutation inspect packet.
860#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
861#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
862pub enum MutationInspectSchemaVersion {
863    /// Initial independent contract.
864    #[serde(rename = "1")]
865    V1,
866}
867
868/// Whether the saved report still describes the current file.
869#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
870#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
871#[serde(rename_all = "snake_case")]
872pub enum MutationInspectStatus {
873    /// The file has the digest of the report, and the mutant is reproduced.
874    Fresh,
875    /// The file changed, is missing, or no longer gives the mutant. The
876    /// packet then has no code.
877    Stale,
878}
879
880/// Why an inspect packet is stale.
881#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
882#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
883#[serde(rename_all = "kebab-case")]
884pub enum MutationStaleReason {
885    /// The file does not exist or cannot be read.
886    FileMissing,
887    /// The report has no digest for the file.
888    NoDigest,
889    /// The SHA-256 digest of the file differs from the report.
890    SourceChanged,
891    /// The file has the digest of the report, but the instrumentation does
892    /// not give the mutant.
893    MutantNotReproduced,
894}
895
896/// The code of one mutant, from the current file.
897#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
898#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
899pub struct MutationInspectCode {
900    /// The original text of the span.
901    pub original: String,
902    /// The text that replaces the span.
903    pub replacement: String,
904    /// The source lines that contain the span, before the mutation.
905    pub original_lines: String,
906    /// The same lines with the replacement applied.
907    pub mutated_lines: String,
908}
909
910/// The envelope of `fallow mutation inspect`. It runs no user code.
911#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
912#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
913pub struct MutationInspectOutput {
914    /// Independent schema version.
915    pub schema_version: MutationInspectSchemaVersion,
916    /// The Fallow version.
917    pub version: ToolVersion,
918    /// Whether the report still describes the current file.
919    pub status: MutationInspectStatus,
920    /// Why the packet is stale. Absent for a fresh packet.
921    #[serde(default, skip_serializing_if = "Option::is_none")]
922    pub stale_reason: Option<MutationStaleReason>,
923    /// The mutant and its position, from the report.
924    #[serde(flatten)]
925    pub location: MutantLocation,
926    /// The status of the mutant in the report. Absent for a plan.
927    #[serde(default, skip_serializing_if = "Option::is_none")]
928    pub report_status: Option<MutantStatus>,
929    /// The SHA-256 digest of the file in the report.
930    pub report_sha256: Option<String>,
931    /// The SHA-256 digest of the current file. `null` when the file is
932    /// missing.
933    pub current_sha256: Option<String>,
934    /// The original and mutated code. `null` for a stale packet.
935    pub code: Option<MutationInspectCode>,
936    /// The tests that reached the mutant in the dry run, sorted.
937    pub covering_tests: Vec<String>,
938    /// The test files that reach the file in the module graph, sorted.
939    pub static_test_files: Vec<String>,
940    /// The test that killed the mutant in the run of the report.
941    pub killed_by: Option<String>,
942}
943
944/// Serialize a mutation report with its root discriminator.
945///
946/// # Errors
947///
948/// Returns a serde error when the envelope cannot be converted to JSON.
949pub fn serialize_mutation_json_output(
950    output: MutationReport,
951) -> Result<serde_json::Value, serde_json::Error> {
952    serialize_named_json_output(output, "mutation")
953}
954
955/// Serialize a mutation plan with its root discriminator.
956///
957/// # Errors
958///
959/// Returns a serde error when the envelope cannot be converted to JSON.
960pub fn serialize_mutation_plan_json_output(
961    output: MutationPlanOutput,
962) -> Result<serde_json::Value, serde_json::Error> {
963    serialize_named_json_output(output, "mutation-plan")
964}
965
966/// Serialize a mutation inspect packet with its root discriminator.
967///
968/// # Errors
969///
970/// Returns a serde error when the envelope cannot be converted to JSON.
971pub fn serialize_mutation_inspect_json_output(
972    output: MutationInspectOutput,
973) -> Result<serde_json::Value, serde_json::Error> {
974    serialize_named_json_output(output, "mutation-inspect")
975}
976
977#[cfg(test)]
978mod tests {
979    use super::*;
980
981    fn counts(statuses: &[MutantStatus]) -> MutationStatusCounts {
982        let mut counts = MutationStatusCounts::default();
983        for status in statuses {
984            counts.add(*status);
985        }
986        counts
987    }
988
989    #[test]
990    fn the_score_counts_executed_mutants_and_coverage_counts_reached_mutants() {
991        let counts = counts(&[
992            MutantStatus::Killed,
993            MutantStatus::Killed,
994            MutantStatus::Timeout,
995            MutantStatus::Survived,
996            MutantStatus::NoCoverage,
997            MutantStatus::NoCoverage,
998            MutantStatus::RuntimeError,
999            MutantStatus::Flaky,
1000            MutantStatus::Pruned,
1001            MutantStatus::Pending,
1002        ]);
1003        assert_eq!(counts.total, 10);
1004        assert_eq!(counts.killed, 2);
1005        assert_eq!(counts.timeout, 1);
1006        assert_eq!(counts.survived, 1);
1007        assert_eq!(counts.no_coverage, 2);
1008        assert_eq!(counts.runtime_error, 1);
1009        assert_eq!(counts.flaky, 1);
1010        assert_eq!(counts.pruned, 1);
1011        assert_eq!(counts.pending, 1);
1012        assert_eq!(counts.score(), Some(0.75));
1013    }
1014
1015    fn result(status: MutantStatus, covering_tests: &[&str]) -> MutantResult {
1016        MutantResult {
1017            location: MutantLocation {
1018                mutant_id: "m".to_owned(),
1019                file: "src/a.ts".to_owned(),
1020                line: 1,
1021                column: 1,
1022                span: (0, 1),
1023                operator: "arithmetic-operator".to_owned(),
1024                function: None,
1025                function_line: None,
1026                rank: None,
1027            },
1028            status,
1029            prune_reason: None,
1030            killed_by: None,
1031            covering_tests: covering_tests
1032                .iter()
1033                .map(|test| (*test).to_owned())
1034                .collect(),
1035            static_mutant: false,
1036            duration_ms: None,
1037            test_reach: None,
1038            retried: false,
1039            cached: false,
1040            type_error_codes: Vec::new(),
1041        }
1042    }
1043
1044    #[test]
1045    fn coverage_counts_executed_mutants_with_a_test_and_pending_mutants_as_covered() {
1046        let summary = MutationSummary::from_results(&[
1047            result(MutantStatus::Killed, &["t1"]),
1048            result(MutantStatus::Survived, &["t1"]),
1049            result(MutantStatus::RuntimeError, &["t2"]),
1050            result(MutantStatus::Pending, &[]),
1051            result(MutantStatus::NoCoverage, &[]),
1052            result(MutantStatus::Equivalent, &["t1"]),
1053            result(MutantStatus::Pruned, &[]),
1054        ]);
1055        assert_eq!(summary.score, Some(0.5));
1056        assert_eq!(summary.coverage, Some(0.8));
1057        assert_eq!(
1058            MutationSummary::from_results(&[result(MutantStatus::Pruned, &[])]).coverage,
1059            None
1060        );
1061    }
1062
1063    #[test]
1064    fn a_capped_run_reports_the_coverage_of_the_full_run() {
1065        let full = MutationSummary::from_results(&[
1066            result(MutantStatus::Killed, &["t1"]),
1067            result(MutantStatus::Survived, &["t1"]),
1068            result(MutantStatus::NoCoverage, &[]),
1069            result(MutantStatus::NoCoverage, &[]),
1070        ]);
1071        let capped = MutationSummary::from_results(&[
1072            result(MutantStatus::Killed, &["t1"]),
1073            result(MutantStatus::Pending, &[]),
1074            result(MutantStatus::NoCoverage, &[]),
1075            result(MutantStatus::NoCoverage, &[]),
1076        ]);
1077        assert_eq!(full.coverage, Some(0.5));
1078        assert_eq!(capped.coverage, Some(0.5));
1079        assert_eq!(full.score, Some(0.5));
1080        assert_eq!(capped.score, Some(1.0));
1081    }
1082
1083    #[test]
1084    fn equivalent_mutants_leave_the_score() {
1085        let mut counts = counts(&[
1086            MutantStatus::Killed,
1087            MutantStatus::Survived,
1088            MutantStatus::Survived,
1089        ]);
1090        assert_eq!(counts.score(), Some(1.0 / 3.0));
1091        counts.remove(MutantStatus::Survived);
1092        counts.add(MutantStatus::Equivalent);
1093        assert_eq!(counts.total, 3);
1094        assert_eq!(counts.survived, 1);
1095        assert_eq!(counts.equivalent, 1);
1096        assert_eq!(counts.score(), Some(0.5));
1097        counts.remove(MutantStatus::Timeout);
1098        assert_eq!(counts.total, 3, "removing an absent status changes nothing");
1099    }
1100
1101    #[test]
1102    fn verdict_documents_reject_unknown_fields_and_verdicts() {
1103        let valid: MutationVerdictInput = serde_json::from_value(serde_json::json!({
1104            "schema_version": "1",
1105            "verdicts": [{ "review_key": "abc", "verdict": "equivalent", "note": "same result" }]
1106        }))
1107        .expect("valid document");
1108        assert_eq!(valid.verdicts[0].verdict, MutationVerdictKind::Equivalent);
1109        for invalid in [
1110            serde_json::json!({ "schema_version": "1", "verdicts": [{ "review_key": "a", "verdict": "killed" }] }),
1111            serde_json::json!({ "schema_version": "1", "verdicts": [{ "review_key": "a", "verdict": "equivalent", "extra": 1 }] }),
1112            serde_json::json!({ "schema_version": "2", "verdicts": [] }),
1113            serde_json::json!({ "verdicts": [] }),
1114        ] {
1115            assert!(
1116                serde_json::from_value::<MutationVerdictInput>(invalid.clone()).is_err(),
1117                "{invalid}"
1118            );
1119        }
1120    }
1121
1122    #[test]
1123    fn the_score_is_null_when_no_mutant_counts() {
1124        let excluded = counts(&[
1125            MutantStatus::RuntimeError,
1126            MutantStatus::Pruned,
1127            MutantStatus::Flaky,
1128        ]);
1129        assert_eq!(excluded.score(), None);
1130        assert_eq!(counts(&[MutantStatus::Survived]).score(), Some(0.0));
1131        assert_eq!(counts(&[MutantStatus::Timeout]).score(), Some(1.0));
1132    }
1133
1134    #[test]
1135    fn completion_is_partial_only_with_a_reason() {
1136        let complete = MutationCompletion::from_reasons(Vec::new());
1137        assert_eq!(complete.status, MutationCompletionStatus::Complete);
1138        let partial = MutationCompletion::from_reasons(vec![
1139            MutationIncompleteReason::TimeBudget,
1140            MutationIncompleteReason::RuntimeError,
1141            MutationIncompleteReason::TimeBudget,
1142        ]);
1143        assert_eq!(partial.status, MutationCompletionStatus::Partial);
1144        assert_eq!(
1145            partial.reasons,
1146            vec![
1147                MutationIncompleteReason::RuntimeError,
1148                MutationIncompleteReason::TimeBudget
1149            ]
1150        );
1151    }
1152
1153    #[test]
1154    fn a_mutant_result_serializes_flat_with_snake_case_status_and_no_source_text() {
1155        let result = MutantResult {
1156            location: MutantLocation {
1157                mutant_id: "m1".to_owned(),
1158                file: "src/math.ts".to_owned(),
1159                line: 2,
1160                column: 10,
1161                span: (40, 45),
1162                operator: "arithmetic".to_owned(),
1163                function: Some("add".to_owned()),
1164                function_line: Some(1),
1165                rank: Some(1),
1166            },
1167            status: MutantStatus::NoCoverage,
1168            prune_reason: None,
1169            killed_by: None,
1170            covering_tests: Vec::new(),
1171            static_mutant: false,
1172            duration_ms: None,
1173            test_reach: None,
1174            retried: false,
1175            cached: false,
1176            type_error_codes: Vec::new(),
1177        };
1178        let value = serde_json::to_value(&result).expect("serializes");
1179        assert_eq!(
1180            value,
1181            serde_json::json!({
1182                "mutant_id": "m1",
1183                "file": "src/math.ts",
1184                "line": 2,
1185                "column": 10,
1186                "span": [40, 45],
1187                "operator": "arithmetic",
1188                "function": "add",
1189                "function_line": 1,
1190                "rank": 1,
1191                "status": "no_coverage"
1192            })
1193        );
1194    }
1195}