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}