Projects and Files¶
Scrutin runs in one of two modes:
- File mode: point a single tool at one or more files. No project, no config, no auto-detection.
- Project mode (the default): run every applicable tool against a full package, loading your code into R or Python so tests can exercise it.
File mode is covered first because it's the simplest.
File mode¶
File mode checks individual files with a single tool. No config, no package, no auto-detection.
Available tools:
| Tool | Language | What it does |
|---|---|---|
| jarl | R | R linter |
| ruff | Python | Fast Python linter / formatter |
| skyspell | Prose | Dictionary-based spell checker |
| typos | Any | Curated-misspelling spell checker (language-agnostic) |
These tools invoke a CLI once per file and don't need a surrounding project. Test and data-validation tools (testthat, tinytest, pointblank, validate, pytest, Great Expectations) do need a project: they load your package into a long-lived R or Python interpreter so the tests can exercise it, and they're refused in file mode with a pointer to run from the project root instead.
File mode differs from project mode:
- You must pass
--tool <name>; there's nothing to auto-detect. - The project-local
.scrutin/config.tomlisn't loaded (there's no project). A user-level config (see Config file lookup) still applies, so your global preferences carry over; use--setoverrides for one-off tweaks on top. - Run state (history DB, dep-map cache) lives in a scratch directory, so nothing lands next to your files.
- Watch mode still applies if your terminal supports the TUI: edits to the listed files trigger reruns.
Project mode¶
Scrutin runs from the project directory and activates every applicable tool against the whole package. Works for testthat, tinytest, pointblank, validate (R), and pytest, Great Expectations (Python) with zero configuration; jarl, ruff, skyspell, typos join in when you opt them in explicitly (see Example projects below).
Concepts¶
Project root: the directory you point Scrutin at on the command line. This is where .scrutin/config.toml lives (if any), and where the run's shared state is anchored: .scrutin/state.db, runner scripts, hooks, and ignore patterns.
Tool: a test runner or quality checker Scrutin knows how to drive (testthat, pytest, jarl, ruff, skyspell, ...). Support for every supported tool is built into the Scrutin binary; there are no separate Scrutin plugins to install. You still install the underlying tools (R, Python, pytest, ruff, ...) through their usual channels.
Suite: one configured instance of a tool. A suite owns a working directory and a set of file patterns. A project can run many suites in one invocation, even several using the same tool on different subtrees; suites run one after another so each gets the full worker pool, and files within a suite run in parallel.
Suite root: the working directory a suite's subprocess runs from. Subprocesses are spawned with cwd = suite.root and SCRUTIN_PKG_DIR = suite.root, so pkgload::load_all(), pytest, ruff, and your tests' relative read.csv(...) / open(...) calls all resolve against that subtree. In a single-package project, suite root equals project root. In a monorepo, each suite points at its own package.
Pointing at a project¶
scrutin # project root is the current directory
scrutin path/to/project # project root is the given path
Scrutin does not walk upward to find a parent project. Running it from inside tests/testthat/ looks for tools in tests/testthat/. Run from the project root or pass the path explicitly.
Auto-detection¶
With no [[suite]] entries in your config, Scrutin scans the project root for marker files and activates every matching test or data-validation tool. A project with both tests/testthat/ and inst/tinytest/ gets both suites; adding pyproject.toml with a tests/ directory adds pytest alongside them. Every auto-detected suite gets root equal to the project root.
| Tool | Language | Detected when |
|---|---|---|
| testthat | R | DESCRIPTION + tests/testthat/ |
| tinytest | R | DESCRIPTION + inst/tinytest/ |
| pointblank | R | DESCRIPTION + tests/pointblank/ |
| validate | R | DESCRIPTION + tests/validate/ |
| pytest | Python | pyproject.toml (or setup.py / setup.cfg) + tests/ or test/, or test_*.py at the root |
| Great Expectations | Python | tests/great_expectations/ |
Linters and spell checkers (jarl, ruff, skyspell, typos) never auto-detect, even when their config files are present. They're orthogonal to testing: your editor probably already runs ruff on save, and spell checking is rarely something you want gating a CI test run. Enable them with an explicit [[suite]] entry when you want Scrutin to orchestrate them alongside your tests.
Auto-detection only scans the project root: subdirectory names are arbitrary, so Scrutin does not guess. To narrow to a single tool, use --tool (short form -t):
--tool is sugar for --set run.tool=<name>, which applies to both auto-detected and explicitly-declared suites.
Suite config¶
When auto-detection doesn't fit (monorepo, non-standard test layout, scattered files, opt-in linters / spell checkers), declare [[suite]] entries in .scrutin/config.toml. As soon as one [[suite]] is present, auto-detection is skipped entirely.
A suite with every field set:
[[suite]]
tool = "pytest" # which plugin to use
root = "backend/" # suite working directory (relative to project root)
run = ["tests/**/test_*.py"] # files the tool operates on (relative to root)
watch = ["marginaleffects/**/*.py"] # files whose edits trigger reruns of dependent tests
runner = "shared/pytest-runner.py" # custom runner script (relative to project root)
Only tool is required; every other field falls back to a plugin-specific default. The table below documents the fields; the subsection after it covers the relative / absolute path rules the example skirted.
| Field | Type | Required | Description |
|---|---|---|---|
tool |
string | yes | Tool name from the detection table above. |
root |
string | no | Suite root. Default: .. |
run |
glob list | no | Files the tool operates on. Default: plugin-provided (see Configuration). |
watch |
glob list | no | Files that trigger reruns. Default: plugin-provided (or same as run for linters). |
runner |
string | no | Path to a custom runner script that replaces the built-in default. |
Unknown keys are a hard error, so typos surface immediately.
Paths: relative vs absolute¶
Every path-valued field (root, run, watch, runner) accepts both forms:
- Relative paths are resolved against the project root for
rootandrunner, and against the suite root forrunandwatchglobs. This is the common case and keeps configs portable across checkouts. - Absolute paths are used verbatim. Useful for sketch directories outside the repo, shared fixture trees, or a runner that lives in
~/.scrutin/.
# Relative: project-root-anchored
[[suite]]
tool = "pytest"
root = "backend/" # project-root + "backend/"
run = ["tests/**/test_*.py"] # suite-root + "tests/..."
runner = "shared/pytest-runner.py" # project-root + "shared/..."
# Absolute: taken verbatim
[[suite]]
tool = "testthat"
root = "/home/me/scratch/sketch"
run = ["/srv/shared-fixtures/foo_test.R"]
Globs under a relative root are still anchored under that root, not under the project root: a run = ["tests/**/*.py"] in a suite with root = "backend/" matches <project_root>/backend/tests/**/*.py. There's no directory-recursion shortcut: run = ["tests"] matches the literal path tests, not files under it. Write tests/**/*.py for recursion.
Example projects¶
Tests and quality checks¶
A single R package where tests auto-detect fine, but you also want jarl linting and skyspell on the docs. As soon as you declare any [[suite]], auto-detection is off, so testthat has to be re-declared explicitly even though it would have been free:
[[suite]]
tool = "testthat" # would auto-detect; re-declared because any [[suite]] disables auto-detection
[[suite]]
tool = "jarl" # opt-in, never auto-detects
[[suite]]
tool = "skyspell"
run = ["README.md", "NEWS.md", "man/**/*.Rd"]
All three suites run with cwd at the project root. testthat uses its plugin-default run globs; skyspell's are overridden to cover the package's prose files.
Multi-lingual and multi-tool¶
R package in r_dir/, Python package in python_dir/, plus a project-wide skyspell suite spell-checking the docs:
[[suite]]
tool = "testthat"
root = "r_dir"
[[suite]]
tool = "tinytest"
root = "r_dir"
[[suite]]
tool = "pytest"
root = "python_dir"
[[suite]]
tool = "ruff"
root = "python_dir"
[[suite]]
tool = "skyspell"
run = ["README.md", "NEWS.md", "docs/**/*.md"]
Each subprocess runs with cwd set to its suite's root: the testthat and tinytest workers run inside r_dir/, the pytest and ruff workers run inside python_dir/, the skyspell worker runs at the project root (no root declared, so root = "."). Relative file I/O inside tests (read.csv("data/foo.csv"), open("tests/fixtures/x.txt")) resolves against the suite's subtree, not against the project root.
If each package has its own virtualenv, step 4 of Python virtual environments picks up python_dir/.venv/ for the pytest suite automatically.
Non-standard layout¶
Tests live outside the conventional tests/ directory, with custom dep-map globs so editing a source file triggers the right test files:
[[suite]]
tool = "pytest"
root = "backend/"
run = ["spec/**/check_*.py", "integration/**/it_*.py"]
watch = ["marginaleffects/**/*.py", "spec/**/*.py"]
[env]
PYTHONPATH = "backend/src"
run selects both the regular spec files and a separate integration-test tree; watch lists the source files whose edits should trigger reruns. The [env] block is not suite-specific (it's global) but shows up here because it's often what a non-standard layout needs to make imports resolve.
Scattered files¶
A bag of ad-hoc R test scripts that don't live in a package. The root is absolute because this directory isn't inside any project you've checked out; run mixes a local file with one reached via ..:
No shared-ancestor requirement: routing is per-glob match, so a suite can pull files from anywhere on disk.
Custom runner script¶
scrutin init writes the default runners to .scrutin/runners/<tool>.<ext> (e.g. .scrutin/runners/testthat.R, .scrutin/runners/scrutin_pytest.py). Python plugins use the scrutin_<name>.py form (not <name>.py) because Python prepends the script's directory to sys.path[0], so a runner literally called pytest.py would shadow import pytest from inside itself. Edit those files in place to swap the default behaviour: for example, to replace pkgload::load_all() with library() in the testthat runner. The engine automatically prefers a file in .scrutin/runners/ over the embedded default whenever it exists, with no config change. scrutin init is idempotent: re-running prints <path> already exists, skipping. for each existing file instead of overwriting. Delete a file to fall back to the built-in default, or run scrutin init again to re-create it.
For an explicit suite that points at a runner somewhere else in the repo, set runner on the suite:
Same pattern works for pytest and every other worker-mode tool.
Python virtual environments¶
For Python suites, Scrutin resolves the interpreter anchored at the suite root, not the project root. Resolution order:
[python].interpreterin.scrutin/config.toml(project-wide override)[python].venv(path to a virtualenv, relative to project root or absolute)$VIRTUAL_ENV.venv/orvenv/under the suite root$CONDA_PREFIXpython3on$PATH(orpythonon Windows)
In a monorepo, step 4 picks up each package's own .venv. For a single shared venv, set [python].venv explicitly:
[python]
venv = "my_env" # relative to project root, or absolute
interpreter = "python3.12" # skip venv detection entirely
Config file lookup¶
Scrutin looks for .scrutin/config.toml in the project root. If none is found, it falls back to a user-level config in the platform-standard config directory:
| Platform | Path |
|---|---|
| Linux | ~/.config/scrutin/config.toml (or $XDG_CONFIG_HOME/scrutin/config.toml) |
| macOS | ~/Library/Application Support/scrutin/config.toml |
| Windows | %APPDATA%\scrutin\config.toml |
The same fallback applies in file mode, so your global preferences (language for skyspell, extra args for a linter, etc.) carry over even without a project. See the configuration reference for the full schema, including per-tool defaults and options not covered here.