From 1162900f9c595667e64f9fc12a1fc43dbaab2ce0 Mon Sep 17 00:00:00 2001 From: Ben Mordue Date: Fri, 2 Oct 2026 14:56:28 +0100 Subject: [PATCH 1/5] feat: add unary minus, numeric type/boundary, condition constant and operand mutators --- R/mutate-nodes.R | 91 ++++++++++++++++++++ R/mutator-condition-constant.R | 30 +++++++ R/mutator-logical.R | 24 ++++++ R/mutator-numeric-types.R | 76 +++++++++++++++++ R/mutator-unary-minus.R | 19 +++++ tests/testthat/test-mutator-batch1.R | 119 +++++++++++++++++++++++++++ 6 files changed, 359 insertions(+) create mode 100644 R/mutate-nodes.R create mode 100644 R/mutator-condition-constant.R create mode 100644 R/mutator-logical.R create mode 100644 R/mutator-numeric-types.R create mode 100644 R/mutator-unary-minus.R create mode 100644 tests/testthat/test-mutator-batch1.R diff --git a/R/mutate-nodes.R b/R/mutate-nodes.R new file mode 100644 index 0000000..c8123a4 --- /dev/null +++ b/R/mutate-nodes.R @@ -0,0 +1,91 @@ +# Helpers for mutators that need the tree-sitter node (not just its text) to +# decide what to write back, e.g. to reuse a child node's text or to rewrite a +# multi-line span. Mutators built on these set `mutate_fn` on the Mutator. + +proposal <- function(node, text) list(node = node, text = text) + +# Replace the (possibly multi-line) span of `node` with `text`. +replace_span <- function(code, node, text) { + s <- treesitter::node_start_point(node) + e <- treesitter::node_end_point(node) + first <- code[s$row + 1L] + last <- code[e$row + 1L] + new <- strsplit( + paste0(substr(first, 1, s$column), text, substr(last, e$column + 1L, nchar(last))), + "\n", + fixed = TRUE + )[[1]] + if (length(new) == 0) new <- "" + c(code[seq_len(s$row)], new, code[-seq_len(e$row + 1L)]) +} + +# Run `query_str`, call `propose(node)` for every @target capture and build one +# mutant per returned proposal(). `propose` may return NULL or an empty list. +mutate_nodes <- function(code, query_str, propose) { + language <- treesitter.r::language() + parser <- treesitter::parser(language) + tree <- treesitter::parser_parse(parser, paste(code, collapse = "\n")) + root_node <- treesitter::tree_root_node(tree) + captures <- treesitter::query_captures(treesitter::query(language, query_str), root_node) + + mutations <- list() + for (i in seq_along(captures$node)) { + if (captures$name[[i]] != "target") next + for (p in propose(captures$node[[i]])) { + mutations <- append(mutations, list(list( + code = replace_span(code, p$node, p$text), + location = node_location(p$node), + replacement = p$text + ))) + } + } + if (length(mutations) == 0) return(NULL) + mutations +} + +node_mutator <- function(from, to, query, propose) { + Mutator$new( + from = from, + to = to, + query = query, + mutate_fn = function(code) mutate_nodes(code, query, propose) + ) +} + +field_text <- function(node, field) { + child <- treesitter::node_child_by_field_name(node, field) + if (is.null(child)) NULL else treesitter::node_text(child) +} + +# Name of the called function, without any `pkg::` prefix; NULL when the callee +# is not a plain or namespaced identifier. +call_function_name <- function(call_node) { + fn <- treesitter::node_child_by_field_name(call_node, "function") + if (is.null(fn)) return(NULL) + type <- treesitter::node_type(fn) + if (type == "namespace_operator") return(field_text(fn, "rhs")) + if (type == "identifier") return(treesitter::node_text(fn)) + NULL +} + +# Argument nodes of a call (comments excluded); empty list for `f()`. +call_argument_nodes <- function(call_node) { + args <- treesitter::node_child_by_field_name(call_node, "arguments") + if (is.null(args)) return(list()) + Filter( + function(n) treesitter::node_type(n) == "argument", + treesitter::node_named_children(args) + ) +} + +is_assignment_target <- function(node) { + parent <- treesitter::node_parent(node) + if (is.null(parent) || treesitter::node_type(parent) != "binary_operator") { + return(FALSE) + } + op <- field_text(parent, "operator") + side <- if (op %in% c("<-", "<<-", "=", ":=")) "lhs" else if (op %in% c("->", "->>")) "rhs" else return(FALSE) + target <- treesitter::node_child_by_field_name(parent, side) + treesitter::node_start_byte(target) == treesitter::node_start_byte(node) && + treesitter::node_end_byte(target) == treesitter::node_end_byte(node) +} diff --git a/R/mutator-condition-constant.R b/R/mutator-condition-constant.R new file mode 100644 index 0000000..a37829e --- /dev/null +++ b/R/mutator-condition-constant.R @@ -0,0 +1,30 @@ +#' Replace if/while conditions with a constant +#' +#' Replaces the condition of each matching statement with `TRUE` or `FALSE`, +#' so that a branch is always (or never) taken. Surviving mutants mean no test +#' distinguishes the real condition from a constant. +#' +#' Targets `if` only by default. `while (TRUE)` mutants normally loop forever +#' and `while (FALSE)` skips the loop entirely, so opt in to `"while"` only if +#' your tests have a timeout. +#' +#' @param value `TRUE` or `FALSE`, the constant to substitute. +#' @param statements Character vector of statement types to target; a subset +#' of `c("if", "while")`. +#' @return A [Mutator] object. +#' @export +#' @examples +#' condition_constant(TRUE) +#' condition_constant(FALSE) +condition_constant <- function(value = TRUE, statements = "if") { + checkmate::assert_flag(value) + checkmate::assert_subset(statements, c("if", "while"), empty.ok = FALSE) + replacement <- as.character(value) + Mutator$new( + from = "", + to = replacement, + query = condition_query(statements), + match_fn = function(text) text != replacement, + replacement_fn = function(text) replacement + ) +} diff --git a/R/mutator-logical.R b/R/mutator-logical.R new file mode 100644 index 0000000..ef29d6b --- /dev/null +++ b/R/mutator-logical.R @@ -0,0 +1,24 @@ +#' Keep only one operand of a logical operator +#' +#' Replaces `a && b` (and `||`, `&`, `|`) with just the left or right operand. +#' Stronger than swapping `&&` for `||`: a surviving mutant proves the dropped +#' operand never changes any test outcome. +#' +#' @param keep Which operand to keep, `"lhs"` or `"rhs"`. +#' @return A [Mutator] object. +#' @export +#' @examples +#' keep_operand("lhs") +#' keep_operand("rhs") +keep_operand <- function(keep = c("lhs", "rhs")) { + keep <- match.arg(keep) + node_mutator( + from = "a && b", + to = if (keep == "lhs") "a" else "b", + query = "(binary_operator) @target", + propose = function(node) { + if (!field_text(node, "operator") %in% c("&&", "||", "&", "|")) return(NULL) + list(proposal(node, field_text(node, keep))) + } + ) +} diff --git a/R/mutator-numeric-types.R b/R/mutator-numeric-types.R new file mode 100644 index 0000000..36eec32 --- /dev/null +++ b/R/mutator-numeric-types.R @@ -0,0 +1,76 @@ +#' Convert integer literals to doubles +#' +#' Replaces an integer literal such as `5L` with the double `5`. Catches code +#' whose behaviour depends on the numeric type (`identical()`, `is.integer()`, +#' integer overflow, `%/%` results). +#' +#' @return A [Mutator] object. +#' @export +#' @examples +#' integer_to_double() +integer_to_double <- function() { + Mutator$new( + from = "L", + to = "", + query = "(integer) @target", + match_fn = function(text) grepl("^[0-9]+L$", text), + replacement_fn = function(text) sub("L$", "", text) + ) +} + +#' Convert whole-number double literals to integers +#' +#' Replaces a whole-number double literal such as `5` with the integer `5L`. +#' Literals with a decimal point or exponent are left alone. Many of these +#' mutants are equivalent (e.g. `x[1]` vs `x[1L]`); survivors are only +#' interesting where the numeric type is observable. +#' +#' @return A [Mutator] object. +#' @export +#' @examples +#' double_to_integer() +double_to_integer <- function() { + Mutator$new( + from = "", + to = "L", + query = "(float) @target", + match_fn = function(text) grepl("^[0-9]+$", text), + replacement_fn = function(text) paste0(text, "L") + ) +} + +#' Replace numeric literals with boundary values +#' +#' Complements [numeric_increment()] and [numeric_decrement()] by replacing +#' every non-zero numeric literal `n` with a value that probes a different +#' failure mode: `0` (division by zero, empty results), `-n` (sign handling) or +#' `2 * n` (scaling errors). Zero literals are never mutated. +#' +#' @param kind One of `"zero"`, `"negate"` or `"double"`. +#' @return A [Mutator] object. +#' @export +#' @examples +#' numeric_replace("zero") +#' numeric_replace("negate") +#' numeric_replace("double") +numeric_replace <- function(kind = c("zero", "negate", "double")) { + kind <- match.arg(kind) + value <- function(text) suppressWarnings(as.numeric(sub("L$", "", text))) + suffix <- function(text) if (endsWith(text, "L")) "L" else "" + Mutator$new( + from = "", + to = switch(kind, zero = "0", negate = "-", double = " * 2"), + query = "[(float) (integer)] @target", + match_fn = function(text) { + v <- value(text) + !is.na(v) && v != 0 + }, + replacement_fn = function(text) { + switch(kind, + zero = paste0("0", suffix(text)), + negate = paste0("-", text), + double = paste0(format(value(text) * 2, scientific = FALSE), suffix(text)) + ) + } + ) +} diff --git a/R/mutator-unary-minus.R b/R/mutator-unary-minus.R new file mode 100644 index 0000000..ee9e361 --- /dev/null +++ b/R/mutator-unary-minus.R @@ -0,0 +1,19 @@ +#' Remove unary minus +#' +#' Strips the leading `-` from a negated expression, so `-x` becomes `x` and +#' `-(a + b)` becomes `(a + b)`. Catches tests that never check the sign of a +#' result. +#' +#' @return A [Mutator] object. +#' @export +#' @examples +#' remove_unary_minus() +remove_unary_minus <- function() { + Mutator$new( + from = "-", + to = "", + query = "(unary_operator) @target", + match_fn = function(text) startsWith(text, "-"), + replacement_fn = function(text) substring(text, 2) + ) +} diff --git a/tests/testthat/test-mutator-batch1.R b/tests/testthat/test-mutator-batch1.R new file mode 100644 index 0000000..d46c223 --- /dev/null +++ b/tests/testthat/test-mutator-batch1.R @@ -0,0 +1,119 @@ +describe("remove_unary_minus", { + it("removes the minus from a negated identifier", { + expect_mutates_to(remove_unary_minus(), "y <- -x", list("y <- x")) + }) + + it("removes the minus from a grouped expression", { + expect_mutates_to(remove_unary_minus(), "-(a + b)", list("(a + b)")) + }) + + it("leaves logical negation and unary plus alone", { + expect_no_mutations(remove_unary_minus(), c("!x", "+x")) + }) + + it("does not touch binary minus", { + expect_no_mutations(remove_unary_minus(), "a - b") + }) +}) + +describe("integer_to_double", { + it("drops the L suffix", { + expect_mutates_to(integer_to_double(), "x <- 5L", list("x <- 5")) + }) + + it("ignores double literals", { + expect_no_mutations(integer_to_double(), "x <- 5") + }) +}) + +describe("double_to_integer", { + it("adds an L suffix to whole numbers", { + expect_mutates_to(double_to_integer(), "x <- 5", list("x <- 5L")) + }) + + it("ignores fractional, exponent and integer literals", { + expect_no_mutations(double_to_integer(), c("a <- 1.5", "b <- 1e3", "c <- 2L")) + }) +}) + +describe("numeric_replace", { + it("replaces with zero, preserving the integer suffix", { + expect_mutates_to(numeric_replace("zero"), "x <- 5 + 2L", list("x <- 0 + 2L", "x <- 5 + 0L")) + }) + + it("negates literals", { + expect_mutates_to(numeric_replace("negate"), "x <- 2.5", list("x <- -2.5")) + }) + + it("doubles literals", { + expect_mutates_to(numeric_replace("double"), c("x <- 4", "y <- 3L"), list(c("x <- 8", "y <- 3L"), c("x <- 4", "y <- 6L"))) + }) + + it("never mutates zero", { + for (kind in c("zero", "negate", "double")) { + expect_no_mutations(numeric_replace(kind), "x <- 0") + } + }) + + it("rejects unknown kinds", { + expect_error(numeric_replace("bogus")) + }) +}) + +describe("condition_constant", { + it("replaces an if condition with TRUE", { + expect_mutates_to( + condition_constant(TRUE), + "if (x > 0) y", + list("if (TRUE) y") + ) + }) + + it("replaces an if condition with FALSE", { + expect_mutates_to( + condition_constant(FALSE), + "if (x > 0) y", + list("if (FALSE) y") + ) + }) + + it("skips conditions that already equal the constant", { + expect_no_mutations(condition_constant(TRUE), "if (TRUE) y") + }) + + it("does not touch while loops by default", { + expect_no_mutations(condition_constant(FALSE), "while (x) y") + }) + + it("can target while loops", { + expect_mutates_to( + condition_constant(FALSE, statements = "while"), + "while (x) y", + list("while (FALSE) y") + ) + }) +}) + +describe("keep_operand", { + it("keeps the left operand", { + expect_mutates_to(keep_operand("lhs"), "if (a && b) x", list("if (a) x")) + }) + + it("keeps the right operand", { + expect_mutates_to(keep_operand("rhs"), "if (a || b) x", list("if (b) x")) + }) + + it("handles vectorised operators", { + expect_mutates_to(keep_operand("lhs"), "a & b | c", list("a & b", "a | c")) + }) + + it("ignores other binary operators", { + expect_no_mutations(keep_operand("lhs"), "a + b == c") + }) +}) + +describe("membership operator", { + it("can be swapped via operator()", { + expect_mutates_to(operator("%in%", "=="), "x %in% y", list("x == y")) + }) +}) From e6b97da2b1de1f7b6dfbf92a62d26ab6be4fca23 Mon Sep 17 00:00:00 2001 From: Ben Mordue Date: Fri, 2 Oct 2026 14:56:56 +0100 Subject: [PATCH 2/5] feat: add else/if-body, null-guard, implicit-return and call-result mutators --- R/mutator-branch.R | 81 +++++++++++++++++ R/mutator-implicit-return.R | 66 ++++++++++++++ tests/testthat/test-mutator-batch2.R | 126 +++++++++++++++++++++++++++ 3 files changed, 273 insertions(+) create mode 100644 R/mutator-branch.R create mode 100644 R/mutator-implicit-return.R create mode 100644 tests/testthat/test-mutator-batch2.R diff --git a/R/mutator-branch.R b/R/mutator-branch.R new file mode 100644 index 0000000..8303d26 --- /dev/null +++ b/R/mutator-branch.R @@ -0,0 +1,81 @@ +#' Remove the else branch of if statements +#' +#' Turns `if (c) a else b` into `if (c) a`. Surviving mutants mean no test +#' exercises the `else` path with an observable result. +#' +#' @return A [Mutator] object. +#' @export +#' @examples +#' remove_else_branch() +remove_else_branch <- function() { + node_mutator( + from = "if (c) a else b", + to = "if (c) a", + query = "(if_statement alternative: (_)) @target", + propose = function(node) { + consequence <- treesitter::node_child_by_field_name(node, "consequence") + keep <- treesitter::node_end_byte(consequence) - treesitter::node_start_byte(node) + text <- rawToChar(charToRaw(treesitter::node_text(node))[seq_len(keep)]) + list(proposal(node, text)) + } + ) +} + +#' Empty the body of if statements +#' +#' Replaces the consequence of each `if` with an empty block, so +#' `if (c) a else b` becomes `if (c) {} else b`. Surviving mutants mean the +#' effect of the `if` body is never observed by a test. +#' +#' @return A [Mutator] object. +#' @export +#' @examples +#' empty_if_body() +empty_if_body <- function() { + node_mutator( + from = "if (c) a", + to = "if (c) {}", + query = "(if_statement consequence: (_) @target)", + propose = function(node) { + if (grepl("^\\{\\s*\\}$", treesitter::node_text(node))) return(NULL) + list(proposal(node, "{}")) + } + ) +} + +#' Remove `is.null()` guard clauses +#' +#' Replaces an early-return guard such as `if (is.null(x)) return(default)` with +#' `NULL`, so the function carries on as if the guard never fired. Only `if` +#' statements without an `else` whose body is a `return()` call are targeted. +#' +#' @return A [Mutator] object. +#' @export +#' @examples +#' remove_null_guard() +remove_null_guard <- function() { + is_return <- function(node) { + treesitter::node_type(node) == "call" && identical(call_function_name(node), "return") + } + node_mutator( + from = "if (is.null(x)) return(y)", + to = "NULL", + query = "(if_statement) @target", + propose = function(node) { + if (!is.null(treesitter::node_child_by_field_name(node, "alternative"))) return(NULL) + condition <- treesitter::node_child_by_field_name(node, "condition") + if (!grepl("is.null(", treesitter::node_text(condition), fixed = TRUE)) return(NULL) + body <- treesitter::node_child_by_field_name(node, "consequence") + if (treesitter::node_type(body) == "braced_expression") { + stmts <- Filter( + function(n) treesitter::node_type(n) != "comment", + treesitter::node_named_children(body) + ) + if (length(stmts) != 1) return(NULL) + body <- stmts[[1]] + } + if (!is_return(body)) return(NULL) + list(proposal(node, "NULL")) + } + ) +} diff --git a/R/mutator-implicit-return.R b/R/mutator-implicit-return.R new file mode 100644 index 0000000..6b240ac --- /dev/null +++ b/R/mutator-implicit-return.R @@ -0,0 +1,66 @@ +#' Replace the implicit return value of functions +#' +#' Replaces the last expression of every function body (the implicit return +#' value) with a fixed value (default `"NULL"`). Complements +#' [replace_return_value()], which only handles explicit `return()` calls; most +#' idiomatic R relies on implicit returns. Functions whose last expression is +#' already a `return()` call are skipped. +#' +#' @param replacement Raw R source text to substitute as the value. +#' @return A [Mutator] object. +#' @export +#' @examples +#' replace_implicit_return() +#' replace_implicit_return("NA") +replace_implicit_return <- function(replacement = "NULL") { + checkmate::assert_string(replacement, min.chars = 1) + node_mutator( + from = "", + to = replacement, + query = "(function_definition) @target", + propose = function(node) { + last <- treesitter::node_child_by_field_name(node, "body") + if (treesitter::node_type(last) == "braced_expression") { + stmts <- Filter( + function(n) treesitter::node_type(n) != "comment", + treesitter::node_named_children(last) + ) + if (length(stmts) == 0) return(NULL) + last <- stmts[[length(stmts)]] + } + if (treesitter::node_text(last) == replacement) return(NULL) + if (treesitter::node_type(last) == "call" && identical(call_function_name(last), "return")) { + return(NULL) + } + list(proposal(last, replacement)) + } + ) +} + +#' Replace the result of function calls +#' +#' Replaces every function call with a fixed value (default `"NULL"`), e.g. +#' `y <- compute(x)` becomes `y <- NULL`. Surviving mutants mean a call's +#' result is never checked. Calls that are assignment targets +#' (`names(x) <- v`) and `return()` calls are left alone. +#' +#' @param replacement Raw R source text to substitute for the call. +#' @return A [Mutator] object. +#' @export +#' @examples +#' replace_call_result() +#' replace_call_result("0") +replace_call_result <- function(replacement = "NULL") { + checkmate::assert_string(replacement, min.chars = 1) + node_mutator( + from = "f(...)", + to = replacement, + query = "(call) @target", + propose = function(node) { + if (treesitter::node_text(node) == replacement) return(NULL) + if (identical(call_function_name(node), "return")) return(NULL) + if (is_assignment_target(node)) return(NULL) + list(proposal(node, replacement)) + } + ) +} diff --git a/tests/testthat/test-mutator-batch2.R b/tests/testthat/test-mutator-batch2.R new file mode 100644 index 0000000..ccb1675 --- /dev/null +++ b/tests/testthat/test-mutator-batch2.R @@ -0,0 +1,126 @@ +describe("remove_else_branch", { + it("drops the else branch", { + expect_mutates_to(remove_else_branch(), "if (a) b else c", list("if (a) b")) + }) + + it("drops a multi-line else block", { + code <- c("if (a) {", " b", "} else {", " c", " d", "}", "z") + expect_mutates_to(remove_else_branch(), code, list(c("if (a) {", " b", "}", "z"))) + }) + + it("ignores if without else", { + expect_no_mutations(remove_else_branch(), "if (a) b") + }) + + it("handles else-if chains one level at a time", { + mutations <- mutate_codes(remove_else_branch(), "if (a) b else if (c) d else e") + expect_equal(mutations[[1]], "if (a) b") + expect_equal(mutations[[2]], "if (a) b else if (c) d") + }) +}) + +describe("empty_if_body", { + it("empties a bare consequence", { + expect_mutates_to(empty_if_body(), "if (a) b else c", list("if (a) {} else c")) + }) + + it("empties a multi-line block", { + code <- c("if (a) {", " b", "}", "z") + expect_mutates_to(empty_if_body(), code, list(c("if (a) {}", "z"))) + }) + + it("skips bodies that are already empty", { + expect_no_mutations(empty_if_body(), "if (a) {}") + }) +}) + +describe("remove_null_guard", { + it("removes a guard with a bare return", { + expect_mutates_to(remove_null_guard(), "if (is.null(x)) return(1)", list("NULL")) + }) + + it("removes a guard with a braced return", { + code <- c("f <- function(x) {", " if (is.null(x)) {", " return(NULL)", " }", " x", "}") + expect_mutates_to( + remove_null_guard(), + code, + list(c("f <- function(x) {", " NULL", " x", "}")) + ) + }) + + it("ignores guards that do not return", { + expect_no_mutations(remove_null_guard(), "if (is.null(x)) x <- 1") + }) + + it("ignores conditions without is.null", { + expect_no_mutations(remove_null_guard(), "if (x > 1) return(1)") + }) + + it("ignores guards with an else branch", { + expect_no_mutations(remove_null_guard(), "if (is.null(x)) return(1) else 2") + }) + + it("ignores guards with several statements", { + code <- c("if (is.null(x)) {", " message('a')", " return(1)", "}") + expect_no_mutations(remove_null_guard(), code) + }) +}) + +describe("replace_implicit_return", { + it("replaces the last expression of a braced body", { + code <- c("f <- function(x) {", " y <- x + 1", " y", "}") + expect_mutates_to( + replace_implicit_return(), + code, + list(c("f <- function(x) {", " y <- x + 1", " NULL", "}")) + ) + }) + + it("replaces a non-braced body", { + expect_mutates_to(replace_implicit_return("NA"), "f <- function(x) x + 1", list("f <- function(x) NA")) + }) + + it("skips functions ending in return()", { + expect_no_mutations(replace_implicit_return(), "f <- function(x) { return(x) }") + }) + + it("ignores trailing comments", { + code <- c("f <- function(x) {", " x", " # done", "}") + expect_mutates_to( + replace_implicit_return(), + code, + list(c("f <- function(x) {", " NULL", " # done", "}")) + ) + }) + + it("mutates nested functions independently", { + code <- "f <- function() { g <- function() 1; g() }" + expect_mutation_count(replace_implicit_return(), code, 2) + }) + + it("skips empty bodies and bodies already equal to the replacement", { + expect_no_mutations(replace_implicit_return(), c("f <- function() {}", "g <- function() NULL")) + }) +}) + +describe("replace_call_result", { + it("replaces a call with NULL", { + expect_mutates_to(replace_call_result(), "y <- compute(x)", list("y <- NULL")) + }) + + it("replaces nested calls independently", { + expect_mutates_to( + replace_call_result("0"), + "f(g(x))", + list("0", "f(0)") + ) + }) + + it("skips return calls and assignment targets", { + expect_no_mutations(replace_call_result(), c("return(x)", "names(x) <- 'a'") ) + }) + + it("still mutates the right-hand side of replacement-function assignment", { + expect_mutates_to(replace_call_result(), "names(x) <- f(y)", list("names(x) <- NULL")) + }) +}) From d798c56070907d78cc39d01dfa346ee291b5c061 Mon Sep 17 00:00:00 2001 From: Ben Mordue Date: Fri, 2 Oct 2026 14:57:37 +0100 Subject: [PATCH 3/5] feat: add argument, validation, named-flag, pipe and sequence mutators --- R/mutator-arguments.R | 124 +++++++++++++++++++++++ R/mutator-sequence.R | 103 +++++++++++++++++++ tests/testthat/test-mutator-batch3.R | 143 +++++++++++++++++++++++++++ 3 files changed, 370 insertions(+) create mode 100644 R/mutator-arguments.R create mode 100644 R/mutator-sequence.R create mode 100644 tests/testthat/test-mutator-batch3.R diff --git a/R/mutator-arguments.R b/R/mutator-arguments.R new file mode 100644 index 0000000..d847e6f --- /dev/null +++ b/R/mutator-arguments.R @@ -0,0 +1,124 @@ +#' Drop one argument from a call +#' +#' Produces one mutant per argument of each call with two or more arguments, +#' removing that argument: `f(a, b)` becomes `f(b)` and `f(a)`. Surviving +#' mutants reveal parameters that have no observable effect. `return()` is never +#' touched. Dropping arguments often yields code that errors, which counts as +#' killed, so scope it with `functions` to keep runs fast. +#' +#' @param functions Character vector of function names to target (without +#' `pkg::`). `NULL`, the default, targets every call. +#' @return A [Mutator] object. +#' @export +#' @examples +#' drop_argument() +#' drop_argument("c") +drop_argument <- function(functions = NULL) { + checkmate::assert_character(functions, min.chars = 1, any.missing = FALSE, null.ok = TRUE) + argument_mutator("f(a, b)", "f(b)", functions, function(texts) { + lapply(seq_along(texts), function(i) texts[-i]) + }) +} + +#' Reverse the arguments of a call +#' +#' Reverses the argument order of each call with two or more arguments, so +#' `c(a, b)` becomes `c(b, a)`. Surviving mutants mean no test depends on the +#' order of the elements. +#' +#' @param functions Character vector of function names to target (without +#' `pkg::`). Defaults to `"c"`; `NULL` targets every call. +#' @return A [Mutator] object. +#' @export +#' @examples +#' reverse_arguments() +#' reverse_arguments(c("c", "paste")) +reverse_arguments <- function(functions = "c") { + checkmate::assert_character(functions, min.chars = 1, any.missing = FALSE, null.ok = TRUE) + argument_mutator("f(a, b)", "f(b, a)", functions, function(texts) { + if (identical(rev(texts), texts)) list() else list(rev(texts)) + }) +} + +# `variants(texts)` returns a list of character vectors, each the argument +# texts for one mutant. +argument_mutator <- function(from, to, functions, variants) { + node_mutator( + from = from, + to = to, + query = "(call) @target", + propose = function(node) { + name <- call_function_name(node) + if (is.null(name) || name == "return") return(NULL) + if (!is.null(functions) && !name %in% functions) return(NULL) + args <- call_argument_nodes(node) + if (length(args) < 2) return(NULL) + texts <- vapply(args, treesitter::node_text, character(1)) + args_node <- treesitter::node_child_by_field_name(node, "arguments") + lapply(variants(texts), function(v) { + proposal(args_node, paste0("(", paste(v, collapse = ", "), ")")) + }) + } + ) +} + +#' Remove validation calls +#' +#' Replaces calls that enforce preconditions with `NULL`, so +#' `if (!is.numeric(x)) stop("bad")` becomes `if (!is.numeric(x)) NULL`. +#' Surviving mutants mean no test checks that invalid input is rejected. By +#' default targets `stop()`, `stopifnot()`, `warning()`, `rlang::abort()`, +#' `cli::cli_abort()`, `cli::cli_warn()` and checkmate-style `assert_*()` calls. +#' +#' Unlike [delete_statement()], the call is replaced rather than deleted, so +#' calls used as the body of an `if` stay syntactically valid. +#' +#' @param pattern Regular expression matched against the function name. +#' @return A [Mutator] object. +#' @export +#' @examples +#' remove_validation() +#' remove_validation("^stopifnot$") +remove_validation <- function( + pattern = "^(stop|stopifnot|warning|abort|cli_abort|cli_warn|assert_[A-Za-z0-9_]+)$" +) { + checkmate::assert_string(pattern, min.chars = 1) + node_mutator( + from = "stop(...)", + to = "NULL", + query = "(call) @target", + propose = function(node) { + name <- call_function_name(node) + if (is.null(name) || !grepl(pattern, name)) return(NULL) + list(proposal(node, "NULL")) + } + ) +} + +#' Flip logical flags passed as named arguments +#' +#' Flips `TRUE`/`FALSE` only where it is the value of a named argument, e.g. +#' `mean(x, na.rm = TRUE)` becomes `mean(x, na.rm = FALSE)`. A narrower and +#' less noisy alternative to [boolean_literal()] for catching silently-wrong +#' option handling (`na.rm`, `drop`, `fixed`, `decreasing`, ...). +#' +#' @param arguments Character vector of argument names to target. `NULL`, the +#' default, targets every named argument. +#' @return A [Mutator] object. +#' @export +#' @examples +#' flip_named_flag() +#' flip_named_flag(c("na.rm", "drop")) +flip_named_flag <- function(arguments = NULL) { + checkmate::assert_character(arguments, min.chars = 1, any.missing = FALSE, null.ok = TRUE) + node_mutator( + from = "name = TRUE", + to = "name = FALSE", + query = "(argument name: (_) value: [(true) (false)] @target)", + propose = function(node) { + name <- field_text(treesitter::node_parent(node), "name") + if (!is.null(arguments) && !name %in% arguments) return(NULL) + list(proposal(node, if (treesitter::node_text(node) == "TRUE") "FALSE" else "TRUE")) + } + ) +} diff --git a/R/mutator-sequence.R b/R/mutator-sequence.R new file mode 100644 index 0000000..0b6ce26 --- /dev/null +++ b/R/mutator-sequence.R @@ -0,0 +1,103 @@ +#' Remove one stage from a pipe chain +#' +#' Replaces `x |> f()` (and `x %>% f()`) with `x`, dropping the right-hand +#' stage. In a chain `x |> f() |> g()` each stage is removed in turn. Surviving +#' mutants mean a pipeline step has no observable effect. +#' +#' @return A [Mutator] object. +#' @export +#' @examples +#' remove_pipe_stage() +remove_pipe_stage <- function() { + node_mutator( + from = "x |> f()", + to = "x", + query = "(binary_operator) @target", + propose = function(node) { + if (!field_text(node, "operator") %in% c("|>", "%>%")) return(NULL) + list(proposal(node, field_text(node, "lhs"))) + } + ) +} + +#' Shrink `seq_len()` by one +#' +#' Replaces `seq_len(n)` with `seq_len(n - 1L)`, catching loops that run one +#' iteration too many or too few. +#' +#' @return A [Mutator] object. +#' @export +#' @examples +#' seq_len_decrement() +seq_len_decrement <- function() { + node_mutator( + from = "seq_len(n)", + to = "seq_len(n - 1L)", + query = "(call) @target", + propose = function(node) { + if (!identical(call_function_name(node), "seq_len")) return(NULL) + args <- call_argument_nodes(node) + if (length(args) != 1) return(NULL) + args_node <- treesitter::node_child_by_field_name(node, "arguments") + list(proposal(args_node, paste0("(", treesitter::node_text(args[[1]]), " - 1L)"))) + } + ) +} + +#' Skip the first element of `seq_along()` +#' +#' Replaces `seq_along(x)` with `seq_along(x)[-1L]`, catching loops that +#' silently depend on processing the first element. +#' +#' @return A [Mutator] object. +#' @export +#' @examples +#' seq_along_drop_first() +seq_along_drop_first <- function() { + node_mutator( + from = "seq_along(x)", + to = "seq_along(x)[-1L]", + query = "(call) @target", + propose = function(node) { + if (!identical(call_function_name(node), "seq_along")) return(NULL) + list(proposal(node, paste0(treesitter::node_text(node), "[-1L]"))) + } + ) +} + +#' Shift the bounds of `a:b` ranges +#' +#' Replaces the start of a range with `(a + 1L)` ([colon_start_increment()]) or +#' the end with `(b - 1L)` ([colon_end_decrement()]), the classic off-by-one in +#' `1:n` loops. Only non-literal bounds are touched; literal bounds are already +#' covered by [numeric_increment()] and [numeric_decrement()]. +#' +#' @return A [Mutator] object. +#' @export +#' @examples +#' colon_start_increment() +colon_start_increment <- function() { + colon_mutator("(a + 1L):b", "lhs", " + 1L") +} + +#' @rdname colon_start_increment +#' @export +#' @examples +#' colon_end_decrement() +colon_end_decrement <- function() { + colon_mutator("a:(b - 1L)", "rhs", " - 1L") +} + +colon_mutator <- function(to, side, shift) { + node_mutator( + from = "a:b", + to = to, + query = "(binary_operator) @target", + propose = function(node) { + if (field_text(node, "operator") != ":") return(NULL) + bound <- treesitter::node_child_by_field_name(node, side) + if (treesitter::node_type(bound) %in% c("float", "integer")) return(NULL) + list(proposal(bound, paste0("(", treesitter::node_text(bound), shift, ")"))) + } + ) +} diff --git a/tests/testthat/test-mutator-batch3.R b/tests/testthat/test-mutator-batch3.R new file mode 100644 index 0000000..f9076b0 --- /dev/null +++ b/tests/testthat/test-mutator-batch3.R @@ -0,0 +1,143 @@ +describe("drop_argument", { + it("drops each argument in turn", { + expect_mutates_to(drop_argument(), "f(a, b, c)", list("f(b, c)", "f(a, c)", "f(a, b)")) + }) + + it("keeps named arguments intact", { + expect_mutates_to(drop_argument(), "f(a, n = 1)", list("f(n = 1)", "f(a)")) + }) + + it("ignores single-argument calls and return()", { + expect_no_mutations(drop_argument(), c("f(a)", "return(a, b)")) + }) + + it("can be scoped to named functions, including namespaced calls", { + code <- c("c(a, b)", "paste(a, b)", "base::c(a, b)") + mutations <- mutate_codes(drop_argument("c"), code) + expect_length(mutations, 4) + expect_equal(mutations[[1]], c("c(b)", "paste(a, b)", "base::c(a, b)")) + }) + + it("handles multi-line argument lists", { + code <- c("f(", " a,", " b", ")") + expect_mutates_to(drop_argument(), code, list("f(b)", "f(a)")) + }) +}) + +describe("reverse_arguments", { + it("reverses c() by default", { + expect_mutates_to(reverse_arguments(), "c(a, b, d)", list("c(d, b, a)")) + }) + + it("ignores other functions by default", { + expect_no_mutations(reverse_arguments(), "paste(a, b)") + }) + + it("skips palindromic argument lists", { + expect_no_mutations(reverse_arguments(), "c(a, a)") + }) + + it("can target every call", { + expect_mutates_to(reverse_arguments(NULL), "paste(a, b)", list("paste(b, a)")) + }) +}) + +describe("remove_validation", { + it("replaces stop() in an if body with NULL", { + expect_mutates_to( + remove_validation(), + "if (!ok) stop('bad')", + list("if (!ok) NULL") + ) + }) + + it("covers stopifnot, abort and assert_ calls", { + code <- c("stopifnot(x)", "rlang::abort('m')", "assert_string(x)") + expect_mutation_count(remove_validation(), code, 3) + }) + + it("ignores unrelated calls", { + expect_no_mutations(remove_validation(), "print(x)") + }) + + it("accepts a custom pattern", { + expect_mutates_to(remove_validation("^check$"), "check(x)", list("NULL")) + }) +}) + +describe("flip_named_flag", { + it("flips TRUE and FALSE in named arguments", { + expect_mutates_to( + flip_named_flag(), + "mean(x, na.rm = TRUE)", + list("mean(x, na.rm = FALSE)") + ) + expect_mutates_to(flip_named_flag(), "f(a = FALSE)", list("f(a = TRUE)")) + }) + + it("ignores positional flags and function defaults", { + expect_no_mutations(flip_named_flag(), c("f(TRUE)", "g <- function(x = TRUE) x")) + }) + + it("can be scoped to argument names", { + code <- "f(na.rm = TRUE, other = FALSE)" + expect_mutates_to(flip_named_flag("na.rm"), code, list("f(na.rm = FALSE, other = FALSE)")) + }) +}) + +describe("remove_pipe_stage", { + it("drops the native pipe stage", { + expect_mutates_to(remove_pipe_stage(), "x |> f()", list("x")) + }) + + it("drops the magrittr pipe stage", { + expect_mutates_to(remove_pipe_stage(), "y <- x %>% f()", list("y <- x")) + }) + + it("removes each stage of a chain in turn", { + expect_mutates_to(remove_pipe_stage(), "x |> f() |> g()", list("x |> f()", "x |> g()")) + }) + + it("ignores other operators", { + expect_no_mutations(remove_pipe_stage(), "a + b") + }) +}) + +describe("seq_len_decrement", { + it("subtracts one from the length", { + expect_mutates_to(seq_len_decrement(), "for (i in seq_len(n)) f(i)", list("for (i in seq_len(n - 1L)) f(i)")) + }) + + it("ignores other calls", { + expect_no_mutations(seq_len_decrement(), "seq_along(x)") + }) +}) + +describe("seq_along_drop_first", { + it("drops the first index", { + expect_mutates_to(seq_along_drop_first(), "seq_along(x)", list("seq_along(x)[-1L]")) + }) + + it("ignores other calls", { + expect_no_mutations(seq_along_drop_first(), "seq_len(x)") + }) +}) + +describe("colon ranges", { + it("increments a non-literal start", { + expect_mutates_to(colon_start_increment(), "a:b", list("(a + 1L):b")) + }) + + it("decrements a non-literal end", { + expect_mutates_to(colon_end_decrement(), "1:n", list("1:(n - 1L)")) + }) + + it("leaves literal bounds to the numeric mutators", { + expect_no_mutations(colon_start_increment(), "1:10") + expect_no_mutations(colon_end_decrement(), "1:10") + }) + + it("ignores namespace operators and other binary operators", { + expect_no_mutations(colon_end_decrement(), c("a::b", "a + b")) + }) +}) From 0282dd724a51315bb2d104a14e2de886e50c1859 Mon Sep 17 00:00:00 2001 From: Ben Mordue Date: Fri, 2 Oct 2026 14:58:38 +0100 Subject: [PATCH 4/5] feat: add presets, docs, pkgdown entries and NEWS for new mutators --- NAMESPACE | 27 ++++ NEWS.md | 8 ++ R/mutator-presets-extra.R | 133 ++++++++++++++++++++ _pkgdown.yml | 26 ++++ man/branch_mutations.Rd | 23 ++++ man/colon_start_increment.Rd | 24 ++++ man/condition_constant.Rd | 31 +++++ man/double_to_integer.Rd | 20 +++ man/drop_argument.Rd | 26 ++++ man/empty_if_body.Rd | 19 +++ man/flip_named_flag.Rd | 25 ++++ man/function_swaps.Rd | 26 ++++ man/integer_to_double.Rd | 19 +++ man/keep_operand.Rd | 23 ++++ man/membership_operators.Rd | 19 +++ man/numeric_boundaries.Rd | 18 +++ man/numeric_replace.Rd | 25 ++++ man/numeric_type_swaps.Rd | 19 +++ man/operand_elision.Rd | 18 +++ man/remove_else_branch.Rd | 18 +++ man/remove_null_guard.Rd | 19 +++ man/remove_pipe_stage.Rd | 19 +++ man/remove_unary_minus.Rd | 19 +++ man/remove_validation.Rd | 31 +++++ man/replace_call_result.Rd | 24 ++++ man/replace_implicit_return.Rd | 25 ++++ man/reverse_arguments.Rd | 24 ++++ man/seq_along_drop_first.Rd | 18 +++ man/seq_len_decrement.Rd | 18 +++ man/sequence_mutations.Rd | 19 +++ tests/testthat/test-mutator-presets-extra.R | 48 +++++++ 31 files changed, 811 insertions(+) create mode 100644 R/mutator-presets-extra.R create mode 100644 man/branch_mutations.Rd create mode 100644 man/colon_start_increment.Rd create mode 100644 man/condition_constant.Rd create mode 100644 man/double_to_integer.Rd create mode 100644 man/drop_argument.Rd create mode 100644 man/empty_if_body.Rd create mode 100644 man/flip_named_flag.Rd create mode 100644 man/function_swaps.Rd create mode 100644 man/integer_to_double.Rd create mode 100644 man/keep_operand.Rd create mode 100644 man/membership_operators.Rd create mode 100644 man/numeric_boundaries.Rd create mode 100644 man/numeric_replace.Rd create mode 100644 man/numeric_type_swaps.Rd create mode 100644 man/operand_elision.Rd create mode 100644 man/remove_else_branch.Rd create mode 100644 man/remove_null_guard.Rd create mode 100644 man/remove_pipe_stage.Rd create mode 100644 man/remove_unary_minus.Rd create mode 100644 man/remove_validation.Rd create mode 100644 man/replace_call_result.Rd create mode 100644 man/replace_implicit_return.Rd create mode 100644 man/reverse_arguments.Rd create mode 100644 man/seq_along_drop_first.Rd create mode 100644 man/seq_len_decrement.Rd create mode 100644 man/sequence_mutations.Rd create mode 100644 tests/testthat/test-mutator-presets-extra.R diff --git a/NAMESPACE b/NAMESPACE index 9fbf233..3b5f1aa 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -15,30 +15,57 @@ export(TestStrategy) export(arithmetic_operators) export(boolean_literal) export(boolean_literals) +export(branch_mutations) export(call_name) +export(colon_end_decrement) +export(colon_start_increment) export(comparison_operators) +export(condition_constant) export(condition_mutations) export(default_copy_strategy) export(default_reporter) export(default_test_strategy) export(delete_statement) +export(double_to_integer) +export(drop_argument) +export(empty_if_body) +export(flip_named_flag) +export(function_swaps) export(index_decrement) export(index_increment) export(index_mutations) +export(integer_to_double) +export(keep_operand) export(logical_operators) +export(membership_operators) export(muttest) export(muttest_plan) export(na_literal) export(na_literals) export(negate_condition) +export(numeric_boundaries) export(numeric_decrement) export(numeric_increment) export(numeric_literals) +export(numeric_replace) +export(numeric_type_swaps) +export(operand_elision) export(operator) export(remove_condition_negation) +export(remove_else_branch) export(remove_negation) +export(remove_null_guard) +export(remove_pipe_stage) +export(remove_unary_minus) +export(remove_validation) +export(replace_call_result) +export(replace_implicit_return) export(replace_return_value) export(report) +export(reverse_arguments) +export(seq_along_drop_first) +export(seq_len_decrement) +export(sequence_mutations) export(string_empty) export(string_fill) export(string_literals) diff --git a/NEWS.md b/NEWS.md index c145107..ca9023c 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,3 +1,11 @@ +# muttest (development version) + +- ✨ New mutators: + - Calls and arguments: `drop_argument()`, `reverse_arguments()`, `remove_validation()`, `flip_named_flag()`, `replace_call_result()`, `remove_pipe_stage()`. + - Control flow: `condition_constant()`, `remove_else_branch()`, `empty_if_body()`, `remove_null_guard()`, `keep_operand()`, `replace_implicit_return()`. + - Literals and sequences: `remove_unary_minus()`, `integer_to_double()`, `double_to_integer()`, `numeric_replace()`, `seq_len_decrement()`, `seq_along_drop_first()`, `colon_start_increment()`, `colon_end_decrement()`. +- ✨ New presets: `function_swaps()` (`any`/`all`, `min`/`max`, `nrow`/`ncol`, ...), `membership_operators()`, `operand_elision()`, `branch_mutations()`, `numeric_boundaries()`, `numeric_type_swaps()` and `sequence_mutations()`. + # muttest 0.3.0 - ✨ New `JSONMutationReporter` writes a report conforming to the [mutation-testing-elements](https://stryker-mutator.io) schema, a cross-language standard shared by StrykerJS/.NET/Scala — so muttest output can be fed to any schema-compatible tooling. diff --git a/R/mutator-presets-extra.R b/R/mutator-presets-extra.R new file mode 100644 index 0000000..b30f547 --- /dev/null +++ b/R/mutator-presets-extra.R @@ -0,0 +1,133 @@ +#' Function name swap mutators +#' +#' Returns a ready-made list of [call_name()] mutators for semantically related +#' functions: `any`/`all`, `min`/`max`, `sum`/`prod`, `head`/`tail`, +#' `which.min`/`which.max`, `cumsum`/`cumprod`, `floor`/`ceiling`, +#' `nrow`/`ncol` (and `NROW`/`NCOL`), `startsWith`/`endsWith`, +#' `toupper`/`tolower`, `isTRUE`/`isFALSE` and `nchar`/`length`. +#' +#' Use on any file that aggregates, slices or reshapes data. A surviving mutant +#' means the test data cannot tell the two functions apart, e.g. a square +#' matrix for `nrow`/`ncol` or a single-element vector for `min`/`max`. +#' +#' @return A list of [call_name()] mutators. +#' @export +#' @examples +#' function_swaps() +function_swaps <- function() { + pairs <- list( + c("any", "all"), + c("min", "max"), + c("sum", "prod"), + c("head", "tail"), + c("which.min", "which.max"), + c("cumsum", "cumprod"), + c("floor", "ceiling"), + c("nrow", "ncol"), + c("NROW", "NCOL"), + c("startsWith", "endsWith"), + c("toupper", "tolower"), + c("isTRUE", "isFALSE"), + c("nchar", "length") + ) + unlist( + lapply(pairs, function(p) list(call_name(p[1], p[2]), call_name(p[2], p[1]))), + recursive = FALSE + ) +} + +#' Membership operator mutators +#' +#' Returns a list with an [operator()] mutator that replaces `%in%` with `==`. +#' The two agree for scalar inputs but differ for vectors, so a surviving +#' mutant means tests only ever pass a single value. +#' +#' @return A list of [operator()] mutators. +#' @export +#' @examples +#' membership_operators() +membership_operators <- function() { + list(operator("%in%", "==")) +} + +#' Operand elision mutators +#' +#' Returns [keep_operand()] mutators that reduce each `&&`, `||`, `&` and `|` +#' expression to its left or right operand. +#' +#' @return A list of [keep_operand()] mutators. +#' @export +#' @examples +#' operand_elision() +operand_elision <- function() { + list(keep_operand("lhs"), keep_operand("rhs")) +} + +#' Branch mutators +#' +#' Returns mutators that force or remove branches: [condition_constant()] +#' (`TRUE` and `FALSE`), [remove_else_branch()], [empty_if_body()] and +#' [remove_null_guard()]. +#' +#' Use on files with significant control flow. Surviving mutants point to +#' branches whose behaviour no test observes. +#' +#' @return A list of mutators. +#' @export +#' @examples +#' branch_mutations() +branch_mutations <- function() { + list( + condition_constant(TRUE), + condition_constant(FALSE), + remove_else_branch(), + empty_if_body(), + remove_null_guard() + ) +} + +#' Numeric boundary mutators +#' +#' Returns [numeric_replace()] mutators that replace each non-zero numeric +#' literal with `0`, `-n` and `2 * n`. Use alongside [numeric_literals()]. +#' +#' @return A list of mutators. +#' @export +#' @examples +#' numeric_boundaries() +numeric_boundaries <- function() { + list(numeric_replace("zero"), numeric_replace("negate"), numeric_replace("double")) +} + +#' Numeric type mutators +#' +#' Returns [integer_to_double()] and [double_to_integer()] mutators that flip +#' the numeric type of literals. Many resulting mutants are equivalent, so use +#' selectively on code where integer vs double behaviour matters. +#' +#' @return A list of mutators. +#' @export +#' @examples +#' numeric_type_swaps() +numeric_type_swaps <- function() { + list(integer_to_double(), double_to_integer()) +} + +#' Sequence mutators +#' +#' Returns mutators for off-by-one errors in loops and ranges: +#' [seq_len_decrement()], [seq_along_drop_first()], [colon_start_increment()] +#' and [colon_end_decrement()]. +#' +#' @return A list of mutators. +#' @export +#' @examples +#' sequence_mutations() +sequence_mutations <- function() { + list( + seq_len_decrement(), + seq_along_drop_first(), + colon_start_increment(), + colon_end_decrement() + ) +} diff --git a/_pkgdown.yml b/_pkgdown.yml index a957b07..72d58e4 100644 --- a/_pkgdown.yml +++ b/_pkgdown.yml @@ -76,6 +76,25 @@ reference: - remove_negation - replace_return_value - delete_statement + - remove_unary_minus + - integer_to_double + - double_to_integer + - numeric_replace + - condition_constant + - keep_operand + - remove_else_branch + - empty_if_body + - remove_null_guard + - replace_implicit_return + - replace_call_result + - drop_argument + - reverse_arguments + - remove_validation + - flip_named_flag + - remove_pipe_stage + - seq_len_decrement + - seq_along_drop_first + - colon_start_increment - title: Mutator presets desc: Ready-made collections of related mutators for common bug categories. contents: @@ -88,6 +107,13 @@ reference: - index_mutations - string_literals - condition_mutations + - function_swaps + - membership_operators + - operand_elision + - branch_mutations + - numeric_boundaries + - numeric_type_swaps + - sequence_mutations - title: Reporters desc: Control how results are displayed. contents: diff --git a/man/branch_mutations.Rd b/man/branch_mutations.Rd new file mode 100644 index 0000000..5323ad4 --- /dev/null +++ b/man/branch_mutations.Rd @@ -0,0 +1,23 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/mutator-presets-extra.R +\name{branch_mutations} +\alias{branch_mutations} +\title{Branch mutators} +\usage{ +branch_mutations() +} +\value{ +A list of mutators. +} +\description{ +Returns mutators that force or remove branches: \code{\link[=condition_constant]{condition_constant()}} +(\code{TRUE} and \code{FALSE}), \code{\link[=remove_else_branch]{remove_else_branch()}}, \code{\link[=empty_if_body]{empty_if_body()}} and +\code{\link[=remove_null_guard]{remove_null_guard()}}. +} +\details{ +Use on files with significant control flow. Surviving mutants point to +branches whose behaviour no test observes. +} +\examples{ +branch_mutations() +} diff --git a/man/colon_start_increment.Rd b/man/colon_start_increment.Rd new file mode 100644 index 0000000..4860969 --- /dev/null +++ b/man/colon_start_increment.Rd @@ -0,0 +1,24 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/mutator-sequence.R +\name{colon_start_increment} +\alias{colon_start_increment} +\alias{colon_end_decrement} +\title{Shift the bounds of \code{a:b} ranges} +\usage{ +colon_start_increment() + +colon_end_decrement() +} +\value{ +A \link{Mutator} object. +} +\description{ +Replaces the start of a range with \code{(a + 1L)} (\code{\link[=colon_start_increment]{colon_start_increment()}}) or +the end with \code{(b - 1L)} (\code{\link[=colon_end_decrement]{colon_end_decrement()}}), the classic off-by-one in +\code{1:n} loops. Only non-literal bounds are touched; literal bounds are already +covered by \code{\link[=numeric_increment]{numeric_increment()}} and \code{\link[=numeric_decrement]{numeric_decrement()}}. +} +\examples{ +colon_start_increment() +colon_end_decrement() +} diff --git a/man/condition_constant.Rd b/man/condition_constant.Rd new file mode 100644 index 0000000..52a81c5 --- /dev/null +++ b/man/condition_constant.Rd @@ -0,0 +1,31 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/mutator-condition-constant.R +\name{condition_constant} +\alias{condition_constant} +\title{Replace if/while conditions with a constant} +\usage{ +condition_constant(value = TRUE, statements = "if") +} +\arguments{ +\item{value}{\code{TRUE} or \code{FALSE}, the constant to substitute.} + +\item{statements}{Character vector of statement types to target; a subset +of \code{c("if", "while")}.} +} +\value{ +A \link{Mutator} object. +} +\description{ +Replaces the condition of each matching statement with \code{TRUE} or \code{FALSE}, +so that a branch is always (or never) taken. Surviving mutants mean no test +distinguishes the real condition from a constant. +} +\details{ +Targets \code{if} only by default. \verb{while (TRUE)} mutants normally loop forever +and \verb{while (FALSE)} skips the loop entirely, so opt in to \code{"while"} only if +your tests have a timeout. +} +\examples{ +condition_constant(TRUE) +condition_constant(FALSE) +} diff --git a/man/double_to_integer.Rd b/man/double_to_integer.Rd new file mode 100644 index 0000000..6d55f40 --- /dev/null +++ b/man/double_to_integer.Rd @@ -0,0 +1,20 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/mutator-numeric-types.R +\name{double_to_integer} +\alias{double_to_integer} +\title{Convert whole-number double literals to integers} +\usage{ +double_to_integer() +} +\value{ +A \link{Mutator} object. +} +\description{ +Replaces a whole-number double literal such as \code{5} with the integer \code{5L}. +Literals with a decimal point or exponent are left alone. Many of these +mutants are equivalent (e.g. \code{x[1]} vs \code{x[1L]}); survivors are only +interesting where the numeric type is observable. +} +\examples{ +double_to_integer() +} diff --git a/man/drop_argument.Rd b/man/drop_argument.Rd new file mode 100644 index 0000000..a171e90 --- /dev/null +++ b/man/drop_argument.Rd @@ -0,0 +1,26 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/mutator-arguments.R +\name{drop_argument} +\alias{drop_argument} +\title{Drop one argument from a call} +\usage{ +drop_argument(functions = NULL) +} +\arguments{ +\item{functions}{Character vector of function names to target (without +\verb{pkg::}). \code{NULL}, the default, targets every call.} +} +\value{ +A \link{Mutator} object. +} +\description{ +Produces one mutant per argument of each call with two or more arguments, +removing that argument: \code{f(a, b)} becomes \code{f(b)} and \code{f(a)}. Surviving +mutants reveal parameters that have no observable effect. \code{return()} is never +touched. Dropping arguments often yields code that errors, which counts as +killed, so scope it with \code{functions} to keep runs fast. +} +\examples{ +drop_argument() +drop_argument("c") +} diff --git a/man/empty_if_body.Rd b/man/empty_if_body.Rd new file mode 100644 index 0000000..b200c57 --- /dev/null +++ b/man/empty_if_body.Rd @@ -0,0 +1,19 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/mutator-branch.R +\name{empty_if_body} +\alias{empty_if_body} +\title{Empty the body of if statements} +\usage{ +empty_if_body() +} +\value{ +A \link{Mutator} object. +} +\description{ +Replaces the consequence of each \code{if} with an empty block, so +\code{if (c) a else b} becomes \code{if (c) {} else b}. Surviving mutants mean the +effect of the \code{if} body is never observed by a test. +} +\examples{ +empty_if_body() +} diff --git a/man/flip_named_flag.Rd b/man/flip_named_flag.Rd new file mode 100644 index 0000000..4d977ed --- /dev/null +++ b/man/flip_named_flag.Rd @@ -0,0 +1,25 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/mutator-arguments.R +\name{flip_named_flag} +\alias{flip_named_flag} +\title{Flip logical flags passed as named arguments} +\usage{ +flip_named_flag(arguments = NULL) +} +\arguments{ +\item{arguments}{Character vector of argument names to target. \code{NULL}, the +default, targets every named argument.} +} +\value{ +A \link{Mutator} object. +} +\description{ +Flips \code{TRUE}/\code{FALSE} only where it is the value of a named argument, e.g. +\code{mean(x, na.rm = TRUE)} becomes \code{mean(x, na.rm = FALSE)}. A narrower and +less noisy alternative to \code{\link[=boolean_literal]{boolean_literal()}} for catching silently-wrong +option handling (\code{na.rm}, \code{drop}, \code{fixed}, \code{decreasing}, ...). +} +\examples{ +flip_named_flag() +flip_named_flag(c("na.rm", "drop")) +} diff --git a/man/function_swaps.Rd b/man/function_swaps.Rd new file mode 100644 index 0000000..08e075f --- /dev/null +++ b/man/function_swaps.Rd @@ -0,0 +1,26 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/mutator-presets-extra.R +\name{function_swaps} +\alias{function_swaps} +\title{Function name swap mutators} +\usage{ +function_swaps() +} +\value{ +A list of \code{\link[=call_name]{call_name()}} mutators. +} +\description{ +Returns a ready-made list of \code{\link[=call_name]{call_name()}} mutators for semantically related +functions: \code{any}/\code{all}, \code{min}/\code{max}, \code{sum}/\code{prod}, \code{head}/\code{tail}, +\code{which.min}/\code{which.max}, \code{cumsum}/\code{cumprod}, \code{floor}/\code{ceiling}, +\code{nrow}/\code{ncol} (and \code{NROW}/\code{NCOL}), \code{startsWith}/\code{endsWith}, +\code{toupper}/\code{tolower}, \code{isTRUE}/\code{isFALSE} and \code{nchar}/\code{length}. +} +\details{ +Use on any file that aggregates, slices or reshapes data. A surviving mutant +means the test data cannot tell the two functions apart, e.g. a square +matrix for \code{nrow}/\code{ncol} or a single-element vector for \code{min}/\code{max}. +} +\examples{ +function_swaps() +} diff --git a/man/integer_to_double.Rd b/man/integer_to_double.Rd new file mode 100644 index 0000000..bfb1981 --- /dev/null +++ b/man/integer_to_double.Rd @@ -0,0 +1,19 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/mutator-numeric-types.R +\name{integer_to_double} +\alias{integer_to_double} +\title{Convert integer literals to doubles} +\usage{ +integer_to_double() +} +\value{ +A \link{Mutator} object. +} +\description{ +Replaces an integer literal such as \code{5L} with the double \code{5}. Catches code +whose behaviour depends on the numeric type (\code{identical()}, \code{is.integer()}, +integer overflow, \code{\%/\%} results). +} +\examples{ +integer_to_double() +} diff --git a/man/keep_operand.Rd b/man/keep_operand.Rd new file mode 100644 index 0000000..b24a902 --- /dev/null +++ b/man/keep_operand.Rd @@ -0,0 +1,23 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/mutator-logical.R +\name{keep_operand} +\alias{keep_operand} +\title{Keep only one operand of a logical operator} +\usage{ +keep_operand(keep = c("lhs", "rhs")) +} +\arguments{ +\item{keep}{Which operand to keep, \code{"lhs"} or \code{"rhs"}.} +} +\value{ +A \link{Mutator} object. +} +\description{ +Replaces \code{a && b} (and \code{||}, \code{&}, \code{|}) with just the left or right operand. +Stronger than swapping \code{&&} for \code{||}: a surviving mutant proves the dropped +operand never changes any test outcome. +} +\examples{ +keep_operand("lhs") +keep_operand("rhs") +} diff --git a/man/membership_operators.Rd b/man/membership_operators.Rd new file mode 100644 index 0000000..b1fd86c --- /dev/null +++ b/man/membership_operators.Rd @@ -0,0 +1,19 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/mutator-presets-extra.R +\name{membership_operators} +\alias{membership_operators} +\title{Membership operator mutators} +\usage{ +membership_operators() +} +\value{ +A list of \code{\link[=operator]{operator()}} mutators. +} +\description{ +Returns a list with an \code{\link[=operator]{operator()}} mutator that replaces \code{\%in\%} with \code{==}. +The two agree for scalar inputs but differ for vectors, so a surviving +mutant means tests only ever pass a single value. +} +\examples{ +membership_operators() +} diff --git a/man/numeric_boundaries.Rd b/man/numeric_boundaries.Rd new file mode 100644 index 0000000..9e4d3ef --- /dev/null +++ b/man/numeric_boundaries.Rd @@ -0,0 +1,18 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/mutator-presets-extra.R +\name{numeric_boundaries} +\alias{numeric_boundaries} +\title{Numeric boundary mutators} +\usage{ +numeric_boundaries() +} +\value{ +A list of mutators. +} +\description{ +Returns \code{\link[=numeric_replace]{numeric_replace()}} mutators that replace each non-zero numeric +literal with \code{0}, \code{-n} and \code{2 * n}. Use alongside \code{\link[=numeric_literals]{numeric_literals()}}. +} +\examples{ +numeric_boundaries() +} diff --git a/man/numeric_replace.Rd b/man/numeric_replace.Rd new file mode 100644 index 0000000..0a505a9 --- /dev/null +++ b/man/numeric_replace.Rd @@ -0,0 +1,25 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/mutator-numeric-types.R +\name{numeric_replace} +\alias{numeric_replace} +\title{Replace numeric literals with boundary values} +\usage{ +numeric_replace(kind = c("zero", "negate", "double")) +} +\arguments{ +\item{kind}{One of \code{"zero"}, \code{"negate"} or \code{"double"}.} +} +\value{ +A \link{Mutator} object. +} +\description{ +Complements \code{\link[=numeric_increment]{numeric_increment()}} and \code{\link[=numeric_decrement]{numeric_decrement()}} by replacing +every non-zero numeric literal \code{n} with a value that probes a different +failure mode: \code{0} (division by zero, empty results), \code{-n} (sign handling) or +\code{2 * n} (scaling errors). Zero literals are never mutated. +} +\examples{ +numeric_replace("zero") +numeric_replace("negate") +numeric_replace("double") +} diff --git a/man/numeric_type_swaps.Rd b/man/numeric_type_swaps.Rd new file mode 100644 index 0000000..d5f3f91 --- /dev/null +++ b/man/numeric_type_swaps.Rd @@ -0,0 +1,19 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/mutator-presets-extra.R +\name{numeric_type_swaps} +\alias{numeric_type_swaps} +\title{Numeric type mutators} +\usage{ +numeric_type_swaps() +} +\value{ +A list of mutators. +} +\description{ +Returns \code{\link[=integer_to_double]{integer_to_double()}} and \code{\link[=double_to_integer]{double_to_integer()}} mutators that flip +the numeric type of literals. Many resulting mutants are equivalent, so use +selectively on code where integer vs double behaviour matters. +} +\examples{ +numeric_type_swaps() +} diff --git a/man/operand_elision.Rd b/man/operand_elision.Rd new file mode 100644 index 0000000..1a12067 --- /dev/null +++ b/man/operand_elision.Rd @@ -0,0 +1,18 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/mutator-presets-extra.R +\name{operand_elision} +\alias{operand_elision} +\title{Operand elision mutators} +\usage{ +operand_elision() +} +\value{ +A list of \code{\link[=keep_operand]{keep_operand()}} mutators. +} +\description{ +Returns \code{\link[=keep_operand]{keep_operand()}} mutators that reduce each \code{&&}, \code{||}, \code{&} and \code{|} +expression to its left or right operand. +} +\examples{ +operand_elision() +} diff --git a/man/remove_else_branch.Rd b/man/remove_else_branch.Rd new file mode 100644 index 0000000..0bee98e --- /dev/null +++ b/man/remove_else_branch.Rd @@ -0,0 +1,18 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/mutator-branch.R +\name{remove_else_branch} +\alias{remove_else_branch} +\title{Remove the else branch of if statements} +\usage{ +remove_else_branch() +} +\value{ +A \link{Mutator} object. +} +\description{ +Turns \code{if (c) a else b} into \code{if (c) a}. Surviving mutants mean no test +exercises the \verb{else} path with an observable result. +} +\examples{ +remove_else_branch() +} diff --git a/man/remove_null_guard.Rd b/man/remove_null_guard.Rd new file mode 100644 index 0000000..88fd9d5 --- /dev/null +++ b/man/remove_null_guard.Rd @@ -0,0 +1,19 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/mutator-branch.R +\name{remove_null_guard} +\alias{remove_null_guard} +\title{Remove \code{is.null()} guard clauses} +\usage{ +remove_null_guard() +} +\value{ +A \link{Mutator} object. +} +\description{ +Replaces an early-return guard such as \code{if (is.null(x)) return(default)} with +\code{NULL}, so the function carries on as if the guard never fired. Only \code{if} +statements without an \verb{else} whose body is a \code{return()} call are targeted. +} +\examples{ +remove_null_guard() +} diff --git a/man/remove_pipe_stage.Rd b/man/remove_pipe_stage.Rd new file mode 100644 index 0000000..a6a9070 --- /dev/null +++ b/man/remove_pipe_stage.Rd @@ -0,0 +1,19 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/mutator-sequence.R +\name{remove_pipe_stage} +\alias{remove_pipe_stage} +\title{Remove one stage from a pipe chain} +\usage{ +remove_pipe_stage() +} +\value{ +A \link{Mutator} object. +} +\description{ +Replaces \code{x |> f()} (and \code{x \%>\% f()}) with \code{x}, dropping the right-hand +stage. In a chain \code{x |> f() |> g()} each stage is removed in turn. Surviving +mutants mean a pipeline step has no observable effect. +} +\examples{ +remove_pipe_stage() +} diff --git a/man/remove_unary_minus.Rd b/man/remove_unary_minus.Rd new file mode 100644 index 0000000..4ec92c9 --- /dev/null +++ b/man/remove_unary_minus.Rd @@ -0,0 +1,19 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/mutator-unary-minus.R +\name{remove_unary_minus} +\alias{remove_unary_minus} +\title{Remove unary minus} +\usage{ +remove_unary_minus() +} +\value{ +A \link{Mutator} object. +} +\description{ +Strips the leading \code{-} from a negated expression, so \code{-x} becomes \code{x} and +\code{-(a + b)} becomes \code{(a + b)}. Catches tests that never check the sign of a +result. +} +\examples{ +remove_unary_minus() +} diff --git a/man/remove_validation.Rd b/man/remove_validation.Rd new file mode 100644 index 0000000..f19d278 --- /dev/null +++ b/man/remove_validation.Rd @@ -0,0 +1,31 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/mutator-arguments.R +\name{remove_validation} +\alias{remove_validation} +\title{Remove validation calls} +\usage{ +remove_validation( + pattern = "^(stop|stopifnot|warning|abort|cli_abort|cli_warn|assert_[A-Za-z0-9_]+)$" +) +} +\arguments{ +\item{pattern}{Regular expression matched against the function name.} +} +\value{ +A \link{Mutator} object. +} +\description{ +Replaces calls that enforce preconditions with \code{NULL}, so +\code{if (!is.numeric(x)) stop("bad")} becomes \code{if (!is.numeric(x)) NULL}. +Surviving mutants mean no test checks that invalid input is rejected. By +default targets \code{stop()}, \code{stopifnot()}, \code{warning()}, \code{rlang::abort()}, +\code{cli::cli_abort()}, \code{cli::cli_warn()} and checkmate-style \verb{assert_*()} calls. +} +\details{ +Unlike \code{\link[=delete_statement]{delete_statement()}}, the call is replaced rather than deleted, so +calls used as the body of an \code{if} stay syntactically valid. +} +\examples{ +remove_validation() +remove_validation("^stopifnot$") +} diff --git a/man/replace_call_result.Rd b/man/replace_call_result.Rd new file mode 100644 index 0000000..cbd2989 --- /dev/null +++ b/man/replace_call_result.Rd @@ -0,0 +1,24 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/mutator-implicit-return.R +\name{replace_call_result} +\alias{replace_call_result} +\title{Replace the result of function calls} +\usage{ +replace_call_result(replacement = "NULL") +} +\arguments{ +\item{replacement}{Raw R source text to substitute for the call.} +} +\value{ +A \link{Mutator} object. +} +\description{ +Replaces every function call with a fixed value (default \code{"NULL"}), e.g. +\code{y <- compute(x)} becomes \code{y <- NULL}. Surviving mutants mean a call's +result is never checked. Calls that are assignment targets +(\code{names(x) <- v}) and \code{return()} calls are left alone. +} +\examples{ +replace_call_result() +replace_call_result("0") +} diff --git a/man/replace_implicit_return.Rd b/man/replace_implicit_return.Rd new file mode 100644 index 0000000..3242d6e --- /dev/null +++ b/man/replace_implicit_return.Rd @@ -0,0 +1,25 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/mutator-implicit-return.R +\name{replace_implicit_return} +\alias{replace_implicit_return} +\title{Replace the implicit return value of functions} +\usage{ +replace_implicit_return(replacement = "NULL") +} +\arguments{ +\item{replacement}{Raw R source text to substitute as the value.} +} +\value{ +A \link{Mutator} object. +} +\description{ +Replaces the last expression of every function body (the implicit return +value) with a fixed value (default \code{"NULL"}). Complements +\code{\link[=replace_return_value]{replace_return_value()}}, which only handles explicit \code{return()} calls; most +idiomatic R relies on implicit returns. Functions whose last expression is +already a \code{return()} call are skipped. +} +\examples{ +replace_implicit_return() +replace_implicit_return("NA") +} diff --git a/man/reverse_arguments.Rd b/man/reverse_arguments.Rd new file mode 100644 index 0000000..9849f2f --- /dev/null +++ b/man/reverse_arguments.Rd @@ -0,0 +1,24 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/mutator-arguments.R +\name{reverse_arguments} +\alias{reverse_arguments} +\title{Reverse the arguments of a call} +\usage{ +reverse_arguments(functions = "c") +} +\arguments{ +\item{functions}{Character vector of function names to target (without +\verb{pkg::}). Defaults to \code{"c"}; \code{NULL} targets every call.} +} +\value{ +A \link{Mutator} object. +} +\description{ +Reverses the argument order of each call with two or more arguments, so +\code{c(a, b)} becomes \code{c(b, a)}. Surviving mutants mean no test depends on the +order of the elements. +} +\examples{ +reverse_arguments() +reverse_arguments(c("c", "paste")) +} diff --git a/man/seq_along_drop_first.Rd b/man/seq_along_drop_first.Rd new file mode 100644 index 0000000..eb63f31 --- /dev/null +++ b/man/seq_along_drop_first.Rd @@ -0,0 +1,18 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/mutator-sequence.R +\name{seq_along_drop_first} +\alias{seq_along_drop_first} +\title{Skip the first element of \code{seq_along()}} +\usage{ +seq_along_drop_first() +} +\value{ +A \link{Mutator} object. +} +\description{ +Replaces \code{seq_along(x)} with \code{seq_along(x)[-1L]}, catching loops that +silently depend on processing the first element. +} +\examples{ +seq_along_drop_first() +} diff --git a/man/seq_len_decrement.Rd b/man/seq_len_decrement.Rd new file mode 100644 index 0000000..4acf23d --- /dev/null +++ b/man/seq_len_decrement.Rd @@ -0,0 +1,18 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/mutator-sequence.R +\name{seq_len_decrement} +\alias{seq_len_decrement} +\title{Shrink \code{seq_len()} by one} +\usage{ +seq_len_decrement() +} +\value{ +A \link{Mutator} object. +} +\description{ +Replaces \code{seq_len(n)} with \code{seq_len(n - 1L)}, catching loops that run one +iteration too many or too few. +} +\examples{ +seq_len_decrement() +} diff --git a/man/sequence_mutations.Rd b/man/sequence_mutations.Rd new file mode 100644 index 0000000..2e0d9bd --- /dev/null +++ b/man/sequence_mutations.Rd @@ -0,0 +1,19 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/mutator-presets-extra.R +\name{sequence_mutations} +\alias{sequence_mutations} +\title{Sequence mutators} +\usage{ +sequence_mutations() +} +\value{ +A list of mutators. +} +\description{ +Returns mutators for off-by-one errors in loops and ranges: +\code{\link[=seq_len_decrement]{seq_len_decrement()}}, \code{\link[=seq_along_drop_first]{seq_along_drop_first()}}, \code{\link[=colon_start_increment]{colon_start_increment()}} +and \code{\link[=colon_end_decrement]{colon_end_decrement()}}. +} +\examples{ +sequence_mutations() +} diff --git a/tests/testthat/test-mutator-presets-extra.R b/tests/testthat/test-mutator-presets-extra.R new file mode 100644 index 0000000..07d7433 --- /dev/null +++ b/tests/testthat/test-mutator-presets-extra.R @@ -0,0 +1,48 @@ +expect_preset <- function(preset, ...) { + expect_gt(length(preset), 0) + for (m in preset) expect_s3_class(m, "Mutator") +} + +describe("extra presets", { + it("all return lists of Mutator objects", { + expect_preset(function_swaps()) + expect_preset(membership_operators()) + expect_preset(operand_elision()) + expect_preset(branch_mutations()) + expect_preset(numeric_boundaries()) + expect_preset(numeric_type_swaps()) + expect_preset(sequence_mutations()) + }) +}) + +describe("function_swaps", { + it("swaps in both directions", { + mutations <- unlist(lapply(function_swaps(), function(m) mutate_codes(m, "any(x)")), recursive = FALSE) + expect_equal(mutations, list("all(x)")) + mutations <- unlist(lapply(function_swaps(), function(m) mutate_codes(m, "ncol(x)")), recursive = FALSE) + expect_equal(mutations, list("nrow(x)")) + }) +}) + +describe("branch_mutations", { + it("produces mutants for an if/else", { + n <- sum(vapply( + branch_mutations(), + function(m) length(m$mutate("if (a) b else c")), + integer(1) + )) + # TRUE, FALSE, remove else, empty body + expect_equal(n, 4) + }) +}) + +describe("sequence_mutations", { + it("produces a mutant per applicable mutator for a loop", { + n <- sum(vapply( + sequence_mutations(), + function(m) length(m$mutate("for (i in seq_len(n)) x[i]")), + integer(1) + )) + expect_equal(n, 1) + }) +}) From 608f8b9e8cd02b0cd59aae1ecff9282ad9f4ae11 Mon Sep 17 00:00:00 2001 From: Ben Mordue Date: Fri, 2 Oct 2026 15:46:23 +0100 Subject: [PATCH 5/5] feat: add all_mutators() convenience function --- NAMESPACE | 1 + NEWS.md | 1 + R/mutator-all.R | 57 +++++++++++++++++++++++++++++++ _pkgdown.yml | 1 + man/all_mutators.Rd | 34 ++++++++++++++++++ tests/testthat/test-mutator-all.R | 22 ++++++++++++ 6 files changed, 116 insertions(+) create mode 100644 R/mutator-all.R create mode 100644 man/all_mutators.Rd create mode 100644 tests/testthat/test-mutator-all.R diff --git a/NAMESPACE b/NAMESPACE index 3b5f1aa..7f0105d 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -12,6 +12,7 @@ export(Mutator) export(PackageCopyStrategy) export(ProgressMutationReporter) export(TestStrategy) +export(all_mutators) export(arithmetic_operators) export(boolean_literal) export(boolean_literals) diff --git a/NEWS.md b/NEWS.md index ca9023c..6482e28 100644 --- a/NEWS.md +++ b/NEWS.md @@ -5,6 +5,7 @@ - Control flow: `condition_constant()`, `remove_else_branch()`, `empty_if_body()`, `remove_null_guard()`, `keep_operand()`, `replace_implicit_return()`. - Literals and sequences: `remove_unary_minus()`, `integer_to_double()`, `double_to_integer()`, `numeric_replace()`, `seq_len_decrement()`, `seq_along_drop_first()`, `colon_start_increment()`, `colon_end_decrement()`. - ✨ New presets: `function_swaps()` (`any`/`all`, `min`/`max`, `nrow`/`ncol`, ...), `membership_operators()`, `operand_elision()`, `branch_mutations()`, `numeric_boundaries()`, `numeric_type_swaps()` and `sequence_mutations()`. +- ✨ New `all_mutators()` returns a flat list of every mutator with default arguments. # muttest 0.3.0 diff --git a/R/mutator-all.R b/R/mutator-all.R new file mode 100644 index 0000000..4f30818 --- /dev/null +++ b/R/mutator-all.R @@ -0,0 +1,57 @@ +#' All mutators +#' +#' Returns a flat list of every mutator muttest ships, built with default +#' arguments: all the presets plus the standalone mutators not covered by one. +#' Handy for a first exploratory run; expect a large number of mutants, so +#' prefer scoping a plan to specific presets for routine use. +#' +#' Mutators that overlap are not duplicated: [remove_negation()] is included +#' instead of [remove_condition_negation()], and `while` conditions are not +#' forced to a constant (see [condition_constant()]). [drop_argument()] is +#' unscoped, so it targets every call. +#' +#' @return A list of [Mutator] objects. +#' @export +#' @examples +#' length(all_mutators()) +#' +#' \dontrun{ +#' plan <- muttest_plan( +#' source_files = "R/stats.R", +#' mutators = all_mutators() +#' ) +#' muttest(plan, "tests/testthat") +#' } +all_mutators <- function() { + c( + arithmetic_operators(), + comparison_operators(), + logical_operators(), + membership_operators(), + operand_elision(), + boolean_literals(), + na_literals(), + numeric_literals(), + numeric_boundaries(), + numeric_type_swaps(), + string_literals(), + index_mutations(), + sequence_mutations(), + branch_mutations(), + function_swaps(), + list( + negate_condition(), + remove_negation(), + remove_unary_minus(), + replace_return_value(), + replace_implicit_return(), + replace_call_result(), + delete_statement(), + drop_argument(), + reverse_arguments(), + remove_validation(), + flip_named_flag(), + remove_pipe_stage() + ) + ) +} diff --git a/_pkgdown.yml b/_pkgdown.yml index 72d58e4..89845c4 100644 --- a/_pkgdown.yml +++ b/_pkgdown.yml @@ -114,6 +114,7 @@ reference: - numeric_boundaries - numeric_type_swaps - sequence_mutations + - all_mutators - title: Reporters desc: Control how results are displayed. contents: diff --git a/man/all_mutators.Rd b/man/all_mutators.Rd new file mode 100644 index 0000000..0571264 --- /dev/null +++ b/man/all_mutators.Rd @@ -0,0 +1,34 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/mutator-all.R +\name{all_mutators} +\alias{all_mutators} +\title{All mutators} +\usage{ +all_mutators() +} +\value{ +A list of \link{Mutator} objects. +} +\description{ +Returns a flat list of every mutator muttest ships, built with default +arguments: all the presets plus the standalone mutators not covered by one. +Handy for a first exploratory run; expect a large number of mutants, so +prefer scoping a plan to specific presets for routine use. +} +\details{ +Mutators that overlap are not duplicated: \code{\link[=remove_negation]{remove_negation()}} is included +instead of \code{\link[=remove_condition_negation]{remove_condition_negation()}}, and \code{while} conditions are not +forced to a constant (see \code{\link[=condition_constant]{condition_constant()}}). \code{\link[=drop_argument]{drop_argument()}} is +unscoped, so it targets every call. +} +\examples{ +length(all_mutators()) + +\dontrun{ +plan <- muttest_plan( + source_files = "R/stats.R", + mutators = all_mutators() +) +muttest(plan, "tests/testthat") +} +} diff --git a/tests/testthat/test-mutator-all.R b/tests/testthat/test-mutator-all.R new file mode 100644 index 0000000..6783094 --- /dev/null +++ b/tests/testthat/test-mutator-all.R @@ -0,0 +1,22 @@ +describe("all_mutators", { + it("returns a flat list of Mutator objects", { + mutators <- all_mutators() + expect_gt(length(mutators), 50) + for (m in mutators) expect_s3_class(m, "Mutator") + }) + + it("includes mutators from presets and standalone constructors", { + froms <- vapply(all_mutators(), function(m) m$from, character(1)) + expect_true(all(c("+", "L", "stop(...)", "x |> f()", "!") %in% froms)) + }) + + it("does not include the condition-scoped negation removal twice", { + froms <- vapply(all_mutators(), function(m) m$from, character(1)) + expect_false("!" %in% froms) + }) + + it("every mutator can be applied to code without error", { + code <- c("f <- function(x, n = 2L) {", " if (is.null(x)) return(1)", " for (i in seq_len(n)) x <- -x[i] + 1", " x", "}") + for (m in all_mutators()) expect_no_error(m$mutate(code)) + }) +})