ADR-007: Add ry static checker and track latest tool versions
| Status | 🟢 Accepted |
| Created | 2026-09-24 |
| Last Updated | 2026-09-24 |
| Deciders | Novica Nakov |
Context
The template formats with air and lints with jarl, but nothing checks for likely runtime bugs before code runs: incompatible types ("a" + 1L), unbound variables, misspelled data frame columns, invalid calls. ry is a static type/scope checker for R, a standalone Rust binary inspired by astral’s ty and built on tree-sitter-r. It is meant to run alongside a formatter and linter, not replace them. It also ships an LSP server and a VS Code / Positron extension (scholzmx.ry-checker). It is young (pre-1.0) and releases often, adding new rules in most versions.
We checked ry 0.11.0 against this repo: it reported no findings on the current code. On a probe module with bugs planted in it, ry caught all three: RY040 (character + integer arithmetic), RY010 (unbound variable) and RY060 (misspelled column, listing the real ones).
ry does not understand box::use(). It resolves names only via library()/require() and an R package’s NAMESPACE. What we observed:
- Nothing
box::use()binds is known to ry. A module object (box::use(./hello)thenhello$say_hello(), the most common box form) reports a false RY010 onhello. Names attached withbox::use(./mod[fn])orbox::use(./mod[...])report a false RY010 when used as a value (e.g.g <- say_hello,lapply(x, say_hello)). - Calls to unknown names are not checked at all, so a misspelled call (
helo("x")) or importing a name the module doesn’t export (box::use(./hello[hello])) goes unreported. This is why the current repo passes cleanly: its code only makes bare calls to attached names and never references its module objects. - Package functions imported through box are not tied to their package. After
box::use(dplyr[filter]), ry checksfilter()asstats::filter, so a valid data-masked column (filter(d, mpg > 21)) reports a false RY010. The same code afterlibrary(dplyr)is clean.
Separately, versions had drifted across the tools: CI installed air with latest, while .pre-commit-config.yaml pinned air 0.8.2 and jarl 0.5.0 via their upstream hook repos. Those hook repos also use language: python, so prek pip-installs the tools and a Python environment comes back, against the goal of ADR-002.
Problem Statement
Static checking would catch real bugs early, but the checker doesn’t model the template’s module system, and tool versions are inconsistent across hooks, CI and the devcontainer. Should the template adopt ry, and how should it (and air/jarl) be versioned?
Options Considered
| Option | Description | Bug detection | Low friction with box | Consistency across hooks/CI/editor | Maintenance | Overall score | Notes |
|---|---|---|---|---|---|---|---|
| Weight | - | 2 | 1 | 2 | 1 | - | |
| A | Add ry (blocking in CI), all tools on latest, local system hooks |
✅ | ⚠️ | ✅ | ✅ | 20 | New releases can fail CI without a code change; accepted as a signal to review |
| B | Add ry, pin every tool version in one place | ✅ | ⚠️ | ✅ | ❌ | 18 | Reproducible, but pins go stale in every project created from the template; no bot bumps curl-installer versions |
| C | Don’t add ry | ❌ | ✅ | ⚠️ | ✅ | 14 | No type/scope checking; version drift still unresolved |
✅ = 3 (good), ⚠️ = 2 (acceptable), ❌ = 1 (poor)
Decision Outcome
We will use Option A. ry catches real bugs that air and jarl cannot, and the current code is clean, so it can be blocking from day one. In a template, getting new tool releases matters more than reproducible tool versions: projects created from it should run the current air, jarl and ry, not whatever was pinned when they were forked.
- Git hooks (
.pre-commit-config.yaml): air, jarl and ry run asrepo: local,language: systemhooks that call the installed binaries. This removes the pinnedrevs and the Python install from the upstream air/jarl hook repos. The air hook falls back tor-air, the name some distros (e.g. Arch) package it under.ry check .runs on the whole project (pass_filenames: false), since ry resolves bindings across files. - CI (
ci.yml): installs ry with itslatestinstaller and runsry check --output-format github .after jarl, before tests, so findings show up as PR annotations. - Devcontainer / editors: the Dockerfile installs the latest ry. The
scholzmx.ry-checkerextension is recommended and preinstalled. just typecheckrunsry check ..just formatnow resolvesairorr-air.ry.tomldocuments how to work around the box limitation.
Consequences
- Good, because type, scope and column bugs are caught before tests run, in CI, hooks and the editor.
- Good, because hooks, CI and devcontainer run the same (latest) versions, and hooks no longer need Python.
- Bad, because a new air/jarl/ry release can fail CI on unchanged code. The fix is to address the new finding or suppress it; a failing build isn’t a regression in the project.
- Bad, because local
systemhooks require air, jarl and ry onPATH. prek no longer installs them. - Bad, because box module objects, box-imported names used as values, and data-masked columns after a box package import give a false RY010 until ry supports
box::use(). Work around with# ry: ignore[RY010]inline, orglobalsinry.toml. - Risk: ry is pre-1.0; rule names, messages or config keys may change. Baselines match on message text, so reworded messages can bring back findings that were already accepted.
Confirmation
CI runs ry check on every PR. Revisit this ADR if ry adds box::use() support (at that point, ry.toml’s globals workaround can go) or if tracking latest causes repeated CI churn (then switch to Option B).
Links
| Type | Links |
|---|---|
| ADRs | ADR-002 |
| Issues | |
| PRs |