Skip to main content

fallow_engine/mutation/
mod.rs

1//! The static half of mutation testing: select, map, prune and rank mutants.
2//!
3//! The plan reads only facts that the dead-code analysis already has: the
4//! module graph with its test reachability (including the `vi.mock`
5//! replacement masks), the dead-code results, the function complexity of each
6//! module, and the captured security sink sites. It reads the source text of
7//! each selected file one time to instrument it, and it holds the
8//! instrumented code in memory. It never writes a file and never runs user
9//! code.
10//!
11//! Each mutant gets one disposition:
12//!
13//! - `planned`: a test file reaches the file, and no static rule prunes the
14//!   mutant. The runner executes these mutants in rank order.
15//! - `pending`: a planned mutant after the `max_mutants` limit.
16//! - `no-coverage`: no test file reaches the file in the graph. A test that
17//!   replaces the module with `vi.mock` does not reach it.
18//! - `pruned`: a static rule removes the mutant (see [`PruneReason`](crate::mutation::PruneReason)).
19
20mod layout;
21mod plan;
22mod reach;
23#[cfg(test)]
24mod tests;
25
26use std::collections::{BTreeMap, BTreeSet};
27use std::path::{Path, PathBuf};
28
29use serde::Serialize;
30
31pub use fallow_mutation::{
32    FunctionRef, Mutant, MutantId, MutationError, Operator, RESERVED_PREFIX, SourceKind,
33};
34pub use fallow_output::DiffIndex;
35pub use layout::{instrument_source, is_component_file};
36
37use crate::EngineError;
38use crate::module_graph::{ExportTestReach, RetainedModuleGraph};
39use crate::session::AnalysisSession;
40use crate::source::ModuleInfo;
41use fallow_types::results::AnalysisResults;
42
43/// The runner config modules at the project root. Each test run loads them,
44/// so they and the files that they reach through static imports are in
45/// [`MutationPlan::support_files`].
46pub const RUNNER_CONFIG_MODULES: [&str; 18] = [
47    "vitest.config.ts",
48    "vitest.config.mts",
49    "vitest.config.cts",
50    "vitest.config.js",
51    "vitest.config.mjs",
52    "vitest.config.cjs",
53    "vite.config.ts",
54    "vite.config.mts",
55    "vite.config.cts",
56    "vite.config.js",
57    "vite.config.mjs",
58    "vite.config.cjs",
59    "vitest.workspace.ts",
60    "vitest.workspace.mts",
61    "vitest.workspace.cts",
62    "vitest.workspace.js",
63    "vitest.workspace.mjs",
64    "vitest.workspace.cjs",
65];
66
67/// The files and lines that a plan mutates.
68#[derive(Debug, Clone)]
69pub enum MutationScope {
70    /// The added lines of a diff against the base ref. Build the index with
71    /// the existing diff machinery (`changed_files::try_get_changed_diff`,
72    /// `DiffIndex::from_unified_diff` and `diff_source::place_diff`). Only
73    /// mutants that overlap an added line are kept.
74    ChangedSinceBase(DiffIndex),
75    /// Each source file of the project.
76    All,
77    /// The files at these paths, or below these directories. A relative path
78    /// is relative to the project root.
79    Paths(Vec<PathBuf>),
80}
81
82/// What to plan.
83#[derive(Debug, Clone)]
84pub struct MutationPlanRequest {
85    /// The files and lines to mutate.
86    pub scope: MutationScope,
87    /// The operators to use. `None` uses each operator.
88    pub operators: Option<Vec<Operator>>,
89    /// Keep the mutants in functions that call a file system, child process,
90    /// network or database sink. The default prunes them, because a mutant
91    /// can turn a guarded side effect into an unguarded one.
92    pub allow_side_effects: bool,
93    /// The maximum number of planned mutants. The mutants after this limit,
94    /// in rank order, become `pending`.
95    pub max_mutants: Option<usize>,
96    /// Leave each Vue, Svelte and Astro component file out of the plan,
97    /// with the skip reason [`SkipReason::RunnerCannotLoad`]. Set it when
98    /// the test runner has no loader for a component file.
99    pub skip_component_files: bool,
100}
101
102impl MutationPlanRequest {
103    /// A request with the default options for `scope`.
104    #[must_use]
105    pub const fn new(scope: MutationScope) -> Self {
106        Self {
107            scope,
108            operators: None,
109            allow_side_effects: false,
110            max_mutants: None,
111            skip_component_files: false,
112        }
113    }
114}
115
116/// Framework config exports, by root-relative file path.
117///
118/// A framework plugin marks some exports as used by the framework, for
119/// example `export const revalidate = 60` in a Next.js page. A mutant in such
120/// an export changes framework config, not tested logic, so the plan prunes
121/// it. Mutants inside a function in the export are not pruned.
122#[derive(Debug, Clone, Default, PartialEq, Eq)]
123pub struct FrameworkConfigExports {
124    files: BTreeMap<String, BTreeSet<String>>,
125    config_files: BTreeSet<String>,
126}
127
128impl FrameworkConfigExports {
129    /// Records that the framework uses `export` of the root-relative `file`.
130    pub fn insert(&mut self, file: impl Into<String>, export: impl Into<String>) {
131        self.files
132            .entry(file.into())
133            .or_default()
134            .insert(export.into());
135    }
136
137    /// Records that the root-relative `file` is a tool config file. The tool
138    /// reads its default export, and a top-level `module.exports = ...`
139    /// statement counts as that default export.
140    pub fn insert_config_file(&mut self, file: impl Into<String>) {
141        let file = file.into();
142        self.insert(file.clone(), DEFAULT_EXPORT);
143        self.config_files.insert(file);
144    }
145
146    /// True when [`Self::insert_config_file`] recorded the root-relative
147    /// `file`.
148    #[must_use]
149    pub fn is_config_file(&self, file: &str) -> bool {
150        self.config_files.contains(file)
151    }
152
153    /// The framework config export names of one root-relative file.
154    #[must_use]
155    pub fn exports_for(&self, file: &str) -> Option<&BTreeSet<String>> {
156        self.files.get(file)
157    }
158}
159
160/// The analysis facts that a plan reads.
161#[derive(Clone, Copy)]
162pub struct MutationPlanInputs<'a> {
163    /// The project root.
164    pub root: &'a Path,
165    /// The module graph of the analysis.
166    pub graph: &'a RetainedModuleGraph,
167    /// The parsed modules, with complexity.
168    pub modules: &'a [ModuleInfo],
169    /// The dead-code results of the analysis.
170    pub results: &'a AnalysisResults,
171    /// The framework config exports. `None` when the caller has no plugin
172    /// facts. The plan then prunes no `framework-config` mutants and sets
173    /// [`MutationPlan::framework_config_checked`] to false.
174    pub framework_config_exports: Option<&'a FrameworkConfigExports>,
175    /// The setup files of the test runner config, absolute or relative to
176    /// the root, with or without an extension. They fill
177    /// [`MutationPlan::support_files`], with the runner config modules at the
178    /// root.
179    pub setup_files: &'a [PathBuf],
180}
181
182/// Why a static rule removes a mutant.
183#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
184#[serde(rename_all = "kebab-case")]
185pub enum PruneReason {
186    /// The file is unused: no entry point reaches it.
187    DeadCode,
188    /// The file is generated code.
189    Generated,
190    /// The enclosing function calls a file system, child process, network or
191    /// database sink of the security catalogue. A mutant outside each
192    /// function gets this reason when the module calls such a sink outside
193    /// each function, because that sink runs on each import of the module.
194    /// A sink that only shapes a response or a navigation (a redirect, a
195    /// header, a cookie, a CORS policy) does not count.
196    SideEffectSink,
197    /// The mutant is in an export that a framework reads as config.
198    FrameworkConfig,
199}
200
201/// The status of a mutant in a plan.
202#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
203#[serde(tag = "status", content = "reason", rename_all = "kebab-case")]
204pub enum MutantDisposition {
205    /// The runner executes the mutant.
206    Planned,
207    /// A planned mutant after the `max_mutants` limit.
208    Pending,
209    /// No test file reaches the file of the mutant.
210    NoCoverage,
211    /// A static rule removes the mutant.
212    Pruned(PruneReason),
213}
214
215/// The facts of the function that contains a mutant.
216#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
217pub struct FunctionFacts {
218    /// The function name. The complexity name wins over the syntax name, so
219    /// that the name is the same as in `health` output.
220    pub name: Option<String>,
221    /// The byte span of the function.
222    pub span: (u32, u32),
223    /// The 1-based first line of the function.
224    pub line: u32,
225    /// The 1-based last line of the function.
226    pub end_line: u32,
227    /// The cyclomatic complexity, when the analysis measured the function.
228    pub cyclomatic: Option<u16>,
229    /// True when an added line of the diff is in the function.
230    pub in_diff: bool,
231}
232
233/// One mutant of the plan.
234#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
235pub struct PlannedMutant {
236    /// The mutant.
237    pub mutant: Mutant,
238    /// The root-relative path of the file, with forward slashes.
239    pub file: String,
240    /// The 1-based execution rank of a planned or pending mutant.
241    pub rank: Option<u32>,
242    /// The status of the mutant.
243    pub disposition: MutantDisposition,
244    /// The function that contains the mutant.
245    pub function: Option<FunctionFacts>,
246    /// How tests reach the code of the mutant in the module graph. `None`
247    /// for a pruned mutant.
248    pub test_reach: Option<MutantTestReach>,
249}
250
251/// How tests reach the code of one mutant, from the module graph.
252///
253/// A mutant in the top-level statement that declares a value export gets the
254/// class of that export. A mutant outside each such statement gets the class
255/// of its file: `none-file` when no test loads the file, `mocked-only` when no
256/// test loads the file and a mock explains an export of the file, and `none`
257/// when a test loads the file.
258#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
259pub struct MutantTestReach {
260    /// The reach class.
261    pub reach: ExportTestReach,
262    /// The export whose declaration holds the mutant. `None` for a mutant
263    /// outside each value export declaration.
264    pub export_name: Option<String>,
265    /// For `mocked-only`: the test files whose references a mock removed,
266    /// sorted.
267    pub tests: Vec<String>,
268    /// For `mocked-only`: the mocked modules on the reference paths, sorted.
269    pub mocked_targets: Vec<String>,
270}
271
272/// One file that the runner loads in its instrumented form.
273#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
274pub struct PlannedFile {
275    /// The root-relative path, with forward slashes.
276    pub path: String,
277    /// The SHA-256 digest of the file bytes, as lowercase hex.
278    pub source_sha256: String,
279    /// The instrumented source text.
280    pub instrumented_code: String,
281    /// The test files that reach the file through imports, sorted. A test
282    /// that replaces the file, or a module on each import path to it, with
283    /// `vi.mock` is not in the list.
284    pub static_test_files: Vec<String>,
285}
286
287/// Why the plan did not instrument a selected file.
288#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
289#[serde(rename_all = "kebab-case")]
290pub enum SkipReason {
291    /// The file could not be read.
292    Unreadable,
293    /// The file changed after the analysis parsed it.
294    Stale,
295    /// The file does not parse.
296    Parse,
297    /// The file declares a name that the instrumentation reserves.
298    ReservedName,
299    /// The file is larger than 4 GiB.
300    TooLarge,
301    /// The module graph has the file, but the analysis kept no module facts
302    /// for it, for example because extraction could not read it.
303    NotAnalyzed,
304    /// The test runner cannot load the file: a component file under a runner
305    /// with no component loader.
306    RunnerCannotLoad,
307}
308
309/// A selected file that the plan did not instrument.
310#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
311pub struct SkippedFile {
312    /// The root-relative path, with forward slashes.
313    pub path: String,
314    /// Why the plan skipped the file.
315    pub reason: SkipReason,
316}
317
318/// The number of pruned mutants for each reason.
319#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)]
320pub struct PruneCounts {
321    /// Mutants in unused files.
322    pub dead_code: usize,
323    /// Mutants in generated files.
324    pub generated: usize,
325    /// Mutants in functions that call a side-effect sink, and top-level
326    /// mutants of a module with a top-level side-effect sink.
327    pub side_effect_sink: usize,
328    /// Mutants in framework config exports.
329    pub framework_config: usize,
330}
331
332/// The number of mutants for each disposition.
333#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)]
334pub struct MutationPlanCounts {
335    /// Each mutant of the plan.
336    pub total: usize,
337    /// Planned mutants.
338    pub planned: usize,
339    /// Planned mutants after the `max_mutants` limit.
340    pub pending: usize,
341    /// Mutants that no test file reaches.
342    pub no_coverage: usize,
343    /// Pruned mutants, by reason.
344    pub pruned: PruneCounts,
345}
346
347/// The static mutation plan.
348#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
349pub struct MutationPlan {
350    /// The files with a planned or pending mutant, sorted by path.
351    pub files: Vec<PlannedFile>,
352    /// Each mutant. Ranked mutants come first in rank order, then the other
353    /// mutants by file, span and id.
354    pub mutants: Vec<PlannedMutant>,
355    /// The union of the static test files of `files`, sorted.
356    pub test_files: Vec<String>,
357    /// The files that the test files reach through static imports, sorted.
358    /// [`MutationPlan::test_file_reach`] indexes into this list.
359    #[serde(skip)]
360    pub reach_files: Vec<String>,
361    /// The files that each test file of `test_files` reaches through static
362    /// imports, as sorted indices into `reach_files`. The list leaves out the
363    /// test file itself and ignores `vi.mock`, so it is a superset of the
364    /// files that the test loads. The result cache of a run keys on the
365    /// content of these files.
366    #[serde(skip)]
367    pub test_file_reach: BTreeMap<String, Vec<u32>>,
368    /// The setup files of the test runner, the runner config modules at the
369    /// root ([`RUNNER_CONFIG_MODULES`]), and the files that they reach through
370    /// static imports, sorted. Each test loads them, so the result cache keys
371    /// on their content for each mutant.
372    #[serde(skip)]
373    pub support_files: Vec<String>,
374    /// The number of mutants for each disposition.
375    pub counts: MutationPlanCounts,
376    /// True when the project has no test files. Each mutant is then
377    /// `no-coverage`, and a report shows this one fact instead of one line
378    /// for each mutant.
379    pub no_test_files: bool,
380    /// True when the plan applied the `framework-config` prune rule. False
381    /// when the caller gave no framework config exports, so that a mutant in
382    /// such an export can be `planned`.
383    pub framework_config_checked: bool,
384    /// The selected files that the plan did not instrument, sorted by path.
385    pub skipped_files: Vec<SkippedFile>,
386    /// The paths of a `Paths` scope that select no source file, as given and
387    /// with forward slashes, sorted. A path can select no file because it
388    /// does not exist, or because it names only test code, declaration files
389    /// or files outside the module graph.
390    pub unmatched_paths: Vec<String>,
391}
392
393impl MutationPlan {
394    /// The files that `test_file` reaches through static imports, sorted.
395    /// Empty for a file that is not a test file of the plan.
396    pub fn test_file_reach_paths<'a>(&'a self, test_file: &str) -> impl Iterator<Item = &'a str> {
397        self.test_file_reach
398            .get(test_file)
399            .into_iter()
400            .flatten()
401            .filter_map(|&index| self.reach_files.get(index as usize))
402            .map(String::as_str)
403    }
404}
405
406/// An error from [`plan_mutations_for_session`].
407#[derive(Debug, Clone, PartialEq, Eq)]
408pub enum MutationPlanError {
409    /// The analysis failed.
410    Analysis(EngineError),
411    /// The analysis did not keep an artifact that the plan reads.
412    MissingArtifact(&'static str),
413    /// The plugin stage failed, so the plan cannot read the framework config
414    /// exports.
415    Plugins(String),
416}
417
418impl std::fmt::Display for MutationPlanError {
419    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
420        match self {
421            Self::Analysis(error) => write!(f, "the analysis failed: {error}"),
422            Self::MissingArtifact(name) => {
423                write!(
424                    f,
425                    "the analysis did not keep the {name} for the mutation plan"
426                )
427            }
428            Self::Plugins(message) => write!(f, "the plugin stage failed: {message}"),
429        }
430    }
431}
432
433impl std::error::Error for MutationPlanError {}
434
435impl From<EngineError> for MutationPlanError {
436    fn from(error: EngineError) -> Self {
437        Self::Analysis(error)
438    }
439}
440
441/// Builds the mutation plan from the facts of one analysis.
442#[must_use]
443pub fn plan_mutations(
444    inputs: &MutationPlanInputs<'_>,
445    request: &MutationPlanRequest,
446) -> MutationPlan {
447    plan::build(inputs, request)
448}
449
450/// Runs the dead-code analysis of `session` with complexity and the graph,
451/// then builds the mutation plan.
452///
453/// The framework config exports come from the plugin used-export rules of
454/// the session, the same rules that the unused-export analysis reads. The
455/// plugin stage runs one more time for them.
456///
457/// # Errors
458///
459/// Returns an error when the analysis or the plugin stage fails.
460pub fn plan_mutations_for_session(
461    session: &AnalysisSession,
462    request: &MutationPlanRequest,
463) -> Result<MutationPlan, MutationPlanError> {
464    plan_mutations_for_session_with(session, request, |_| false)
465}
466
467/// Like [`plan_mutations_for_session`], but `skip_components` reads the
468/// first plan and decides if the component files leave the plan. The test
469/// runner can depend on the test files of the plan, so the caller decides
470/// after the first plan. When the first plan has a component mutant and
471/// `skip_components` returns true, the plan is built again from the same
472/// analysis with [`MutationPlanRequest::skip_component_files`] set.
473///
474/// # Errors
475///
476/// Returns an error when the analysis or the plugin stage fails.
477pub fn plan_mutations_for_session_with(
478    session: &AnalysisSession,
479    request: &MutationPlanRequest,
480    skip_components: impl FnOnce(&MutationPlan) -> bool,
481) -> Result<MutationPlan, MutationPlanError> {
482    let artifacts = session.analyze_dead_code_with_artifacts(true, true)?;
483    let graph = artifacts
484        .graph
485        .as_ref()
486        .ok_or(MutationPlanError::MissingArtifact("module graph"))?;
487    let modules = artifacts
488        .modules
489        .as_deref()
490        .ok_or(MutationPlanError::MissingArtifact("parsed modules"))?;
491    let inventory = crate::list_inventory::collect_listing_inventory(session, false)
492        .map_err(|error| MutationPlanError::Plugins(error.message().to_owned()))?;
493    let framework_config_exports = framework_config_exports(session, graph, &inventory);
494    let setup_files = inventory.plugins.backend().setup_files();
495    let inputs = MutationPlanInputs {
496        root: session.root(),
497        graph,
498        modules,
499        results: &artifacts.results,
500        framework_config_exports: Some(&framework_config_exports),
501        setup_files: &setup_files,
502    };
503    let plan = plan_mutations(&inputs, request);
504    let has_component = plan
505        .mutants
506        .iter()
507        .any(|mutant| is_component_file(&mutant.file));
508    if request.skip_component_files || !has_component || !skip_components(&plan) {
509        return Ok(plan);
510    }
511    let request = MutationPlanRequest {
512        skip_component_files: true,
513        ..request.clone()
514    };
515    Ok(plan_mutations(&inputs, &request))
516}
517
518/// The plugin used-export names of each module of the graph. A tool reads
519/// the default export of its config file, so the default export of each file
520/// that a tool config glob of an active plugin matches is framework config
521/// too.
522fn framework_config_exports(
523    session: &AnalysisSession,
524    graph: &RetainedModuleGraph,
525    inventory: &crate::list_inventory::ListingInventory,
526) -> FrameworkConfigExports {
527    let backend = inventory.plugins.backend();
528    let names = backend.used_export_names();
529    let active = backend.active_plugins();
530    let external = session
531        .config()
532        .external_plugins
533        .iter()
534        .filter(|plugin| active.contains(&plugin.name))
535        .flat_map(|plugin| {
536            plugin
537                .config_patterns
538                .iter()
539                .chain(&plugin.always_used)
540                .cloned()
541        });
542    // Some plugins list their config file as always used, not as a config
543    // glob. The tool config name filter keeps the other always-used files
544    // out.
545    let patterns: Vec<String> = backend
546        .config_patterns()
547        .into_iter()
548        .chain(backend.always_used_patterns())
549        .chain(external)
550        .collect();
551    let config_files = config_file_globs(&patterns);
552    let mut exports = FrameworkConfigExports::default();
553    for node in &graph.as_graph().modules {
554        let rel = crate::module_graph::relative_key_path(&node.path, session.root());
555        for name in names.names_for(&rel) {
556            exports.insert(rel.clone(), name);
557        }
558        if config_files.is_match(&rel) {
559            exports.insert_config_file(rel);
560        }
561    }
562    exports
563}
564
565/// The export name of `export default` and of `module.exports = ...`.
566const DEFAULT_EXPORT: &str = "default";
567
568/// The word in a file name that marks a tool config file, as in
569/// `vite.config.ts`, `stryker.conf.js` or `tsconfig.json`.
570const CONFIG_NAME_MARKERS: [&str; 2] = ["config", ".conf."];
571
572/// True when the file name part of a plugin config glob names a tool config
573/// file: it holds `config` or `.conf.`, or an `rc` suffix as in
574/// `.eslintrc.js`. Some plugins also list application files as config, for
575/// example `codegen*.ts`, `src/module.ts` or `app/routes.ts`. Tests can load
576/// those files, so their default export stays in the plan.
577fn is_tool_config_glob(pattern: &str) -> bool {
578    let name = pattern
579        .rsplit('/')
580        .next()
581        .unwrap_or(pattern)
582        .to_ascii_lowercase();
583    if CONFIG_NAME_MARKERS
584        .iter()
585        .any(|marker| name.contains(marker))
586    {
587        return true;
588    }
589    let stem = name.split(['.', '{']).find(|part| !part.is_empty());
590    stem.is_some_and(|stem| stem.ends_with("rc"))
591}
592
593/// The tool config globs, each also tried under `**/`, because a glob is
594/// relative to the directory that the plugin searches, for example a
595/// workspace package.
596fn config_file_globs(patterns: &[String]) -> globset::GlobSet {
597    let mut builder = globset::GlobSetBuilder::new();
598    for pattern in patterns
599        .iter()
600        .filter(|pattern| is_tool_config_glob(pattern))
601    {
602        let anywhere = if pattern.starts_with("**/") {
603            pattern.clone()
604        } else {
605            format!("**/{pattern}")
606        };
607        for candidate in [pattern.as_str(), anywhere.as_str()] {
608            if let Ok(glob) = globset::Glob::new(candidate) {
609                builder.add(glob);
610            }
611        }
612    }
613    builder
614        .build()
615        .unwrap_or_else(|_| globset::GlobSet::empty())
616}