CHECKRIDE
DOCS
Home Languages Agents API GITHUB
{{ grp.title }}
{{ it.label }}
Looking for every flag, the summary schema, or the exports? API reference →

Checking Go, Rust, Python, Terraform or shell code? Languages →
GET STARTED

Getting started

The whole tool reduces to one rule: pnpm check is the definition of done. Exit 0 means complete. Anything else means it is not.

Prerequisites

Three things outside the project: Node ≥ 22.18, a package manager (pnpm ≥ 9 is the default; npm, yarn, and bun work too), and git. Everything else — oxlint, ast-grep, fallow, vitest — is an ordinary project devDependency. A repo without Node needs mise in their place: it installs checkride, its Node and the language's own tools (see a new Go or Rust project, and Languages). Verify any time:

shell
$ pnpm exec checkride doctor

doctor is read-only: it checks Node/pnpm/git, that the project is installed, and reports every slot's status. Exit 0 means you are ready to run.

An existing repository

shell
$ pnpm add -D -E checkride
$ pnpm exec checkride init

init is additive: it inventories the tools you already have, writes only what is missing (a config, a check script alias, the AGENTS.md stanza, the agent hooks), gitignores .check/, and never overwrites an existing tool config. For a repo with real existing findings, prefer init --baseline — see the baseline. To scaffold a tool you don't have yet, name its slot: init --add struct,dead, then pnpm add -D @ast-grep/cli fallow.

A root without a package.json is existing too when it marks another pack — a go.mod or go.work, a Cargo.toml, a pyproject.toml, setup.cfg or setup.py, or a .terraform.lock.hcl or .tf file — and init adopts it through that pack. In a repo of several parts it also proposes the targets the files suggest: a ts target at the root when it holds a package.json or pnpm-workspace.yaml, one go, rust, python or terraform target per root, and a bash target for shell scripts no other target holds. It prints them as a targets block and writes them as target add would (--dry-run prints and writes nothing); a config that already declares targets is left alone. Each target then checks its part from its own root, its checks named <target>:<slot>, such as web:lint.

Under another package manager the two steps are the same shape — npm install -D -E checkride && npx checkride init, yarn add -D -E checkride && yarn checkride init, or bun add -d -E checkride && bunx checkride init. init detects the manager and spells the alias, stanza, and hooks for it.

A new project

shell
$ pnpm dlx checkride init --shape flat --name my-app
$ pnpm install
$ pnpm check

From an empty directory, init generates a complete repo, green out of the box. Pick a shape: flat (single package, deep-modules layout under src/), monorepo (a pnpm workspace of apps/* and libs/*), or hybrid (root app plus internal packages/*). Preview with --dry-run.

Unit tests sit in a __tests__/ folder beside the code they cover, and tests that span modules go under test/, in integration/, e2e/, contract/, helpers/ or fixtures/; the struct check holds the layout. The checkride.config.json it writes names every check it set up, so each slot's status line ends in [config]. One entry fills no slot: typecheck-tests, a [custom] check that type-checks the tests outside src/.

The generated project is a pnpm project: its engines field and pnpm-workspace.yaml assume it. Under npm, yarn, or bun, create the package.json first and take the existing-repository path above.

A new Go or Rust project

shell
$ mkdir my-api && cd my-api
$ pnpm dlx checkride init --lang go --shape module --module github.com/acme/my-api
$ mise trust
$ mise install
$ go get -tool golang.org/x/tools/cmd/deadcode@v0.52.0
$ mise exec -- checkride

--lang go or --lang rust scaffolds a project with no package.json, which mise runs. Pick a shape with --shape: module or workspace for Go (--module names the module, example.com/<name> by default), crate or workspace for Rust. Each writes a mise.toml that pins checkride as npm:checkride beside a node and every binary tool its checks run, and a checkride.config.json with one target, app, at the root, naming every slot, with requirePins: true, so a tool nothing pins fails its check instead of running whatever PATH holds. The struct check runs the pack's dialect rules from rules/go/ or rules/rust/. A check never fetches anything, so init prints the steps that need the network.

mise trust comes first because checkride's pin sets a tool option, allow_low_downloads = true, which mise won't load from a file it hasn't trusted. The daily command is then mise exec -- checkride, and the stanza and the gate spell it that way. A Python or Terraform repo, or a Go or Rust one you already have, adopts through init in place; Installing checkride shows the mise.toml pin to add first.

Next: The daily loop →
Working with agents →
NO LOCK-IN
checkride's whole footprint is the devDependency, two config files, the script alias, the stanza, and the hook. Remove them and every tool keeps working untouched — you've merely dropped the orchestrator. The worst case of adopting checkride is ending up where you already are.
DAILY USE

The daily loop

Once set up, daily usage is one command regardless of the tool's name, because init writes a check script alias.

shell
$ pnpm check

That runs the full pipeline — types, lint, structure, dead code, tests, docs, links, spelling — cheapest checks first. Exit 0 and you are done. Each check that runs ends its status line on stderr with a tag that says why it runs: [config] when checkride.config.json names it, [detected: <file>] when detection picked its tool, [built-in] or [custom].

Narrow the loop while iterating

shell
$ pnpm check --bail # stop at the first failing check
$ pnpm check --only types,lint # run just the named slots
$ pnpm check --only web:lint # one target's slot, or name the target alone
$ pnpm check --changed # affected-only mode

--only and --skip each take a list of tokens: a slot, a custom check, a target, or <target>:<slot>. A token that names nothing exits 2 rather than running less than you asked for. Run the full pnpm check once at the end to confirm green. Some failures have a one-shot fix — checkride fix runs every active adapter's fix command (oxlint --fix, markdownlint-cli2 --fix, …).

The aliases init writes

SCRIPTruns
{{ a.script }} {{ a.runs }}

A new project's package.json gets all seven. In an existing repo, init adds only check, and leaves a check script you already have alone. Under npm, yarn or bun the same scripts run as npm run check, yarn run check or bun run check. A repo with no package.json has no scripts to alias: run mise exec -- checkride with the same flags, as the stanza says.

DAILY USE

When a check fails

Every run writes machine-readable output to .check/. The debugging path is always the same four steps.

1
Read .check/summary.json — the aggregate report. Find the check whose ok is false.
2
Read that slot's raw output — .check/<slot>.json (for example .check/lint.json), or .check/<slot>.stdout.txt when the tool's output is not JSON. A check refused before it ran — a tool that isn't installed, a setup step that hasn't run — says why in .check/<slot>.stderr.txt, and the summary names the reason too. In a repo with targets, a target's check writes under .check/<target>/ instead, as .check/web/lint.json. The raw file is the truth; the summary is an index.
3
Fix the root cause, not the symptom. Fixable lint/format/markdown errors have a one-shot answer: pnpm exec checkride fix.
4
Re-run pnpm check. Never claim a task is finished while it is red.

Big red repo? Use the digest

--digest writes .check/digest.md — a token-bounded Markdown excerpt of the failing slots (ten findings per slot, 8 kB overall, roughly two thousand tokens), each section linking the authoritative raw file. It truncates, never normalizes; a green run leaves no digest, so its presence always means "this run failed". A red run also prints it on stderr, below the report, so an agent sees what failed in the run's own output.

shell
$ pnpm check --digest

Quick triage

SYMPTOMDO THIS
{{ t.symptom }} {{ t.fix }}
ADOPTION

Working with agents

checkride is an agent harness: the goal of init is that a coding agent adopts the "exit 0 = done" rule on its own — and can't finish without it.

How agents pick it up

init writes the contract into two files. AGENTS.md gets a stanza (between <!-- checkride:begin --> / <!-- checkride:end --> markers) stating the rule, how to read .check/ on failure, the module-boundary conventions, and the tight-loop commands. CLAUDE.md gets an @AGENTS.md import, one line added to the end of yours if you already have one, because Claude Code reads CLAUDE.md in AGENTS.md's place once one exists. Claude Code, Codex, Cursor, and Amp load these at session start — the agent runs pnpm check because it read the instruction. That's guidance; the hook below is enforcement. Keep your own edits outside the markers — init rewrites the stanza in place. Not behind your back, though: the begin marker carries a hash of what checkride wrote, so a stanza you have since edited stops the run (exit 2, nothing written) instead of being overwritten. Pass --force to discard the edits and refresh.

Make it a hard gate

checkride writes three agent hooks, for Claude Code and (when detected) Cursor. The stop-gate is the centerpiece: a generated script the harness fires when the agent tries to finish, which runs checkride gate and blocks the stop while the pipeline is red — feeding the message back so the agent keeps working until green. Beside it, dirty marks turns that actually edited (so a no-edit turn can skip the run), and protect denies edits to checkride.baseline.json and .check/ — "never add to the baseline to make a check pass" as enforcement, not advice. To add them to a repo you already set up:

shell
$ pnpm exec checkride agent-setup # alias + stanza + hooks (gate, dirty, protect)
$ pnpm exec checkride hooks add # hooks only — never touches AGENTS.md

Both commands are idempotent and take --harness <a,b>. agent-setup picks hooks with --hook <a,b> or skips them with --no-hook; hooks add takes the names as a list, as in hooks add gate,dirty. Under Claude Code the Stop entry points at the generated script:

.claude/settings.json
{
"hooks": {
"Stop": [{ "hooks": [{
"type": "command",
"command": "sh \"${CLAUDE_PROJECT_DIR:-.}/.claude/hooks/checkride-gate.sh\"",
"timeout": 900
}] }]
}
}

The script runs checkride gate, which runs the repo's check with --strict --digest — <pm> run check under your detected package manager where there's a package.json, mise exec -- checkride where a root mise.toml pins checkride, and the checkride on PATH otherwise — and answers in the harness's own protocol — under Claude Code, exit 2 with the verdict on stderr is what blocks (a plain exit 1 would be treated as non-blocking); Cursor instead reads a JSON reply on stdout. Three verdicts: green (the turn may end), red (blocked, naming what to read), and "could not run" — nothing executed, so it names the environment cause (usually the hook's Node vs an engines pin) instead of pretending the code is red. The fail-closed --strict run also belongs in CI, which protects the branch.

Avoiding duplicate runs

The stanza and the hook both want pnpm check, so a naive setup runs the pipeline twice. The generated stanza already applies the simplest fix: when a stop gate is configured, the hook owns the final full run — the agent iterates with the narrow commands and lets the hook run the authoritative pipeline once at the end. Two levers tune the cost further: the dirty hook plus gate --if-dirty skip the run entirely on turns that edited nothing, and a gate key in checkride.config.json narrows what the stop hook runs (only, skip, changed) — every verdict then says so, so a narrowed green is never reported as a full one. And don't delete the AGENTS.md block to dodge the duplicate — it also teaches the agent how to fix what the hook flags, and it's what non-Claude agents have.

Going deeper — the bundled /checkride:check and /checkride:qa skills, where Claude Code and Cursor genuinely differ, and what to do when the gate says "could not run" — has its own page: the plugin & harnesses →

ADOPTION

The baseline ratchet

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.

shell
$ pnpm exec checkride baseline # record current diagnostics into checkride.baseline.json

checkride.baseline.json lives at the repo root and is committed. It records per-slot fingerprints — a stable file:rule:message key that survives line moves — not raw output. Adopting from scratch? checkride init --baseline does this as part of setup instead of disabling failing slots.

How the ratchet works

Old debt is masked
A slot is green when only baselined findings remain. The raw .check/<slot>.json still holds everything, and the check gains a "baselined": n count.
New debt fails
Current findings have the grandfathered ones subtracted; the slot fails listing only the new ones.
Fixed debt is pruned
A full run drops fixed fingerprints from the file — it only ever shrinks, so debt can't silently creep back. Only a run that observed every check prunes; the list below never does.

Runs that never prune

+A partial run — --only, --skip, --changed, --static-only, or a --bail that stops early. It can't tell an unobserved finding from a fixed one.
+A run that isn't the repo's own gate — --config or --out pointing outside it, --defaults, --pins, or --discover. It never writes checkride.baseline.json.
+A check that didn't run to a verdict — refused, timed out or crashed. Its slot keeps every key, and checkride baseline carries them forward and says so, rather than emptying a slot whose tool never looked.

Review it like code

shell
$ pnpm exec checkride baseline diff # keys added and dropped since HEAD
$ pnpm exec checkride baseline diff main # … or since another ref; --json for JSON

baseline diff compares the working tree's checkride.baseline.json with the one committed at a ref (HEAD by default) and writes nothing. Run it before you commit a re-baseline: every added key is debt you chose to keep.

The rules of the road

Never add to the baseline to make a check pass — fix the finding, or re-run checkride baseline deliberately and read the diff in the PR like code (it re-records everything currently failing, including brand-new debt). The file is canonical (sorted slots and keys), so parallel branches usually merge cleanly; when they conflict, keep both sides' entries and run a full checkride — the ratchet prunes anything already fixed, and a dropped entry that still fails simply resurfaces red.

When a merge goes worse than that — entries deleted wholesale, or the file mangled past parsing — the run says so: a stderr warning for an unparseable file, and a hint: line on a red run naming the commit the "new" findings were grandfathered until. checkride recover then rebuilds candidate states from the file's git history and restores one additively — a plain working-tree edit to review and commit, never a git checkout. Don't fix that with checkride baseline: a re-capture grandfathers brand-new debt along with the lost entries.

Only slots whose tool has a parser the baseline keys with participate: 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. A tsc key leaves out what tsc fills into its message, the types and names it quotes, its counts and the properties a type lacks, which change on edits elsewhere, and a tsc run that a syntax error or a broken tsconfig stopped short masks nothing and prunes nothing. The errors of the other type checkers, each of which can stop before it reports every one, and tests are never grandfathered. A target's check keys under <target>:<slot>, as web:lint, and names each file from the repo root. A crash or empty output is never masked: a slot only goes green when there are findings and all of them are grandfathered.

ADOPTION

Running in CI

One job, one command, exit 0 = done. Copy-paste for a pnpm repo on GitHub Actions — and always pass --strict in CI: a gate should never mistake "nothing ran" for "everything passed".

.github/workflows/check.yaml
name: check

on:
push: { branches: [main] }
pull_request:

jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v6
with: { node-version: 24, cache: pnpm }
- run: pnpm install --frozen-lockfile
# --strict: zero checks running is exit 2, never a silent pass
- run: pnpm exec checkride --strict
# on failure, keep the raw diagnostics for humans and agents
- if: failure()
uses: actions/upload-artifact@v7
with: { name: check-report, path: .check/ }

Notes

+Exit codes: 0 pass, 1 a check failed ("red build"), 2 the harness broke or was misused ("fix the pipeline"). A gate can branch on the difference.
+If the repo uses --changed locally, still run the full set in CI — CI is where the whole pipeline gets observed, which is also what lets a committed baseline ratchet.
+With a committed checkride.baseline.json, CI needs nothing extra: grandfathered diagnostics are masked, new ones fail, and a full green run prunes fixed entries (in the CI workspace only — the developer commits the same prune locally).
+A merge that silently drops baseline entries turns the next run into a wall of "new, not in baseline" red. The run prints a hint: line when those findings were grandfathered until a recent commit, and checkride recover restores them from git history additively. Both need history in the clone — actions/checkout defaults to fetch-depth: 1 — so recover locally, or deepen the checkout.
+Two runs in one job — say, once with the base branch's config and once with the PR's — each take their own --out <dir> (or CHECKRIDE_OUT), so neither overwrites the other's artifacts: checkride --strict --out "$RUNNER_TEMP/pr". A directory outside the repo makes it a consumer run, which never ratchets the baseline; archive <dir> on failure in place of .check/.
+Nothing here is GitHub-specific. On GitLab, CircleCI, Buildkite, Jenkins, or a bare shell: check out, install from the lockfile, run checkride --strict, gate on the exit code, and archive .check/ on failure.

Other package managers

Replace the pnpm steps; the checkride invocation is identical.

variants
# npm
- run: npm ci
- run: npx checkride --strict

# yarn
- run: yarn install --immutable
- run: yarn checkride --strict

# bun
- run: bun install --frozen-lockfile
- run: bunx checkride --strict

A repo without Node

A Go, Rust, Python or Terraform repo with no package.json pins checkride in its mise.toml, so CI installs it with mise. Checks never download, so fetch the language's dependencies first. MISE_TRUSTED_CONFIG_PATHS lets mise read the repo's own mise.toml without a mise trust step.

.github/workflows/check.yaml (steps)
# a repo without Node: mise installs checkride and the pinned tools
env:
MISE_TRUSTED_CONFIG_PATHS: {{ ghWorkspace }}
steps:
- uses: actions/checkout@v7
- uses: jdx/mise-action@v5
- run: go mod download # cargo fetch, uv sync, terraform init -backend=false
- run: mise exec -- checkride --strict

The security slot (pnpm audit) is pnpm-specific and reports itself unavailable under the other package managers; a go, rust or terraform target fills it with govulncheck, cargo-deny or trivy instead.

ADOPTION

Checking a repo you don't own

A pull-request reviewer, a CI job or an agent often runs checkride over a checkout it didn't write, with its own config, defaults, pins and output directory, and reads summary.json and the raw artifacts as evidence. The repo's own gate is unchanged by any of it.

NOT A SANDBOX
checkride runs the tools a check names, and those tools can run the code they read. --static-only picks the checks that read the repo as data, judged by names and known config keys, and a tool release can add a way to load code that judgment doesn't know. Run untrusted pull requests inside a real sandbox, and treat each pass below as a way to choose what runs there, never as protection.

The flags a consumer uses

FLAGOR SETWHAT IT DOES
{{ r.flag }} {{ r.env }} {{ r.what }}

None of it rewrites the repo. A run with --defaults, --pins, --discover or --static-only, or with a --config or --out outside the repo, never ratchets checkride.baseline.json, and the stop gate strips every one of these variables from the check it spawns, so a consumer's environment never reaches the repo's own verdict.

The two passes

The first pass runs before the repo is installed or sandboxed and reads it as data, with your own tools. The second runs after the install, inside the sandbox, with the repo's own pins:

shell
{{ twoPasses }}

In the first pass, each check that might run the repo's code or need its install skips, and its reason says why: static-only: tsc isn't a static check, say. Each check that remains runs your pinned copy wherever your mise.toml names the tool, and records pin_source: "override". A pass that skips every check verified nothing, and --strict makes it exit 2. doctor takes the same flags and runs no check, so it previews what a pass would skip and which pinned versions mise hasn't installed.

Reading what it found

summary.json says which checks failed, why each ran (origin), which copy ran (pin_source, tool_version) and exactly what it spawned (argv), and its run and git say what the run covered and which commit it read. checkride/findings turns each check's artifact into findings with a file, a line, a rule and whether it can fail the check, through the same parsers the baseline keys with; the raw artifact stays the authority. checkride baseline diff "$BASE_SHA" --json lists the keys a pull request adds to checkride.baseline.json: findings it hides instead of fixing.

ADOPTION

Tools & installation

checkride has no runtime dependency on any tool it runs. For a TypeScript slot it spawns <pm> exec <tool>, so the tools are ordinary project devDependencies, pinned and owned by your repository, and "installing a missing tool" means adding an npm package.

The table below is the ts and repo packs' tools. The bash, go, rust, python and terraform packs fill the same slots with their own — golangci-lint, clippy, ruff, tflint, shellcheck and the rest — each resolved through its pack's pin chain: a go.mod tool directive, rust-toolchain.toml, uv.lock, .terraform-version, then a mise.toml, then PATH. Packs and pins lists every pack's slots, tools and pin sources, and Languages sums them up.

Slot tools

A slot runs only when its detect file is present, or, for some tools, when package.json lists the package or a script (built-in checks always run), or when the config names its tool. Note the npm package names that differ from the binaries: ast-grep ships in @ast-grep/cli, stryker in @stryker-mutator/core, attw in @arethetypeswrong/cli, vale in @vvago/vale.

SLOT tool install with detect file
{{ r.slot }} {{ r.tool }} {{ r.install }} {{ r.detect }}

fallow, the unfamiliar name

A Rust-native codebase-intelligence tool (unused code, duplication, cycles, complexity, architecture drift). checkride splits it across three slots sharing one fallow.toml: dead (default), and opt-in dupes and health. Uniquely, checkride owns the pass/fail decision here: fallow's JSON modes exit 0 even with findings, so checkride reads the report and gates on the issue count — and an unrecognized report fails loudly rather than passing silently. Needs fallow ≥ 3.5.

The publish-ready bundle

Four opt-in library slots gate the artifact a consumer actually installs, with zero new devDependencies: build (wave 10) runs your build so the rest inspect fresh output; then pack (the tarball ships the right files) and smoke (the built package imports cleanly, every export live) share wave 20 with publint and attw. snippets type-checks the tagged doc fences against your source in any wave, or, as snippets-dist, against the built .d.ts in wave 20. Enable with --include build,pack,smoke,snippets or by naming them in config — the bundle orders itself.

When doctor reports a missing tool

shell
$ pnpm exec checkride init --add dead,struct # writes fallow.toml, sgconfig.yml, rules/
$ pnpm add -D fallow @ast-grep/cli # installs the tools
$ pnpm check # the slots now run

If the config exists but the package is gone (fresh clone, pruned node_modules), it's just pnpm install. A slot that's irrelevant to the repo is disabled in config — "spell": false — and doctor reports it as such instead of a failure.