mutscan
analysisProcess deep mutational scanning and other MAVE sequencing with the mutscan R package — FASTQ to per-variant counts with an audited filter cascade, then edgeR or limma enrichment scored against wild type, plus QC plots.
mutscan
Overview
mutscan is a Bioconductor package that takes a multiplexed assay of variant effect (MAVE) — a deep mutational scan, a protein–protein interaction screen, any library of variants sequenced before and after selection — from raw FASTQ to a statistical answer. It is described in Soneson, Bendel, Diss and Stadler, mutscan — a flexible R package for efficient end-to-end analysis of multiplexed assays of variant effect data, Genome Biology 24:132 (2023), doi:10.1186/s13059-023-02967-0.
The pipeline has three stages, and each hands a well-defined object to the next:
| stage | function | gives you |
|---|---|---|
| read processing | digestFastqs() |
per-sample list: variant table, filter log, error statistics |
| assembly | summarizeExperiment() |
a SummarizedExperiment, variants × samples |
| scoring | calculateRelativeFC() / calculateFitnessScore() |
per-variant logFC and p-values, or fitness ratios |
Reach for it when you have the reads and want counts and a defensible model in one place. The read layout is described with a small grammar rather than a fixed library format, so the same function handles single variable regions, paired reads that overlap and must be merged, paired reads covering two different proteins, UMIs, and primer-anchored designs. Scoring is a wrapper over edgeR and limma, so the statistics are the ones those packages are known for.
How it sits next to neighbours. The registry's enrich2 skill scores counts you already
have with Enrich2's weighted regression across time points; it is the better fit for a
selection sampled at three or more time points. mutscan is built around an input-versus-
output comparison with replicates, and it also does the counting. DiMSum is another
FASTQ-to-fitness pipeline for the same experiments, with its own error model; it is a
separate tool and is not covered here. If the variants were screened one per well rather
than pooled — a plate-format directed evolution round — the levseq skill covers turning
that plate's reads into a per-well variant table.
What a reader needs before starting
- Licence: MIT, copyright the Friedrich Miescher Institute for Biomedical Research.
No account, key, GPU or container. CPU only;
digestFastqs()takesnThreads. - GitHub reports the repository's licence as
NOASSERTION. That is a detector artefact, not a missing licence. R packages declareLicense: MIT + file LICENSEinDESCRIPTION, and theLICENSEfile that convention requires holds only two lines,YEARandCOPYRIGHT HOLDER. GitHub's detector reads that stub, cannot match it, and gives up; the full MIT text sits next to it inLICENSE.md. Read the files rather than trusting an automated licence field — in this domain it errs in both directions. - R version decides which mutscan you get. The current Bioconductor release (3.23)
ships mutscan 1.2.0 and needs R 4.6. On R 4.5,
BiocManagerresolves to Bioconductor 3.22 and installs mutscan 1.0.0. On R 4.4 or older there is no mutscan at all: it entered Bioconductor in 3.22, and no earlier Bioconductor repository carries it. The two releases differ in one place that matters here: 1.2.0 adaptedcalculateRelativeFC()to newer edgeR and limma. - A C++ toolchain whenever the package builds from source (Rcpp). Bioconductor ships macOS and Windows binaries for R installed from CRAN; a conda-provided R always builds from source.
Install — R 4.6 from CRAN
if (!requireNamespace("BiocManager", quietly = TRUE)) install.packages("BiocManager")
BiocManager::install("mutscan")
packageVersion("mutscan") # 1.2.0 on Bioconductor 3.23
Install — isolated conda environment
As of 2026-10-20 conda-forge and bioconda build R packages for R 4.5 but not yet for 4.6,
so this route pairs R 4.5 with bioconda's Bioconductor builds and installs mutscan 1.2.0
itself from the head of the upstream RELEASE_3_23 branch, pinned by commit. It runs as an ordinary user, needs no root, and keeps everything in
./mutscan-env.
conda create -y -p ./mutscan-env -c conda-forge -c bioconda --override-channels \
r-base=4.5 compilers make r-biocmanager r-rcpp r-dplyr r-tidyr r-tibble r-rlang \
r-ggplot2 r-ggally r-ggrepel r-dt r-rmarkdown r-xfun \
bioconductor-summarizedexperiment bioconductor-edger bioconductor-limma \
bioconductor-iranges bioconductor-delayedarray
# csaw is compiled from source on every platform; the whole block took 9-10 minutes
# on osx-arm64 (measured), most of it compiling. mutscan 1.2.0 is pinned to the
# RELEASE_3_23 branch head of the upstream repository.
# Call the environment's own Rscript by path: if the environment is missing, a bare
# `Rscript` would quietly fall through to whatever R is on PATH and install there.
conda run -p ./mutscan-env ./mutscan-env/bin/Rscript -e '
BiocManager::install("csaw", update = FALSE, ask = FALSE)
install.packages("https://github.com/fmicompbio/mutscan/archive/6aa85729d6ab31b4227c979a742e96f6d33dae62.tar.gz",
repos = NULL, type = "source")
stopifnot(packageVersion("mutscan") == "1.2.0")
cat("mutscan", format(packageVersion("mutscan")), "on", R.version.string, "\n")'
Run every later block with that environment's R, the same way:
conda run -p ./mutscan-env ./mutscan-env/bin/Rscript script.R.
Describing the reads
digestFastqs() is told what each read contains through two arguments per direction: a
string of element codes and a vector of their lengths, read left to right.
| code | element | what happens to it |
|---|---|---|
S |
skip | discarded |
U |
UMI | used to count unique molecules |
C |
constant | compared with constant* to estimate the sequencing error rate |
V |
variable | the region whose variants are counted |
P |
primer | located by sequence anywhere in the read; anchors what follows |
A length of -1 means "whatever is left". A read built as one skipped base, a 10-nt UMI,
an 18-nt constant stretch and a 96-nt variable region is "SUCV" with c(1, 10, 18, 96).
Repeat a code to describe two separated stretches of the same kind; mutscan joins them.
Three choices shape what a "variant" means downstream:
- Wild type given, named. Each read is aligned to the closest supplied sequence and
named by its mutated codons, for instance
FOS.0.WT_JUN.13.CCC— FOS unmutated, codon 13 of JUN changed to CCC. Pass a named vector; an unnamed one yields the stand-in prefixesfandr. - Wild type omitted for a direction. That direction is not aligned or mutation-filtered at all: its variable sequence becomes part of the name verbatim. This is the right mode for a random barcode and the wrong one for a mutated gene.
mergeForwardReverse = TRUE. For two reads covering the same amplicon. SetrevComplReverse = TRUEtoo, so the reverse read is in the forward orientation before the overlap is searched; supply a single wild type throughwildTypeForward.
The mutation limits apply per read direction. With the default
nbrMutatedCodonsMaxForward = 1 and nbrMutatedCodonsMaxReverse = 1, a pair carrying one
mutation in each protein survives as a double mutant. Raise or lower each side
deliberately.
Counting: digestFastqs()
The example files below ship inside the package. They are 1,000-read subsamples of the FOS–JUN interaction screen of Diss and Lehner, eLife 2018 (GEO GSE102901): the forward read covers mutated FOS, the reverse read mutated JUN.
suppressPackageStartupMessages({ library(mutscan); library(SummarizedExperiment) })
ext <- system.file("extdata", package = "mutscan")
FOS <- "ACTGATACACTCCAAGCGGAGACAGACCAACTAGAAGATGAGAAGTCTGCTTTGCAGACCGAGATTGCCAACCTGCTGAAGGAGAAGGAAAAACTA"
JUN <- "ATCGCCCGGCTGGAGGAAAAAGTGAAAACCTTGAAAGCTCAGAACTCGGAGCTGGCGTCCACGGCCAACATGCTCAGGGAACAGGTGGCACAGCTT"
digest_trans <- function(sample) digestFastqs(
fastqForward = file.path(ext, paste0(sample, "_1.fastq.gz")),
fastqReverse = file.path(ext, paste0(sample, "_2.fastq.gz")),
adapterForward = "GGAAGAGCACACGTC", adapterReverse = "GGAAGAGCGTCGTGT",
elementsForward = "SUCV", elementLengthsForward = c(1, 10, 18, 96),
elementsReverse = "SUCV", elementLengthsReverse = c(1, 8, 20, 96),
constantForward = "AACCGGAGGAGGGAGCTG", constantReverse = "GAAAAAGGAAGCTGGAGAGA",
wildTypeForward = c(FOS = FOS), wildTypeReverse = c(JUN = JUN),
forbiddenMutatedCodonsForward = "NNW", forbiddenMutatedCodonsReverse = "NNW",
nThreads = 1)
inp <- digest_trans("transInput")
out <- digest_trans("transOutput")
names(inp)
head(inp$summaryTable[, c("mutantName", "nbrReads", "nbrUmis", "mutantNameAAHGVS")], 3)
forbiddenMutatedCodons takes IUPAC codes. "NNW" drops any read whose mutated codon
ends in A or T, which is right for an NNS library like this one (third base G or C), where
such codons can only be errors. It is wrong for an NNK library (third base G or T): it
throws away every legitimate T-ending codon. The NNK filter is "NNM". Applying "NNM" to
these NNS reads makes the point — it charges 249 of 1,000 reads to the forbidden-codon
filter instead of 8. Pass "" when the library was not built from degenerate codons.
Each result is a list of four: parameters (every argument, plus version and timing),
summaryTable (one row per distinct variant, with read and UMI counts, codon, amino-acid
and HGVS names, and a mutationTypes column), filterSummary, and errorStatistics
(matches and mismatches in the constant region by base quality).
Read the filter log before anything else
Filters run in a fixed order and each read is charged to the first one it fails. So
the filter columns plus nbrRetained always add back to nbrTotal, and a single
dominant column tells you what went wrong.
fs <- inp$filterSummary
steps <- grep("^f[0-9]", names(fs), value = TRUE)
stopifnot(sum(fs[steps]) + fs$nbrRetained == fs$nbrTotal)
t(fs[, c("nbrTotal", steps[fs[steps] > 0], "nbrRetained")])
The failure worth knowing is the quiet one. If the element lengths do not add up to the
read length, digestFastqs() does not raise an error — every read lands in
f3_nbrReadWrongLength, nbrRetained is 0, and the run "succeeds". Check that column on
every new design. A mismatched number of codes and lengths, by contrast, does stop
immediately.
The adapter filter matters more than it looks. With the adapters omitted, the reads that carried them are not dropped at step 1; they reach the mutation filters, look heavily mutated, and are discarded there instead — so the retained count barely moves while the log now blames the wrong step.
Leave a large job's chunking alone unless memory says otherwise (chunkSize, default
100,000 reads), and pass several FASTQ files as a vector to fastqForward /
fastqReverse — in the same order — to treat a sample split across lanes as one.
Assembling samples: summarizeExperiment()
se <- summarizeExperiment(
x = list(input = inp, output = out),
coldata = data.frame(Name = c("input", "output"),
Condition = c("input", "output")),
countType = "umis")
dim(se)
colSums(assay(se, "counts"))
Two rules are enforced with a clear error, and one thing happens without being asked:
xmust be a named list, and its names must matchcoldata$Name.countType = "umis"is the default. If the read layout had noUelement, ask for"reads"; mutscan refuses to count UMIs it never saw.- The filter log of every sample is copied into
colData, so the QC plots below can draw it.
Two collapsing helpers then reduce the rows:
collapseMutantsByAA(se)merges codons that encode the same protein. Synonymous variants fold into the wild-type row — 37 codon rows, wild type included, become the singlef.0.WTrow of the bundled CIS object — which is usually what you want for protein scoring and never what you want for a codon-level question. Total counts are preserved.collapseMutantsBySimilarity()merges sequences within a Hamming distance (collapseMaxDist) — the tool for error-prone barcodes, not for named mutants.
Scoring against wild type: calculateRelativeFC()
This fits each variant with edgeR's quasi-likelihood GLM (method = "edgeR", the default)
or limma-voom (method = "limma") and reports a logFC for the coefficient you name. It
needs replicates — at least two samples per condition — because the dispersion is
estimated from them.
The bundled object below is the full CIS experiment from the same study, already digested: three replicates of input and output, with optical densities recorded per sample.
cis <- readRDS(system.file("extdata", "GSE102901_cis_se.rds", package = "mutscan"))
aa <- collapseMutantsByAA(cis)
# Replicate is stored as an integer; as a number it would fit a linear trend in
# replicate ID instead of one offset per replicate.
aa$Replicate <- factor(aa$Replicate)
design <- model.matrix(~ Replicate + Condition, data = colData(aa))
colnames(design)
fc <- calculateRelativeFC(aa, design = design, coef = "Conditioncis_output",
WTrows = "f.0.WT", method = "edgeR")
fc["f.0.WT", c("logFC", "PValue")]
head(fc[order(fc$PValue), c("logFC", "logCPM", "PValue", "FDR")])
WTrows is what makes the score relative to wild type, and leaving it out changes the
question silently. With WTrows set, normalisation defaults to normMethod = "sum",
scaling every library by its wild-type count, and the wild-type row comes out at logFC 0 —
every other logFC is enrichment relative to wild type. Without it, normalisation falls back
to TMM over all variants, and the wild-type row itself acquires a logFC (1.74 on this
dataset). Nothing warns you. Decide which reference you mean and set WTrows accordingly;
the value must be a row name exactly as the collapsed object spells it, and an unknown name
is refused with the list of valid ones.
The edgeR table carries logFC, logCPM, F, PValue, FDR, logFC_shrunk,
df.total, df.prior and df.test; the limma table carries logFC, CI.L, CI.R,
AveExpr, t, P.Value, adj.P.Val, B, se.logFC, df.total and df.prior
(mutscan 1.2.0 with edgeR 4.10.7 and limma 3.68.5). Different column names for the same
quantity — select by name, not position.
Fitness scores
calculateFitnessScore() implements the score of the original study, one column per
replicate. Counts are turned into frequencies and multiplied by each culture's optical
density (the product of the ODCols); each variant's log2 output/input ratio of those
values is then divided by the same log2 ratio for wild type.
fit <- calculateFitnessScore(aa, pairingCol = "Replicate", ODCols = c("OD1", "OD2"),
comparison = c("Condition", "cis_output", "cis_input"),
WTrows = "f.0.WT")
fit["f.0.WT", ] # exactly 1 by construction
Use it to reproduce a published fitness scale; prefer calculateRelativeFC() when you
need a test, an FDR or more than two conditions.
Plots and the QC report
Every plotting function returns a ggplot object (plotPairs() a GGally matrix), so
save them with ggsave().
library(ggplot2)
ggsave("filtering.pdf", plotFiltering(cis, valueType = "fractions",
plotType = "filtered", facetBy = "step"),
width = 8, height = 10)
ggsave("volcano.pdf", plotVolcano(fc), width = 6, height = 5)
ggsave("meandiff.pdf", plotMeanDiff(fc), width = 6, height = 5)
generateQCReport(cis, outFile = file.path(getwd(), "qc_report.html"))
generateQCReport() renders an HTML report through rmarkdown and needs pandoc on PATH;
the conda route above provides it. Give outFile an absolute path.
Try it
A cold, end-to-end check: real reads in, counts and a wild-type-relative score out.
Data — four FASTQ files and one processed object, fetched from the upstream
repository pinned to the Bioconductor 3.23 commit (6aa8572), so the bytes cannot move
under the test. The FASTQs are 1,000-read paired subsamples of the TRANS experiment in
Diss and Lehner, eLife 2018 (GEO GSE102901); the .rds is that study's full CIS
experiment, digested by mutscan's authors. All are distributed in the package under its
MIT licence, need no account, and total about 260 KB (sizes measured 2026-10-20). They are
byte-identical to the copies installed with mutscan 1.2.0. Install mutscan first, by
either route above.
Run — saved as try_mutscan.R and run with Rscript try_mutscan.R on the R 4.6
route, or conda run -p ./mutscan-env ./mutscan-env/bin/Rscript try_mutscan.R on the conda
route, from the directory that holds ./mutscan-env. It routes through
the three traps: the filter log must account for every read, the replicate must be a
factor, and the score is only wild-type-relative when WTrows is given.
suppressPackageStartupMessages({ library(mutscan); library(SummarizedExperiment) })
stopifnot(packageVersion("mutscan") >= "1.2.0")
base <- "https://raw.githubusercontent.com/fmicompbio/mutscan/6aa85729d6ab31b4227c979a742e96f6d33dae62/inst/extdata"
dir.create(dd <- file.path(tempdir(), "mutscan-try"), showWarnings = FALSE)
files <- c(paste0(rep(c("transInput", "transOutput"), each = 2), c("_1", "_2"), ".fastq.gz"),
"GSE102901_cis_se.rds")
for (f in files) download.file(file.path(base, f), file.path(dd, f), mode = "wb", quiet = TRUE)
FOS <- "ACTGATACACTCCAAGCGGAGACAGACCAACTAGAAGATGAGAAGTCTGCTTTGCAGACCGAGATTGCCAACCTGCTGAAGGAGAAGGAAAAACTA"
JUN <- "ATCGCCCGGCTGGAGGAAAAAGTGAAAACCTTGAAAGCTCAGAACTCGGAGCTGGCGTCCACGGCCAACATGCTCAGGGAACAGGTGGCACAGCTT"
dig <- function(s) digestFastqs(
file.path(dd, paste0(s, "_1.fastq.gz")), file.path(dd, paste0(s, "_2.fastq.gz")),
adapterForward = "GGAAGAGCACACGTC", adapterReverse = "GGAAGAGCGTCGTGT",
elementsForward = "SUCV", elementLengthsForward = c(1, 10, 18, 96),
elementsReverse = "SUCV", elementLengthsReverse = c(1, 8, 20, 96),
constantForward = "AACCGGAGGAGGGAGCTG", constantReverse = "GAAAAAGGAAGCTGGAGAGA",
wildTypeForward = c(FOS = FOS), wildTypeReverse = c(JUN = JUN),
forbiddenMutatedCodonsForward = "NNW", forbiddenMutatedCodonsReverse = "NNW")
res <- list(input = dig("transInput"), output = dig("transOutput"))
# Invariant 1: every read is charged to exactly one filter or retained.
for (r in res) {
fs <- r$filterSummary; steps <- grep("^f[0-9]", names(fs), value = TRUE)
stopifnot(sum(fs[steps]) + fs$nbrRetained == fs$nbrTotal,
sum(r$summaryTable$nbrReads) == fs$nbrRetained,
fs$f3_nbrReadWrongLength == 0)
cat("total", fs$nbrTotal, "retained", fs$nbrRetained,
"adapter", fs$f1_nbrAdapter, "too many codons", fs$f10a_nbrTooManyMutCodons, "\n")
}
se <- summarizeExperiment(res, data.frame(Name = names(res), Condition = names(res)),
countType = "reads")
# Invariant 2: read counts in the assembled matrix equal the retained reads.
stopifnot(all(colSums(assay(se, "counts")) ==
sapply(res, function(r) r$filterSummary$nbrRetained)))
cat("variants x samples:", dim(se), "\n")
cis <- readRDS(file.path(dd, "GSE102901_cis_se.rds"))
aa <- collapseMutantsByAA(cis)
# Invariant 3: collapsing to amino acids preserves every count.
stopifnot(sum(assay(aa, "counts")) == sum(assay(cis, "counts")))
aa$Replicate <- factor(aa$Replicate)
design <- model.matrix(~ Replicate + Condition, data = colData(aa))
fc <- calculateRelativeFC(aa, design, coef = "Conditioncis_output", WTrows = "f.0.WT")
tmm <- calculateRelativeFC(aa, design, coef = "Conditioncis_output")
fit <- calculateFitnessScore(aa, pairingCol = "Replicate", ODCols = c("OD1", "OD2"),
comparison = c("Condition", "cis_output", "cis_input"),
WTrows = "f.0.WT")
# Invariant 4: with WTrows, wild type is the zero point; fitness puts it at exactly 1.
stopifnot(abs(fc["f.0.WT", "logFC"]) < 1e-8, all(fit["f.0.WT", ] == 1))
cat("rows codon -> aa:", nrow(cis), "->", nrow(aa), "\n")
cat("WT logFC with WTrows:", signif(fc["f.0.WT", "logFC"], 3),
"| without:", round(tmm["f.0.WT", "logFC"], 3), "\n")
cat("FDR < 0.05:", sum(fc$FDR < 0.05), "of", nrow(fc), "\n")
top <- head(fc[order(fc$PValue), ], 5)
print(data.frame(logFC = round(top$logFC, 2), FDR = signif(top$FDR, 2),
row.names = rownames(top)))
Expect
Invariants — a failure means this skill is wrong:
- Every sample's filter columns plus
nbrRetainedequalnbrTotal, the variant table's read counts sum tonbrRetained, and no read fails the length check. - With
countType = "reads"the assembled matrix's column sums equal the retained reads. collapseMutantsByAA()preserves the total count.- With
WTrows = "f.0.WT"the wild-type logFC is zero to within 1e-8, and every replicate's fitness score for wild type is exactly 1.
Observed, mutscan 1.2.0 / edgeR 4.10.7 / limma 3.68.5 / R 4.5.3, 2026-10-21 (unchanged from edgeR 4.10.6 on 2026-10-20), and identical on R 4.6.1 apart from the floating-point residue on the wild-type line — drift to investigate, not a bug:
total 1000 retained 279 adapter 314 too many codons 392
total 1000 retained 285 adapter 366 too many codons 338
variants x samples: 560 2
rows codon -> aa: 1007 -> 640
WT logFC with WTrows: 8.73e-16 | without: 1.74
FDR < 0.05: 568 of 640
logFC FDR
f.27.* -4.50 1.1e-22
f.7.G -4.18 1.1e-22
f.28.* -4.39 1.1e-22
f.28.G -3.94 1.1e-22
f.7.* -4.46 1.1e-22
The top of the list is the sanity check a biologist reads first. Three of the five
strongest depletions are premature stop codons (*), which should abolish the
interaction, and the other two are glycine substitutions, a classic helix breaker in a
coiled coil. The non-zero wild-type logFC on the "without" side is the WTrows trap in
number form.
Troubleshooting
Each message below was reproduced against mutscan 1.2.0.
suppressPackageStartupMessages({ library(mutscan); library(SummarizedExperiment) })
ext <- system.file("extdata", package = "mutscan")
r1 <- file.path(ext, "transInput_1.fastq.gz"); r2 <- file.path(ext, "transInput_2.fastq.gz")
show_error <- function(expr) tryCatch({ expr; "no error" },
error = function(e) conditionMessage(e))
# Element lengths that do not sum to the read length: no error, every read discarded.
quiet <- digestFastqs(r1, r2, elementsForward = "SUCV", elementLengthsForward = c(1, 10, 18, 90),
elementsReverse = "SUCV", elementLengthsReverse = c(1, 8, 20, 96))
quiet$filterSummary[, c("nbrTotal", "f3_nbrReadWrongLength", "nbrRetained")]
# No UMI in the layout, but UMIs requested.
noumi <- digestFastqs(r1, r2, elementsForward = "SCV", elementLengthsForward = c(11, 18, 96),
elementsReverse = "SCV", elementLengthsReverse = c(9, 20, 96))
show_error(summarizeExperiment(list(a = noumi), data.frame(Name = "a")))
# One sample per condition: nothing to estimate a dispersion from.
cis <- collapseMutantsByAA(readRDS(file.path(ext, "GSE102901_cis_se.rds")))
one <- cis[, cis$Replicate == 1]
show_error(calculateRelativeFC(one, model.matrix(~ Condition, colData(one)),
coef = "Conditioncis_output", WTrows = "f.0.WT"))
'countType' is set to 'umis', but no UMI sequences were provided when quantifying.— the layout has noU; passcountType = "reads".'x' must be a named list/names in 'x' do not match 'coldata$Name'— name the list passed tosummarizeExperiment()and use the same names incoldata$Name.NA dispersions not allowed(edgeR) orNo residual degrees of freedom in linear model fits(limma) — no replicates; the model has nothing to estimate variance from.One or more named coef arguments do not match a column of the design matrix.— printcolnames(design); the coefficient is the column name, such asConditioncis_output, not the level.All values in 'WTrows' must be one of: ...— aftercollapseMutantsByAA()the wild-type row keeps its pre-collapse name (f.0.WThere,FOS.0.WT_JUN.0.WTfor a named two-protein design). Usegrep("WT", rownames(se), value = TRUE)to find it.'elementsForward' and 'elementsLengthsForward' must have the same length— one length per code. (The message spells the argumentelementsLengths; the argument iselementLengthsForward.)nbrRetainednear zero withf3_nbrReadWrongLengthnearnbrTotal— the element lengths do not cover the read; count them against an actual read.C17 standard requested but CC17 is not definedwhile compilinglocfit(an edgeR dependency) — seen with a conda-forge R 4.6 that builds everything from source. That R'sMakeconfleavesCC17empty; pointR_MAKEVARS_USERat a file that setsCC17 = <the CC from R CMD config CC> -std=gnu17. On macOS and Windows, CRAN's R 4.6 normally installs binaries and skips that compile.