--- title: "Configuration" output: rmarkdown::html_vignette vignette: > %\VignetteIndexEntry{Configuration} %\VignetteEngine{knitr::rmarkdown} %\VignetteEncoding{UTF-8} --- ```{r, include = FALSE} knitr::opts_chunk$set( collapse = TRUE, comment = "#>" ) ``` This vignette documents the options that control how `mutate_package()` runs: which test strategy is used, how mutant timeouts are calibrated, which tests are selected, how parallel workers are isolated, how to exclude code from mutation, and how the optional coverage-guided, precise-location, and equivalent-mutant-detection features behave. See `?mutate_package` for the full argument and return-value documentation. ## Mutation testing modes (`strategy`) mutator selects a package test strategy automatically: - If `tests/testthat/` exists, mutator loads the mutant in-process with `pkgload::load_all()` and mirrors the package's own `tests/testthat.R` harness by forwarding extractable arguments (notably any `filter`) from `testthat::test_check()` to `testthat::test_dir()`, without paying for an install per mutant. - Otherwise, if `inst/tinytest/` exists, mutator loads the mutant in-process with `pkgload::load_all()` and runs `tinytest::run_test_dir("inst/tinytest")`, also without an install per mutant. - Otherwise, if `tests/` exists, mutator falls back to `tools::testInstalledPackage(..., types = "tests")` after installing each mutant with `--install-tests`. Both in-process strategies use `pkgload::load_all()`, which in some cases does not load a package the way an installed copy would (see [pkgload issue #340](https://github.com/r-lib/pkgload/issues/340)). A tinytest package affected by this can be run against an installed copy with `strategy = "tinytest-installed"` (`R CMD INSTALL` plus `tinytest::test_package()`), which is slower but matches an installed package and still supports coverage-guided selection. Pass `strategy` to `mutate_package()` to override auto-detection: ```r mutate_package("path/to/pkg", strategy = "tinytest-installed") ``` ## Timeouts and contention in parallel mode Each mutant is run with a wall-clock timeout; exceeding it is reported as `HANG`. The timeout is derived from how long the package's own test suite takes, unless you pass `timeout_seconds` explicitly. Because mutants run in parallel (`cores` at a time), the timeout must account for **contention**: with many workers, each test run is slower than it would be alone for packages that load many dependencies, or do heavy per-mutant install work. A timeout based on a single, _uncontended_ baseline run would then fire on nearly every mutant (leading to numerous false `HANG`s). To avoid this, `mutate_package()` first runs the baseline suite once on its own (to confirm it passes and fail fast otherwise), then runs it `cores` times **concurrently** and takes the slowest of those as the _contended baseline_. The timeout is `max(contended_baseline * 1.5, 5s)`. This self-calibrates to the machine, the chosen parallelism, and the package's real load/compile cost, avoiding manual tuning. Pass `timeout_seconds` to override it entirely. When `cores = 1`, or when forking is unavailable, the timing for the baseline suite is used. ## CRAN mode (test selection) By default `mutate_package()` runs each mutant's tests in **CRAN mode** (`cran = TRUE`): the `NOT_CRAN` environment variable is set to `"false"` in the test subprocess, so `testthat::skip_on_cran()` and `skip_if_offline()` guards take effect and mutator runs the same tests CRAN would. This skips the slow, flaky, or network-dependent tests that packages mark as CRAN-skippable, which keeps mutation runs fast and avoids spurious timeouts/kills from, e.g., tests that hit the network. Set `cran = FALSE` to run the **full** suite instead (`NOT_CRAN = "true"`, which is what happens by default when running `devtools::test()`): ```r mutate_package("path/to/pkg", cran = FALSE) ``` This applies to every test strategy (`testthat`, `tinytest`, `tinytest-installed`, and the installed-tests fallback; the tinytest strategies map it to `run_test_dir(at_home = )`). Note that it only affects tests the package _explicitly_ guards with `skip_on_cran()` / `skip_if_offline()` (or `at_home()` for tinytest); a package whose network test has no such guard will still run that test in either mode. ## Fail-fast (stop at the first failing test) A mutant is `KILLED` the instant any of the tests detects it, so running the rest of its suite is wasted work. By default (`fail_fast = TRUE`), each mutant's test run **stops at the first failing test** instead of finishing the suite, which speeds up the test-running phase (often substantially for packages with large suites) without changing any mutant's verdict. Set `fail_fast = FALSE` to run the full suite for every mutant: ```r mutate_package("path/to/pkg", fail_fast = FALSE) ``` This applies to the `testthat` strategy: it sets `TESTTHAT_MAX_FAILS = 1` in the test subprocess and uses the progress reporter, which aborts the run at the first failing `test_that()` block. `SURVIVED` mutants are unaffected, as they have no failure to short-circuit on, so baseline timing and timeout calibration are unchanged. The `tinytest` strategies run the full set of selected test files (a mutant is still `KILLED` on the first failing file), and the installed-tests fallback already stops at the first failing test _file_ regardless of this flag. ## Parallel execution and isolation (`isolate`) By default, each mutant package copy _symlinks_ the unchanged directories of the original package and only the mutated `R/` file is materialised, so all parallel workers point at the same physical `src/`, `tests/`, etc. This is fast, and two design choices keep it correct: - **Compiled code is built once, not per mutant.** For the in-process strategies (`testthat` and `tinytest`), `pkgload::load_all()` reuses the baseline's compiled `src/` since C code is never mutated. The `installed` and `tinytest-installed` strategies compile once into a _template_ library and install each mutant with `--no-libs`, which never writes into `src/`. This prevents parallel workers from fighting over the same compiled objects. - **Snapshot references are not shared.** For `testthat` packages with `_snaps` directories, mutator gives each mutant its own snapshot copy while symlinking the rest of `tests/`, so filtered or parallel runs cannot rewrite shared snapshot references. The one remaining hazard is **non-hermetic tests that write files** into a shared directory (most often `tests/`). When parallel workers' tests fight over the same files you can still see spurious `KILLED`/`HANG`. Two ways to attenuate it: 1. **Run without parallelism:** `cores = 1`. No contention, no extra disk, but the slowest option. 2. **Isolate file state:** `isolate = TRUE`. Each mutant gets its own deep copy of `src/` and `tests/` instead of a symlink, so file-writing tests can't collide: ```r mutate_package("path/to/pkg", isolate = TRUE) ``` The default (`isolate = FALSE`) is fast and correct for hermetic test suites; reach for `isolate = TRUE` (or `cores = 1`) only when a package's tests are not hermetic and you see parallel-only `KILLED`/`HANG` results. ### Progress bar When the optional [`pbmcapply`](https://cran.r-project.org/package=pbmcapply) package is installed, the multi-core test run shows a live progress bar. ```r install.packages("pbmcapply") ``` ## Excluding code from mutation Not all code should be mutation-tested. Vendored/standalone files, generated code, or deprecated paths a suite is not meant to cover will mostly produce `SURVIVED` mutants and depress the score without telling you anything useful. There are a few ways to exclude code. **1. By file, at the call site, with `exclude_files`.** A character vector of shell-style glob patterns matched against the base names of the `.R` files in `R/`. Matching files are skipped entirely before any mutants are generated: ```r mutate_package("path/to/scales", exclude_files = c("import-standalone-*")) ``` **2. In the source, with `# mutator:ignore` directives.** Place markers in the `.R` file itself: - `# mutator:ignore-file` anywhere in a file excludes the **whole file**. - `# mutator:ignore-start` / `# mutator:ignore-end` exclude the **line region** between them (wrap a function to exclude it): ```r # mutator:ignore-start legacy_helper <- function(x) { # not worth mutation-testing x * 2 } # mutator:ignore-end ``` An unmatched `-start` excludes through the end of the file. **3. With covr's `# nocov` annotations.** mutator also honours [covr](https://covr.r-lib.org/)'s coverage-exclusion comments, so code you have already marked as untested-by-design needs no separate mutator directive: - `# nocov start` / `# nocov end` exclude the **line region** between them. - A bare `# nocov` excludes its **own line**, and may trail code: ```r if (impossible_state) { stop("unreachable") # nocov } ``` **4. With a covr `.covrignore` file.** If the package root has a `.covrignore` (covr's file-level coverage-exclusion list), mutator reads it too: each line is a glob expanded relative to the package root (a matched directory expands to the files under it), and matching `R/` files are skipped before generation, using the same mechanism covr uses. So files you already exclude from coverage need no extra mutator configuration. **Granularity.** Excluding whole _files_ and whole _functions_ is reliable. More fine-grained than that is not, for operator mutations: R mostly only attaches source references to blocks `{}`, so the engine resolves an operator mutant's position only to its enclosing block. A region directive inside a function therefore excludes that function's operator mutants as a group: you cannot single out one operator mid-function. Line-deletion mutants _are_ excluded line-precisely. In practice, wrap whole functions, not fragments. This only affects the surviving mutant output, which shows a larger context than necessary, and prevents the coverage-guided test selection from being as precise as it could be. The granularity can be improved with the optional [`imputesrcref`](#imputesrcref) package. ## Coverage-guided test selection (`coverage_guided`) Most mutants are settled by a small subset of the suite, and a mutant on a line that **no test exercises** can never be killed. With `coverage_guided = TRUE` (the default) mutator measures coverage once with [covr](https://covr.r-lib.org/) and then, for each mutant, runs only the test files that cover its mutated line, and skips running tests altogether for mutants on uncovered lines (reported `SURVIVED` immediately). Coverage guidance applies to the `testthat`, `tinytest`, and `tinytest-installed` strategies. covr builds the coverage-to-tests map against an instrumented, installed copy of the package; per test file, it maps each covered source line to the test files that reach it (for tinytest, by running each `inst/tinytest` file individually). When the resolved strategy is the generic installed-tests fallback, mutator cannot attribute coverage to test files, so it emits a warning and runs the full suite for every mutant. Pass `coverage_guided = FALSE` to disable the optimisation (and that warning). ```r # On by default; pass FALSE to run the full suite for every mutant. mutate_package("path/to/pkg", coverage_guided = FALSE) ``` The single coverage run also serves as the baseline check (it runs the package's own `tests/testthat.R` harness, which fails if any test fails), so the suite is not run twice. Selection is at the **test-file** level, as testthat filters tests by file, and assumes the suite deterministically exercises the code, so it changes _which_ tests run without changing a mutant's verdict. Requires the `covr` package. Coverage attribution (and therefore speed-up) depends on the backend (`coverage_backend`): - **`"record_tests"`** (default) uses covr's `record_tests` in a single run and relies only on covr's public output. Its limitation: covr credits a covered line to the _deepest test-directory frame_ on the call stack, so when a test reaches package code through a function defined in a `helper-*.R` / `setup-*.R` file (a common pattern), covr attributes it to the helper, not the originating `test-*.R`. The true triggering test is then unknown, so mutator conservatively runs the **full suite** for that mutant. Packages that wrap their API in shared test helpers therefore see less speed-up. - **`"per_file"`** instruments the package once and runs the suite a single time through a reporter that snapshots coverage per test file, giving **exact file-level attribution** with no helper fallback, at roughly the same cost as the single `record_tests` run (often faster overall, since more mutants get a narrowed test set). It reaches into covr internals, so it is opt-in: ```r mutate_package("path/to/pkg", coverage_guided = TRUE, coverage_backend = "per_file") ``` Either way the pay-off is largest when the suite is big, many lines are uncovered, and tests exercise the code directly. ## Precise mutant locations (optional `imputesrcref`) {#imputesrcref} Each reported mutant carries a source `Range:` (`start:col-end:col`). For statement- and line-deletion mutants this is precise, but **operator mutants** (`+`, `<`, `&&`, …) are different: R attaches no `srcref` to nested call objects, so the engine can only report the bounds of the _enclosing block_ (`{}`), effectively a loop body or even the whole function. A surviving `==`-to-`!=` mutant in a 40-line function therefore points at all 40 lines. If the optional [`imputesrcref`](https://github.com/PRL-PRG/imputesrcref) package is installed, `mutator` uses it to recover precise spans for many operator mutants, typically narrowing a whole-function range down to the exact sub-expression on a single line. It is a GitHub-only optional package listed in `Enhances`; install it yourself to opt in: ```r # install.packages("remotes") remotes::install_github("PRL-PRG/imputesrcref") ``` When it is **not** installed, `mutator` behaves exactly as before (coarser operator ranges); nothing else changes. The refinement is used only as a read-only source-location oracle: mutant files are deparsed from the original code and are **byte-for-byte identical** whether or not `imputesrcref` is present, and `# mutator:ignore-*` directives keep their function-granular semantics regardless. For best results, install the package under test from source with parse data retained, since `imputesrcref` reads it: ```r install.packages("", INSTALL_opts = c("--with-keep.source", "--with-keep.parse.data")) ``` ## Equivalent Mutant Detection Equivalent-mutant detection calls an OpenAI-compatible Chat Completions API. Configure it in any of these ways (listed highest precedence first; each setting is resolved independently, so they can be mixed): 1. **Programmatically**, in your R session: ```r set_openai_config( api_key = "api-key...", model = "gpt-4", base_url = "https://api.openai.com/v1" # any OpenAI-compatible endpoint ) ``` 2. **A `.openai_config` file** in the working directory (or in a directory you pass via `get_openai_config(dir = ...)`). It is a plain, human-readable file of `field: value` lines and is _parsed, never executed_: ``` api_key: api-key... model: gpt-4 base_url: https://api.openai.com/v1 ``` Only the given directory is consulted. Parent directories are not searched. 3. **Environment variables** `OPENAI_API_KEY`, `OPENAI_MODEL` and `OPENAI_BASE_URL`. If nothing is configured, the model defaults to `gpt-4` and the base URL to the public OpenAI API. Set `base_url` to target a self-hosted or alternative OpenAI-compatible service (for example `http://localhost:11434/v1`). Enable the analysis when running package mutation tests: ```r mutate_package("path/to/pkg", detectEqMutants = TRUE) ``` Equivalence detection runs **before** the test suites. Because an equivalent mutant is behaviorally identical to the original, no test can kill it, so running its test suite is wasted work. `mutate_package()` therefore analyzes every generated mutant up front and **skips the test run** for those judged equivalent: they are recorded as survived directly. Mutants judged `NOT_EQUIVALENT` or `DONT_KNOW` are tested as usual. The trade-off is that the equivalence pass now covers all mutants, not only survivors, so it makes more API calls; in exchange it avoids the far more expensive test runs on equivalent mutants. Mutants are analyzed in **bounded batches** (default 25 per request), and each mutant is shown to the model as a small **unified diff** of its edit (plus a short change label) rather than its full mutated source. This is compact, unambiguous, and in a format LLMs read natively. When `mutate_package()` runs with `cores > 1` the batches are sent **concurrently**, which (with the bounded size) keeps the equivalence pass fast and avoids the truncated responses that otherwise drop verdicts. For mutants the model flags as **EQUIVALENT** (they are excluded from the adjusted mutation score), it also returns a one-sentence **reason**, stored as `equivalence_reason` on the mutant so the call can be audited. No reason is requested for `NOT_EQUIVALENT`/`DONT_KNOW`, keeping responses small. Progress and the results summary are emitted via `message()` (so they can be silenced with `suppressMessages()`). The full prompts and model responses are not printed by default; set `options(mutator.verbose = TRUE)` to log them.