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}