One command that tells a coding agent — and you — when the work is actually done.
checkride is an npm package that runs your whole verification pipeline as a
single command (pnpm check) whose exit code is the verdict, and mechanically
enforces module boundaries so parallel changes stay out of each other's way.
Together those give an LLM agent the two things it otherwise lacks: a
definition of done, and lanes to stay inside.
It checks TypeScript, Go, Rust, Python, Terraform and shell code through language packs, each running its own tools from the copy the repo pins, and a mixed-language monorepo's parts as named targets, each from its own root. A repo with no Node installs and pins checkride through mise; built-in checks stand down when their tool isn't in the repo, and custom checks run any other command. A worked example takes a repo with a TypeScript app, a Go API, OpenTofu and shell scripts from install to a green gate.
Agents are good at writing code and bad at knowing when to stop. Checkride fixes both halves of that problem.
- A definition of done. One command runs the whole verification pipeline — types, lint, structure, dead code, tests, docs, links, spelling. Exit 0 means the work is complete. Agents stop guessing; humans stop re-reviewing half-finished work.
- Structured boundaries. Code is organized into modules with narrow
public surfaces. In the default layout, a module starts as a single file;
when it grows internals worth hiding, it becomes a folder whose only public
surface is its
index.ts, and sibling modules import that index — never the internals. (These are deep modules — Ousterhout's term, from A Philosophy of Software Design, for a small interface hiding a substantial implementation.) checkride enforces this mechanically, which keeps each change inside one module's boundary and lets humans and agents work in parallel with minimal merge conflicts. Deep modules are checkride's default convention, not a hardcoded one — the rules are swappable ast-grep files in your repo, and the same slot enforces boundaries in other conventions and languages (see Conventions).
One design choice to know up front: because the consumer of the output is an
LLM, checkride never normalizes diagnostics into a common format to reach a
verdict. Each tool writes its own raw output to .check/, which stays
authoritative, and the agent reads whatever the tool emits; a reader that
wants findings by file and line can parse them with checkride/findings.
Skipping normalization deletes the adapter-maintenance layer that makes
traditional run-all-my-tools wrappers expensive to extend.
For an existing repository, install checkride (exact-pinned — see the
pin policy) and let init
adopt the tools you already have:
pnpm add -D -E checkride
pnpm exec checkride initThe same two steps under another package manager — init detects it and
spells the alias, stanza, and hooks for it (see
Package managers):
npm install -D -E checkride && npx checkride init
yarn add -D -E checkride && yarn checkride init
bun add -d -E checkride && bunx checkride initFor a new project, run init in an empty directory — no install first;
dlx fetches checkride, and the generated package.json pins it:
pnpm dlx checkride init --shape flat --name my-app
pnpm install
pnpm checkThe generated project is a pnpm project: its engines field and
pnpm-workspace.yaml assume it, and the monorepo and hybrid shapes are pnpm
workspaces. To start under npm, yarn, or bun, create the package.json first
(npm init -y, or your manager's equivalent), then take the
existing-repository path above — init --add <slots> scaffolds the tool
configs a bare repository lacks (init --add struct,dead, for example).
For a repo without Node, such as a Go or Rust project with no
package.json, mise installs checkride beside a pinned node and runs it as
mise exec -- checkride. init --lang go|rust writes that mise.toml for a
new project. A Go, Rust, Python or Terraform repo that already exists adds the
pin with mise use, then runs mise exec -- checkride init, which adopts the
tools its root configures through that language's pack.
Installing checkride shows the pin, and the
allow_low_downloads = true opt-in mise needs before it installs checkride.
Every path ends in the same place — init auto-detects which case it is in.
Where the repo has a package.json, it writes a "check": "checkride" script
alias, so daily usage is pnpm check (npm run check, yarn run check, or
bun run check under the others) regardless of the tool's name. It also
writes the agent contract: an AGENTS.md
stanza stating the "exit 0 = done" rule, an @AGENTS.md import in CLAUDE.md
(Claude Code reads a CLAUDE.md in AGENTS.md's place, so the import is what
loads the stanza; an existing CLAUDE.md gains that one line and nothing else),
and hooks in your agent harness's
config — .claude/settings.json for Claude Code, .cursor/hooks.json for
Cursor. Three of them: a stop gate (a checkride-owned script that runs your
detected package manager's run check --strict --digest and blocks an agent
from finishing while the pipeline is red), an edit marker so turns that touched
no files skip the gate, and a guard that denies edits to
checkride.baseline.json and .check/**. In a repo with no package.json,
the gate, the stanza and the skills run mise exec -- checkride where a root
mise.toml pins npm:checkride, and the checkride on PATH otherwise,
which doctor reports as the harness unpinned. Select hooks with --hook <a,b>
(gate | dirty | protect), harnesses with --harness <a,b> (claude | cursor;
detected by default), skip the hooks with --no-hook, or take an installed one
back out with --remove-hook <a,b>. To add all of this — alias, stanza,
CLAUDE.md import, hooks, and the Cursor skills — to a repo you already set up, run
checkride agent-setup.
Re-running either command refreshes the stanza in place, but never behind your
back: the marker carries a hash of what checkride wrote, so a stanza you have
since edited stops the run (exit 2) instead of being overwritten. Keep
repo-specific additions outside the markers, where nothing rewrites them — or
pass --force to discard the edits and refresh. A stanza written before the hash
existed is matched against the wordings checkride released, so upgrading is not
itself mistaken for an edit.
To add or remove a hook on its own, use checkride hooks add <a,b> /
checkride hooks remove <a,b>. It writes only the harness config and the
generated scripts, and never reads or writes AGENTS.md — managing a hook is not
managing your contract file, and an edited stanza does not stand in its way.
Task-focused guides live in docs/:
- Why checkride — the case for adopting it: what it's selling, the ROI versus ad-hoc scripts or a task runner, and answers to the common objections.
- Getting started — prerequisites, adding checkride to a project (new or existing), your first run, and the daily loop.
- Installing checkride — the two channels, npm and the mise route, and how a repo without Node pins checkride.
- Cheat sheet — one-screen reference for commands,
flags, npm-script aliases, and the
.check/output files. - Tools and installation — what each check of the ts and
repo packs runs, and how to install a missing tool when
doctorreports one. - Targets — checking a repo with more than one language, or one language in more than one place, as named targets.
- Packs and pins — each language pack's slots and tools, where each tool is pinned, and the environment its checks run with.
- A polyglot repo, end to end — a worked example: one repo with a TypeScript app, a Go API, OpenTofu and shell scripts, from install through targets, pins, the daily loop and CI.
- Running in CI — a copy-paste GitHub Actions recipe (with
npm/yarn/bun variants, and a mise one for a repo without Node), and why
gates should pass
--strict. - Running checkride from outside a repo — what a
reviewer, a CI job or an agent passes to check a checkout it didn't write:
--discover,--defaults,--pins,--static-onlyand--out. - Shared presets — one versioned config for a fleet of
repos, through
extends. - Deep modules — the default convention
initscaffolds, thestructanddeadrules that hold it, and how to swap it. - Ordering in practice — what
orderand waves buy on a real repository, measured. - The contract — the surfaces consumers may rely on:
exit codes, the
summary.jsonschema discipline, the baseline file format, flags, exports, and the pin policy. - The Claude Code plugin — the bundled
/checkride:checkand/checkride:qaskills: install, what each one reads, and what they deliberately do not do. - Cursor — what
agent-setupwrites for Cursor, where Cursor's hook API differs from Claude Code's, and the assumptions not yet verified against a live Cursor. - Reliability — why checkride is safe to build a gate on.
Runnable counterparts live in examples/: standalone projects you can install and run on their own, each demonstrating one thing — what an agent reads when the gate is red, how a repo with existing debt adopts checkride without a cleanup project first, or how a pack checks a Go, Rust, Python, Terraform or mixed-language repo. Each one declares the run it expects, and the end-to-end suite asserts it on every push.
The rest of this README is the reference: the command surface, the pipeline
model, the .check/ output contract, configuration, and the baseline.
The pipeline is a sequence of slots — roles like types, lint, test —
each filled by an adapter, the concrete tool that runs it (the full
catalogue is below). The commands refer to
both:
checkride Run the default checks. Exit 0 pass / 1 fail / 2 error.
--only <a,b> --skip <a,b> --bail --json --changed --all --include <a,b>
--digest --strict (zero checks running = exit 2 — use wherever checkride gates)
--base <ref> --concurrency <n> --config <path> --out <dir>
--defaults <path> --discover --pins <mise.toml> --static-only (consumer runs)
checkride init Set up a project (new or existing — auto-detected).
--lang ts|go|rust --shape flat|monorepo|hybrid (ts), module|workspace (go),
crate|workspace (rust) --module <path> (go)
--name <n> --scope <@s> --license <id> --author <a>
--add <a,b> (existing mode) scaffold blessed configs for the named empty slots
--baseline (existing mode) grandfather current debt instead of disabling slots
--force overwrite existing files (new mode), or refresh an edited
AGENTS.md stanza (existing mode), instead of refusing
--hook <a,b> write only these hooks (gate | dirty | protect)
--no-hook skip writing the agent hooks
--remove-hook <a,b> remove these installed hooks (entry + generated script)
--harness <a,b> write them for these harnesses (claude | cursor; default: detected)
--dry-run plan only; write nothing
checkride doctor Verify environment + every slot's status (read-only, exit 0/1).
checkride fix Run every active adapter's fix command (oxlint --fix, ...).
checkride baseline Record current diagnostics as a committed baseline.
--versions re-record the copy each check's tool ran, adding no key
--only <a,b> capture just these checks; every other check keeps its entry
--skip <a,b> capture every check but these
checkride baseline diff [<ref>] List the keys the working tree's baseline adds
or drops against <ref>'s (default HEAD); --json for JSON.
checkride recover Rebuild checkride.baseline.json candidates from git history
and restore one (bare `recover` lists them with deltas).
--pick <n|sha|union> apply a candidate — additive union with the current file
--exact write the picked snapshot verbatim instead of the union
--dry-run preview the per-slot delta; write nothing
--depth <n> commits of file history to walk (default 25)
checkride target add <name> --lang <pack> --root <dir>
Declare a target; a repo without targets converts, its
implicit target becoming one of its pack, app at .
(--root-name <n>), unless its root marks no pack and
configures no tool.
--add <a,b> name the blessed tool (a ts, python or terraform target also gets its config)
--dry-run print the changes; write nothing
checkride agent-setup Add the "check" alias, AGENTS.md stanza + agent hooks to a repo.
--hook <a,b> write only these hooks (gate | dirty | protect)
--no-hook skip the hooks (write only the stanza)
--remove-hook <a,b> remove these installed hooks; with --no-hook, remove only
--harness <a,b> which harnesses to write for (claude | cursor; default: detected)
--force refresh a stanza that has local edits (refused otherwise)
checkride hooks add [a,b] | remove <a,b>
Install or remove agent hooks on their own, without
touching AGENTS.md (--harness <a,b>, --dry-run).
checkride gate Run the check script as a stop gate, answering a harness's
hook protocol. The generated hook scripts invoke this.
checkride triage [<repo>] Triage a red gate: run it, then read .check/ as a
bounded report (--out <dir> for another output directory).
checkride qa [<repo>] Read the quality artifacts a previous run left (--out <dir>).
During iteration, narrow the loop: checkride --bail, checkride --only types,lint, checkride --changed.
Output streams: human-readable progress goes to stderr; stdout carries machine
output only — the summary JSON under --json, mirroring .check/summary.json.
So checkride --json produces clean JSON on stdout that is safe to pipe, and the
default run leaves stdout empty.
A slot is a role in the pipeline (order matters — cheapest first). An
adapter is a concrete tool that fills a slot. Each slot has one blessed
default; alternates are wired so checkride can run them, but init only
generates config for the blessed default. A slot's scope says what it
checks: a target slot checks one language's code, once for each target
whose pack fills it, and a repo slot checks the repo as a whole, once. The
tools below are the ts and repo packs'; the bash, go, rust, python and
terraform packs fill the target slots with their own (see
Packs and pins).
| Slot | Scope | Role | Blessed default | Alternates |
|---|---|---|---|---|
types |
target | Type checking | tsc --build |
— |
format |
target | Formatting (opt-in) | prettier |
biome-format |
lint |
target | Linting | oxlint |
biome, eslint |
struct |
repo | Structural rules (deep modules by default) | ast-grep |
— |
dead |
target | Dead code, deps, cycles, boundaries | fallow (dead-code) |
knip |
dupes |
target | Code duplication (opt-in) | fallow (dupes) |
— |
health |
target | Complexity / maintainability (opt-in) | fallow (health) |
— |
test |
target | Tests + coverage | vitest |
jest |
docs |
repo | Markdown lint | markdownlint-cli2 |
rumdl |
links |
repo | Relative markdown links resolve | built-in | — |
spell |
repo | Spelling | cspell |
typos |
prose |
repo | Writing style (opt-in) | vale |
— |
mutation |
target | Mutation testing (opt-in) | stryker |
— |
security |
target | Dependency audit (opt-in) | pnpm audit |
— |
build |
target | Runs the consumer's build script (opt-in) | <pm> run build |
— |
publint |
target | Package publishing lint (opt-in) | publint |
— |
attw |
target | Type resolution across module systems (opt-in) | attw --pack |
— |
pack |
target | Tarball ships the right files (opt-in) | built-in | — |
smoke |
target | Built package imports cleanly (opt-in) | built-in | — |
snippets |
target | Tagged doc fences type-check (opt-in) | built-in (tsc) |
— |
unclaimed |
repo | Every toolchain marker sits in a target (with targets) |
built-in | — |
Most of these tools you already know. The one unfamiliar name, fallow, is a
Rust-native codebase-analysis tool covering dead code, duplication, and
complexity; the tools guide covers it and every other
adapter.
Zero-config: for each slot, checkride runs the first adapter whose config file
exists, and skips slots with no detected tool. The Node-free alternates
rumdl and typos are detected only where a tool can come from mise.toml
or PATH: a repo that declares targets, or one whose root marks another
pack. A repo checked as ts runs its tools through its package manager alone,
which can't install them. The core has no runtime dependency on any
checked tool: it spawns each one through its pack's pin chain (the project's
package manager, a language's own pin file such as go.mod, a mise.toml,
then PATH), so the project owns the pinned tool versions.
Opt-in slots (format, dupes, health, prose, mutation, security, build,
publint, attw, pack, smoke, snippets) stay out of the
default run so adopting checkride — or bumping its version — never turns a repo red on a
check it didn't ask for. Turn one on with --include <slot> (or --all), or by naming
it in checks: an explicit entry like "format": "prettier" opts the slot into every
run. checkride fix then runs its write form (for example prettier --write) alongside the
other fixers. format sits before lint so the tree is tidy before the linters look.
unclaimed isn't opt-in but inactive until the config declares targets:
then it fails when a toolchain marker, such as a package.json or a
go.mod, sits outside every target of its language, a directory of code no
check reads. Without targets it stays out of
every run, --all included, and naming it with --only unclaimed records it
as skipped with the reason.
To configure a slot without opting it in — say, give attw a --profile but keep it
in the full sweep only — add "optIn": true to its entry: it stays out of the default run
and runs solely under --all/--include <slot>. The same field demotes a normally default
slot (or a heavy custom check like test:integration) to full-sweep-only; "optIn": false
does the opposite, forcing a slot into the default run.
publint and attw are the library-publishing pair — turn them on for a package you
ship to npm to make "the published artifact is correct" part of your definition of done.
publint lints package.json's publishing surface (exports, files, types); attw runs
attw --pack to check that your types resolve under every module system (--format json,
captured to .check/attw.json). Both stay opt-in so apps that never publish don't run
them.
A pack fills the target slots for one language: its tools, resolved
through the pins the repo already keeps, and an environment that keeps its
checks offline. A repo whose root marks one language runs that pack with no
config. A repo with several, or one language in several places, declares
targets, and each target runs its pack's checks from its
own root, as web:lint or api:test. Each pack's blessed tool per slot, with
the alternates and every argv in Packs and pins:
| Pack | Root marker | types |
lint |
dead |
test |
Opt-in | Pinned by |
|---|---|---|---|---|---|---|---|
ts |
package.json, tsconfig.json |
tsc | oxlint | fallow | vitest | prettier, fallow's dupes and health, stryker, pnpm audit, the publish bundle | the lockfile |
go |
go.mod, go.work |
go build | golangci-lint | deadcode | go test | gofmt, govulncheck | go.mod tool directives, then mise.toml, then PATH |
rust |
Cargo.toml |
cargo check | clippy | cargo-machete | nextest | rustfmt, doctests, cargo-deny, cargo-mutants | rust-toolchain.toml, then mise.toml, then PATH |
python |
pyproject.toml, setup.cfg, setup.py |
mypy | ruff | — | pytest | ruff format | uv.lock, then mise.toml, then PATH |
terraform |
.terraform.lock.hcl, *.tf |
terraform validate | tflint | — | — | terraform fmt, trivy | .terraform-version or .opentofu-version, then mise.toml, then PATH |
bash |
none: a target's shell files | — | shellcheck | — | — | shfmt | mise.toml, then PATH |
The repo pack's slots (struct, docs, links, spell, prose and
unclaimed) run once for the whole repo, whatever languages it holds, with
the tools in the table above. A check never downloads anything: Go, cargo, pip
and uv run with their downloads switched off, so the repo fetches first
(go mod download, cargo fetch, uv sync, terraform init -backend=false),
and a check that needs the step names it. Each check records the copy that ran
as pin_source and tool_version in the summary, a go check the Go release
it ran on as toolchain_version, and requirePins: true fails a check whose
tool only PATH provides.
checkride is package-manager-agnostic. It resolves the repo's manager from the
packageManager field in package.json first, then from the lockfile —
pnpm-lock.yaml, bun.lock, bun.lockb, yarn.lock, package-lock.json,
first match wins — and falls back to pnpm when neither exists. With
targets, a target's checks run under the manager nearest its root: the first
packageManager field or lockfile from there up to the repo root. Every
command checkride generates is spelled for that manager: the check alias the
hooks run, the AGENTS.md stanza, and each adapter's canonical pnpm exec <tool>
(which becomes npx --no-install, yarn, or bunx --no-install). doctor
reports the detected manager on its first line; init and agent-setup report
it under their headline. Two of the ts pack's slots are manager-specific: its
opt-in security slot is pnpm audit (pnpm only), and the pack slot's
dry-run speaks pnpm's and npm's pack --json (pnpm or npm); each reports
itself unavailable elsewhere rather than failing. The go, rust and
terraform packs fill security with govulncheck, cargo-deny and trivy. The
lockfile-to-exec table and the "a check never fetches a tool" rule live in
Tools § Package managers.
Every run writes to .check/, or to the directory --out names. This is a
public API for agents — the promised surfaces (exit codes, summary shape,
flags, exports) are spelled out in docs/contract.md and
locked by a test/contract/ suite.
The summary shape is published as a JSON Schema at
schema/checkride.summary.schema.json;
fields are additive-only under a given schema_version.
-
summary.json— the aggregate report:A check that sat out carries
skipped: trueand areason, and a check refused before its tool spawned (a tool the project never installed, say) fails with itsreason. With targets, the summary lists them undertargets, and each target's check is named<target>:<slot>(api:lint).ok: truewithchecks_run: 0means nothing was verified — every slot sat out (no tool detected, disabled, or skipped). That state is a vacuous green: a pass that verified nothing. The run warns loudly on stderr when it happens, and--strictturns it into exit 2, so a gate can never mistake "nothing ran" for "everything passed". Anything that gates on checkride (CI, commit hooks) should pass--strict. -
<slot>.json— the raw tool JSON when stdout parses as JSON, or<slot>.jsonlfor a tool that prints JSON lines, such asgo test -json; otherwise<slot>.stdout.txt/<slot>.stderr.txt. A target's checks write the same files under.check/<target>/. Tools that write their own files (vitest--outputFile, stryker) keep doing so.
When a baseline masks a slot's findings, that check gains an additive
"baselined": <n> field counting the grandfathered findings; it is absent on
runs with no baseline, so schema_version is unchanged. A check that fails on
findings the baseline doesn't grandfather gains "new_count": <n> and "new",
the first 50 of their keys, so a reader can tell what failed it from the debt
it carries.
digest.md— written only under--digest: a token-bounded Markdown excerpt of the failing slots, so an agent working through a big red repo reads a capped index instead of every raw file. Each section lists the first findings (reusing the baseline fingerprint extractors, or a tail of raw text for slots without one) and links the authoritative.check/<slot>.json, which is never modified. A check a baseline masked lists the findings it didn't grandfather, and counts the rest; ago testcheck lists its failing tests, with what each printed, and any still running when a timeout stopped it. The bound: ten findings per slot, 8 kB overall — roughly two thousand tokens; past the cap, remaining failing slots are named but not rendered, and a first section longer than the cap on its own is cut where it runs out. It truncates, never normalizes; a green run leaves no digest (any stale one is removed), so its presence always means "this run failed". A red run prints the same text on stderr, last, below thereport:anddigest:lines and any warning orhint:beneath them, so an agent reads what failed in the run's own output. The report never prints it on stdout, so the machine-output split holds, but the summary carries the same text asdigest, so a--jsonrun, which prints no report, hands it over inside the JSON on stdout.
To debug a failure: start from the run's own lines. Beneath each red check's
status line, the run says why it failed when the check says, then what it
found: the findings a baseline didn't grandfather, a go test run's failing
tests, or, where neither lists anything, the first finding its parser read
that can fail it, with how many more, as src/a.ts:3:7 Variable 'x' is declared but never used. (+4 more), or as tsc printed it. Then read
summary.json to find the failing slot, and that slot's raw output for
structured diagnostics. On a large repo, run with --digest for a capped
starting point, printed below the report and kept in digest.md.
The package root is also a Claude Code plugin — two skills that consume the contract above, bundled in the same npm package and versioned with it. This repo is its own marketplace, so the install needs nothing else:
/plugin marketplace add robmclarty/checkride
/plugin install checkride@checkride
/checkride:check— triage a red gate. It runs the repo's owncheckscript (ormise exec -- checkridein a repo with nopackage.json), branches on the promised 0/1/2 exit split, and names one root cause plus what it is deliberately not reading. It covers the corners a summary read by hand gets wrong — exit 2 versus exit 1, vacuous green, a narrow run's green,baselinedcounts,skippedslots,exit_code: -1, stale artifacts — then opens exactly one raw file./checkride:qa— read the quality signal. It foldsmutation.json,dead.json,dupes.jsonandhealth.jsoninto a report under 8 kB, runs nothing, and reports which dimensions were never measured rather than guessing at them.
Both are readers: no new flag, no config, no hook. They exist because
reading .check/ by hand is unbounded (mutation.json is over 12 MB in this
repo) and credulous (every run overwrites summary.json, so an ok: true on
disk may describe three of twenty-one slots from a while ago). The readers ship
prebuilt in dist/, so installing the plugin needs no build step, and they run
without it too: checkride triage and checkride qa print the same reports.
Full details in docs/plugin.md.
checkride.config.json is optional — add it only to deviate from the defaults:
{
"$schema": "https://raw.githubusercontent.com/robmclarty/checkride/v0.15.0/schema/checkride.config.schema.json",
"extends": "@acme/checkride-preset", // inherit a shared preset, then override below
"timeout": 1200, // global per-check timeout in seconds (default: 600)
"changedBase": "origin/develop", // what --changed measures from (default: origin/main, else HEAD)
"checks": {
"format": "prettier", // enable the opt-in format slot (blessed: prettier)
"lint": "biome", // pick an alternate adapter
"spell": false, // disable a slot
"test": { "use": "vitest", "timeout": 0, "changedArgs": ["--changed", "{base}"] },
"tidy": { // a bespoke custom check that runs FIRST, ahead of the built-ins
"command": "pnpm",
"args": ["exec", "some-formatter", "--write"],
"order": "first"
},
"licenses": { // a custom check (runs last by default)
"command": "node",
"args": ["scripts/check-licenses.mjs"],
"description": "License audit", // rendered beside the check in the output
"note": "Legal asked for this in RFC-114; drop it when the SBOM job lands."
}
}
}Two strings, deliberately not one. description is user-facing: it is the
text a run prints beside the check in its status line and records in
.check/summary.json, so it wants to be short. note is the opposite —
maintainer commentary that never renders anywhere, the JSON-comment this file
otherwise has no room for. Put "why is this check here", "revisit after the
2.0 upgrade", or a ticket number in note and keep the output readable.
note is accepted on any check entry — an adapter override ({ "use": … }) or
a custom check ({ "command": … }) — and at the top level of the file for a
note about the config as a whole. It is validated as a string (a typo is a
config error, not silence) and then dropped: no code path carries it onto an
adapter, so it cannot reach the CLI output or the summary.
The "$schema" pointer is optional but recommended: it turns on validation and
autocompletion for checkride.config.json in editors that understand JSON
Schema (VS Code and friends). checkride init writes a version-pinned pointer
into the config it generates; the schema itself ships in the package at
schema/checkride.config.schema.json.
A repo with more than one language declares its parts as targets, each a
pack and a root. packs sets one pack's checks for every target of it, and
unclaimed.ignore names the markers no target has to claim:
{
"targets": {
"web": { "lang": "ts", "root": "web" },
"api": { "lang": "go", "root": "api", "checks": { "lint": "staticcheck" } },
"infra": { "lang": "terraform", "root": "infra" }
},
"packs": {
"terraform": { "checks": { "format": "terraform-fmt" } } // opts every terraform target into fmt
},
"requirePins": true, // a tool only PATH provides fails its check
"unclaimed": { "ignore": ["package.json"] } // the root package.json only installs tools
}A target's slot takes its tool from the target's own checks, then
packs.<pack>.checks, then the top level, then detection at the target's
root. Once targets exist, a top-level entry for a target slot may only turn it
off or tune it (optIn, timeout, order), since the tool depends on the
pack. checkride target add writes a target for you, and
Targets holds the rules.
A custom check (one keyed by a name that isn't a built-in slot) runs after the
built-in catalogue by default. Set "order": "first" to run it ahead of every
built-in check instead — handy for a bespoke formatter that should normalize
the tree before the linters and tests look at it. "order": "last" is the explicit
form of the default. Within each group, custom checks run in the order they appear
in the config.
A custom check that can't run everywhere can say so. Give it a skipExitCode
(an integer from 1 to 255; 77 is the usual one) and exit with that code, and
the run records the check as skipped, not failed, with its last stderr line as
the reason. A skipped check stays out of checks_run, so --strict still
refuses a run in which every check skipped itself. See
Tools § A custom check that can't run everywhere.
For formatting, the blessed format slot (prettier, or biome-format) is the paved road —
enable it with "format": "prettier" and checkride fix writes formatting for you.
The order: "first" custom-check hatch coexists with it, for a one-off formatter the
slot doesn't cover; the slot didn't retire it.
Add "detect": ["<file>", …] to a custom check to gate it on marker files: it
runs only when at least one listed file exists in the repo, and is skipped —
skipped, not failed — otherwise. This keeps a shared config safe across repos
that don't all use the same tools: a check for a tool a given repo lacks quietly
stands down instead of lighting up red. detect applies only to custom checks
that run alongside the catalogue; a custom check that fills a built-in slot
always runs.
Use "extends" to inherit a shared preset — a file path ("./base.json") or an
installed package ("@acme/checkride-preset"), or an array of them to layer
several. Bases merge left to right and your local config wins over all of them:
objects deep-merge (so overriding one field of a check keeps the rest), while
arrays and scalars replace outright — arrays are not concatenated. A
preset's top-level tool pick that a repo can't take at its top level, because
it declares targets or its root's pack lacks that tool, lands in that tool's
packs.<pack>.checks block instead, so "lint": "oxlint" in a fleet preset
lints the TypeScript repos and leaves a Go repo's golangci-lint alone. Pair it
with detect above to publish one org-wide preset that stays safe across repos
that don't all use the same tools. An extends that can't be found, or a config
that extends itself in a loop, fails fast with
invalid checkride.config.json: <reason>.
For operating a preset across many repos — versioning it, rolling a rule out as a release, and automating the per-repo bump — see Running a fleet with shared presets.
A per-check timeout guards against a hung tool, and it is on by default: a
definition-of-done gate that can hang forever fails its one job on the worst
day. The default cap is a generous 600 seconds per check; a check that hits
it gets SIGTERM (then SIGKILL after a short grace) and is recorded as failed
with a timed out after 600s note — red, never vacuous. Tune it with a global
timeout (seconds), override it per check ("timeout": <n>), or set
"timeout": 0 (globally or per check) to disable the cap. Give dead and
test a higher cap — or 0 — on a large repo where they legitimately run
long. The mutation adapters, stryker and cargo-mutants, ship uncapped.
Adopting checkride on an existing repo shouldn't be a cleanup project. A baseline grandfathers the diagnostics a repo has today so day-one runs pass, while any new diagnostic still fails — "don't make it worse" as the definition of done for legacy code.
checkride baseline # record current diagnostics into checkride.baseline.jsoncheckride.baseline.json lives at the repo root beside checkride.config.json and
is committed — it must be in version control to work. It records a per-slot set
of stable fingerprints (a file:rule:message key that survives line moves), not
raw output, and how many findings each key grandfathers when it's more than one:
{
"schema_version": 1,
"slots": {
"lint": ["src/legacy.ts:typescript(no-explicit-any):Unexpected `any`. Specify a different type."],
"spell": ["docs/old.md::teh"]
},
"counts": {
"lint": { "src/legacy.ts:typescript(no-explicit-any):Unexpected `any`. Specify a different type.": 2 },
"spell": {}
}
}A target's checks key under their own names, such as api:lint, and every
key names its file from the repo root, so a key stays valid wherever the tool
ran from. A key has no line in it, so two of the same finding in one file
share it: counts says how many findings each key grandfathers, two above,
and one for a key it doesn't list. A third any in that file is new, and
fails. A file an earlier release captured has no counts, and each of its
keys grandfathers any number of findings, as every key did then, until the
next checkride baseline counts them.
Once it exists, every normal run is baseline-aware:
- Each slot's current findings have the grandfathered ones subtracted. A slot is
green when only baselined findings remain, and fails listing only the new
ones — the raw
.check/<slot>.jsonstill holds everything, and the failing check gains a"baselined": <n>count. - The baseline is a ratchet: fixing a grandfathered finding prunes it from the
file (it only ever shrinks), so debt can't silently creep back. Fixing one of
a key's several findings lowers its count the same way. A partial run
(
--only,--skip,--changed, or a--bailthat stops early) never prunes — it can't tell an unobserved finding from a fixed one, so it leaves the baseline untouched. - Never add to the baseline to make a check pass; fix the finding, or re-run
checkride baselinedeliberately to re-grandfather. - A key carries the tool's own wording, so another version of a tool can key
the same debt differently. The file's
captured_withrecords which copy of each check's tool captured its keys: the pin source and version a run records, such as{ "pin_source": "nix", "tool_version": "2.11.4" }, and for a go check the Go release it ran on, astoolchain_version, since a new Go minor can re-key findings too. A run with another copy still fails on a new key, but its digest and abaseline:line on stderr name both copies, and the ratchet prunes none of that check's keys, since the copy that captured them may still report them. Run under the same copy, or, once the repo pins the new one, runcheckride baseline --versions: for each check that reported no key the file lacks, and no key more often than the file counts it, under a copy the repo pins, it records that copy and prunes the keys it no longer reports. It never adds a key, so an agent may run it; a check with new keys is left for a human's review andcheckride baseline.
Two operational notes. Merge conflicts: the file is canonical (slots and
keys are sorted), so parallel branches usually merge cleanly; when they do
conflict, resolve by keeping both sides' entries and running a full
checkride — the ratchet prunes anything already fixed, so an over-generous
union self-heals, while a dropped entry that still fails simply resurfaces as
a red check. When a merge went badly anyway — entries deleted wholesale, or a
file so mangled it no longer parses (a run says so on stderr and treats it as
no baseline at all) — the run diagnoses what it can: a red run whose "new"
findings were grandfathered until a recent commit prints a hint: line naming
that commit. checkride recover then rebuilds candidate states from the
file's git history and restores one additively; see checkride recover --help.
Deliberate re-baseline: checkride baseline re-records
everything currently failing — including brand-new debt you might rather
fix — so treat re-running it as a reviewed change: do it deliberately (say,
after adopting a stricter rule set) and read the checkride.baseline.json
diff in the PR like code. checkride baseline --only <checks> (or --skip)
re-records just the checks it selects and leaves every other check's entry as
it was, so adopting one check per commit touches only that check in the diff.
checkride baseline diff [<ref>] lists the keys the
working tree adds and drops against <ref>'s copy (default HEAD), per check,
and each key whose count changed, or as JSON under --json, so a reviewer sees
exactly what a change grandfathers.
Only slots whose tool has a parser the baseline keys with participate, and a
capture doesn't run the others: tsc for types, oxlint, eslint, biome,
golangci-lint, staticcheck, clippy, shellcheck, ruff and tflint for lint,
ast-grep for struct, markdownlint-cli2 and rumdl for docs, cspell and typos
for spell, vale for prose, and fallow for dead, dupes and health,
each keying only the findings that can fail the slot. A custom check whose
name is one of these adapters' is keyed the same way, so an eslint rule set
run as a check of its own, with "name": "eslint", can be adopted with
findings it already has. The file's shape is a promised surface — see
the contract. A crash or empty output is
never masked — a slot only goes green when there are findings and all of them are
grandfathered.
A tsc key holds the error's file, its code and its message's first line, with
what tsc filled into it written …: each quoted span, the list a "following
…:" message ends on, and every number, since that text changes on edits
elsewhere: src/a.ts:TS2322:Type '…' is not assignable to type '…'., or
src/a.ts:TS2554:Expected … arguments, but got …. Two such errors in one file
share a key, and counts holds both. Since the key no longer says which name
or type an error is about, a run lists each new key's findings beneath it as
tsc printed them, the first ten keys of a check and then how many more, and so
does the digest. A run that
stopped tsc short of some errors masks nothing and prunes nothing, and its
check's reason says why: a syntax error, which leaves its whole project
unchecked, a tsconfig tsc rejected, a reference it didn't build (TS6305),
a tsc killed or crashed part way, or any error from a build that skips each
project depending on one with errors, under --stopBuildOnErrors or, before
TypeScript 5.6, under a --build of project references, which 5.6 and later
check whole. The other type checkers, go build,
cargo check, mypy and basedpyright, are never grandfathered, since each can
stop before it reports every error, and neither are tests, so those slots
never appear in the baseline.
To adopt on an existing repo, checkride init --baseline grandfathers today's
failing (fingerprintable) slots into the baseline and keeps them enabled, instead of
writing them off as false; a failing slot with no extractor still falls back to a
disable. A slot that fails before it reaches a verdict, a declared tool nothing
installed, a setup step not yet run, a crash or a timeout, is neither: init
leaves it on, names what stopped it, and a checkride baseline once it runs
grandfathers what it finds. A tsc run that stopped short is left on too, on a
line of its own naming what stopped it: once that's fixed, checkride baseline grandfathers the rest.
init scaffolds three TypeScript shapes. They share everything except
tsconfig.json, fallow.toml, and pnpm-workspace.yaml:
- flat — a single package using the deep-modules layout under
src/. - monorepo — a pnpm workspace of
apps/*(deployable leaves) andlibs/*(reusable internals); libs may not import from apps. - hybrid — a root app in
src/plus internal packages underpackages/*.
init --lang go scaffolds a Go module or workspace, and init --lang rust
a Rust crate or workspace, each with no package.json: a mise.toml pins
checkride and its tools, the config sets requirePins, and the pack's dialect
rules ship under rules/. See getting started.
Every generated shape is green out of the box — an end-to-end test enforces it.
At runtime, a repo without targets is one project: checkride runs each tool
once, from the repo root — one pipeline, one .check/ — and workspace
awareness comes from the tools' own configs, which is what the monorepo
scaffold sets up: tsc --build follows the root tsconfig's project
references, and vitest, oxlint, ast-grep, and the rest walk the whole tree. To
check each part from its own root instead, declare
targets: each runs its pack's checks from its root, as
web:lint or api:test, writing under .check/<target>/. --only web runs
one target, and --changed skips the ones a change didn't touch.
init scaffolds one default convention — deep
modules, whose boundaries are enforced by ast-grep
(the struct slot) and fallow:
- A module is a unit of encapsulation. A single file is a module; promote it to
a folder with a barrel
index.tswhen it grows internals worth hiding — a one-file folder is just ceremony. - A folder module's
index.tsis its only public surface: it re-exports, it holds no logic. Siblings import it via'../<sibling>/index.js', never its internals. - Named exports only; no classes;
.jsextensions on relative imports (NodeNext); tests colocated with the code they cover.
None of this is hardcoded into checkride. The struct slot just runs whatever
ast-grep rules live in your repo's rules/ directory (the barrel rule is
rules/no-deep-sibling-import.yml), so the convention is whatever those files
encode:
- A different convention, same language. Edit or replace the rules to match
how your repo is organized — public/private folders, a naming prefix, an
allowed-import list — and
structenforces the new shape unchanged. - Another language. ast-grep is polyglot; set each rule's
language(typescript,python,go,rust, …) and the same slot covers boundaries there.init --lang goandinit --lang ruststart you off with their packs' dialect rules underrules/go/andrules/rust/(no dot imports, import aliases orinitoutsidepackage mainin Go; no glob imports or glob re-exports in Rust), andexamples/python-projectwrites its rules for Python. - Beyond ast-grep. When a rule can't be expressed in ast-grep, or lives in
another ecosystem's tooling (
import-linter,depguard,dependency-cruiser), wire it as a custom check.
checkride ships an opinionated default; it does not lock you to it.
Every push runs the full suite — unit, contract, and end-to-end (generated projects, installed and checked for real) — on macOS and Linux, at Node 22.18.0 (the exact supported floor), Node 24 and Node 26. The e2e suite exercises the full package-manager quartet, pnpm, npm, yarn, and bun, and every language pack against its real tools, which CI installs at pinned versions through mise, so no pack's cases are skipped. Windows is not tested and not claimed; it waits for a real consumer who needs it.
The promised surfaces (exit codes, summary.json shape, flags, exports) are
locked by a dedicated contract suite — see
docs/contract.md. Beyond line coverage (enforced at 70%),
the test suite is itself tested: Stryker mutation testing runs with a hard
floor of 68, and the last full run scored 83% (pnpm mutation reproduces
it, in about three hours).
See AGENTS.md for the contract agents follow, CONTRIBUTING.md for the release ritual, and CHANGELOG.md for release notes.
The CLI is the primary interface, but the run, init, doctor and fix are
also functions. Import from the package root to run the pipeline inside your
own tooling; the result carries the same summary written to
.check/summary.json, plus the exit code the CLI would return:
import { runChecks, type RunResult } from 'checkride';
const result: RunResult = await runChecks({ cwd: process.cwd(), strict: true });
process.exitCode = result.exitCode;runChecks sits alongside runInit, runDoctor, runFix, selectChecks
and the loadConfig/resolveChecks pair; the adapter registry (SLOTS,
ADAPTERS), the language packs and the tools they run (PACKS, TOOLS),
SCHEMA_VERSION, DEFAULT_TIMEOUT_SECONDS and every public type are
re-exported too. A second entry point, checkride/findings, parses a run's
raw artifacts into findings, with parseFindings, findingsFor and
isParseable. The full surface is the package's exports, and it is part of
the contract.

{ "schema_version": 1, "timestamp": "…", "ok": true, "checks_run": 8, // checks that actually executed "total_duration_ms": 4200, "run": { "partial": false }, // the flags given, and whether they narrowed the run "git": { "head": "…", "dirty": false }, // the commit the checks read, in a git work tree "checks": [ { "name": "lint", "adapter": "oxlint", "description": "…", "ok": true, "exit_code": 0, "duration_ms": 470, "output_file": "lint.json", "origin": "detected", "detected_by": ".oxlintrc.json", // why it ran "argv": ["pnpm", "…", "exec", "oxlint", "--format=json"], // what it spawned "pin_source": "js-lockfile", "tool_version": "1.87.0" } // which copy ran ] }