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