check-R-code

Standards derived from the check-R-code skill

NoteSource

Derived from the check-R-code skill (project).

Source file: ~/.claude/skills/check-R-code/SKILL.md

Last synced: 2026-08-19

0.1 Purpose

Review R code for tidyverse style compliance, correctness, and modern best practices

0.2 Standard

1 Check R Code for Tidyverse Compliance

When reviewing R code, apply these standards systematically. Report findings grouped by category, most critical first.

1.1 Priority Tiers

Critical — breaks code or modern tidyverse practice: - Wrong pipe operator (%>% vs |>) - Incorrect join syntax (not using join_by()) - Inefficient grouping patterns - map_dfr() / map_dfc() instead of map() |> list_rbind() - Base R string functions instead of stringr - sapply() with implicit type coercion

High — violates current best practices: - Non-snake_case names - group_by() |> ... |> ungroup() pattern (should be .by =) - head() instead of slice_head() (strips attributes) - Labeled data not using modern ggplot2 4.0+ auto-labeling - Awkward data masking (missing {{}} or .data[[]]) - Code not formatted with the air formatter

Medium — style/readability concerns: - Spacing around operators or arguments - Comment quality (explains WHY not WHAT) - Function/variable naming inconsistency

Low — optimization opportunities: - Vectorization suggestions - Performance tweaks in non-critical code


1.2 Formatting

Standard: All R code must be formatted with the air formatter.

# Format a single file
air format path/to/file.R

# Format a whole project
air format .

# Check formatting without writing (CI-friendly)
air format --check .

Flag any code that hasn’t been run through air — inconsistent spacing, indentation, or line-wrapping that air format would normalize is a High finding. Recommend running air format . on the project before/after review rather than manually reformatting by hand.


1.3 Pipe Operator

Standard: Native pipe |> (R 4.1+)

Anti-patterns:

data %>% filter(x > 5) %>% mutate(y = x * 2)
x %>% map(~f(.x)) %>% unlist()

Correct:

data |> filter(x > 5) |> mutate(y = x * 2)
x |> map(\(x) f(x)) |> list_rbind()

Exception: Older codebases locked on R < 4.1 or magrittr-only code. If you see %>%, flag it as CRITICAL unless the codebase declares a minimum R version < 4.1.


1.4 Joins

Standard: join_by() for all join conditions (dplyr 1.1+)

Anti-patterns:

inner_join(x, y, by = c("id" = "user_id"))
left_join(a, b, by = c("year" = "fiscal_year", "amount" = "value"))

Correct:

inner_join(x, y, by = join_by(id == user_id))
left_join(a, b, join_by(year == fiscal_year, amount == value))

Quality control: On 1:1 joins expected to match cleanly, add:

inner_join(x, y, join_by(id), multiple = "error", unmatched = "error")

1.5 Grouping

Standard: .by = parameter (dplyr 1.1+) — always returns ungrouped

Anti-patterns:

data |> group_by(category) |> summarise(mean_val = mean(x)) |> ungroup()
data |> group_by(a, b) |> mutate(cumsum = cumsum(x)) |> ungroup()
data |> group_by(year) |> slice_head(n = 2)

Correct:

data |> summarise(mean_val = mean(x), .by = category)
data |> mutate(cumsum = cumsum(x), .by = c(a, b))
data |> slice_head(n = 2, .by = year)

When to use persistent grouping: Only when grouping applies to multiple consecutive operations AND you explicitly ungroup after. Flag if not intentional.


1.6 Map & purrr

Standard: map() + list_rbind() / list_cbind(); lambda shorthand with \(x)

Anti-patterns:

map_dfr(splits, train_model)              # superseded
map_dfc(cols, normalize)                  # superseded
sapply(x, sqrt)                           # implicit type coercion risky
map(x, ~f(.x))                            # old formula syntax

Correct:

splits |> map(train_model) |> list_rbind()
cols |> map(normalize) |> list_cbind()
x |> map_dbl(sqrt)                        # explicit type
x |> map(\(x) f(x))                       # lambda shorthand

walk2 for side effects:

walk2(data_list, names_list, \(df, name) ggsave(name, plot(df)))

1.7 String Functions

Standard: stringr package (never base R string functions)

Anti-patterns:

grepl("pattern", text)           # use str_detect()
sub("old", "new", x)             # use str_replace()
gsub("a", "b", text)             # use str_replace_all()
substr(text, 1, 5)               # use str_sub()
nchar(text)                       # use str_length()
toupper(text); tolower(text)     # use str_to_upper(); str_to_lower()
paste0(a, b, c)                  # use str_c()
paste("Hello", name, sep = " ") # use str_glue("Hello {name}")
strsplit(text, ",")              # use str_split()
trimws(text)                     # use str_trim()

Correct:

str_detect(text, "pattern")
str_replace(x, "old", "new")
str_replace_all(text, "a", "b")
str_sub(text, 1, 5)
str_length(text)
str_to_upper(text); str_to_lower(text)
str_c(a, b, c)
str_glue("Hello {name}")
str_split(text, ",")
str_trim(text)

1.8 Naming

Standard: All names in snake_case; nouns for variables, verbs for functions

Anti-patterns:

user_Data; UserCount; calculate.total; THRESHOLD

Correct:

user_data; user_count; calculate_total; threshold
my_function <- function(...) { }
daily_revenue <- data |> summarise(...)

1.9 Data Masking & Quoting

Embrace {{}} for function arguments:

summarise_by <- function(.data, group_var, summary_var) {
  .data |>
    summarise(mean = mean({{ summary_var }}), .by = {{ group_var }})
}

# Call: data |> summarise_by(group_var = category, summary_var = sales)

Character column names with .data[[]]:

for (col in names(df)) {
  result <- df |> count(.data[[col]])
}

# Or across() for multiple columns:
df |> mutate(across(where(is.numeric), log, .names = "log_{.col}"))

1.10 ggplot2 4.0+ Label Integration

Auto-labeling from SPSS variable labels (haven):

Correct (labels come from var_label() automatically):

# Variable labels on axes automatically
ggplot(df, aes(satisfaction, engagement)) + geom_point()

# Bulk override:
ggplot(df, aes(var1, var2)) + geom_point() + labs(dictionary = unlist(var_label(df)))

Value labels (factor categories):

# Convert labeled numeric to factor with value labels:
df |> mutate(across(where(is.labelled), to_factor))
df |> mutate(gender = to_factor(gender))   # selective conversion

# Strip labels, keep numeric:
zap_labels(x)

Caveats (auto-labeling fails): - Transformations in aes(): aes(x = log(income)) breaks labeling — use pure variable names - aes(x = .data$varname) is fine — the .data pronoun still counts as a pure reference and preserves labeling - head() strips attributes — use slice_head() instead

Label priority (low → high): - aes() expression - labs(dictionary=) - Column label attribute - labs(<aes>=) - scale_*(name=) - guide_*(title=)

Related packages (not a formal dependency — ggplot2 just reads the generic label attribute; haven/labelled happen to set it):

# ggplot2 >= 4.0.0
# labelled >= 2.16.0 (for var_label, to_factor, val_labels, look_for)
# haven >= 2.5.5 (for zap_labels, as_factor, reading SPSS/Stata/SAS files)

1.11 Labeled Data (labelled package)

var_label(df)                  # Get all variable labels
var_label(df$col) <- "Label"   # Set label
val_labels(df$col)             # Get value labels
to_factor(x)                   # Convert labeled numeric to factor
zap_labels(x)                  # Strip labels, keep numeric
look_for(df, "keyword")        # Search variable names and labels

1.12 Anti-Patterns Checklist

Anti-Pattern Correct Category
%>% \|> Critical
by = c("a" = "b") join_by(a == b) Critical
group_by() \|> ungroup() .by = Critical
map_dfr(), map_dfc() map() \|> list_rbind/cbind() Critical
grepl(), gsub() str_detect(), str_replace_all() Critical
sapply() map_dbl(), map_chr() Critical
head() on labeled data slice_head() High
camelCase, PascalCase snake_case High
Base string functions stringr Critical
paste0() for templates str_glue() Medium
nchar() str_length() Critical
strsplit() str_split() Critical

1.13 Reporting Format

When reviewing R code, structure findings as:

  1. Critical issues (breaks modern tidyverse or correctness)
  2. High-priority issues (violates best practices)
  3. Medium issues (style/readability)
  4. Low issues (optimization)

For each finding, cite: - File & line number - What: the anti-pattern found - Why: the standard and reasoning - Fix: the corrected code - Impact: whether it’s a code issue or style

Example:

### Critical: Wrong pipe operator
**File:** analysis.R:42
**Issue:** Using `%>%` instead of `|>`
**Why:** Native pipe is standard in R 4.1+; cleaner, no magrittr dependency
**Current:** data %>% filter(x > 5) %>% mutate(...)
**Fixed:** data |> filter(x > 5) |> mutate(...)

1.14 Context-Dependent Exceptions

  • R < 4.1: %>% is correct; flag if minimum version isn’t declared
  • Legacy codebases: Document why older patterns remain; deprecate incrementally
  • Performance-critical code: data.table or base R optimizations may be justified; document trade-off
  • Non-tidyverse projects: Adapt standards to project’s declared approach

1.15 Notes for reviewers

This section is not auto-generated. Add concrete examples, the “why” behind each rule, and links to this project’s actual code (shared setup scripts, theme functions, etc.) that the skill above may only refer to abstractly.