Skip to content
robmclartyPublic

About

Give your coding agent a definition of done: one command that verifies types, lint, structure, tests, and docs, plus enforced module boundaries to keep it in its lane.

Topics

Resources

Contributing

Stars

1 star

Watchers

0 watching

Forks

Latest commit

 

History

460 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

A robot pilot labeled AGENT approaches a runway signed DONE. Eight approach lights, one per check (types, lint, struct, dead, test, docs, links, spell) and all run by pnpm check, are green, so the checkride control tower signals exit 0, cleared to land. A dashed loop marks the earlier approach, where a failed test meant exit 1 and a go-around.

checkride

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.

The thesis

Agents are good at writing code and bad at knowing when to stop. Checkride fixes both halves of that problem.

  1. 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.
  2. 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.

Install

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 init

The 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 init

For 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 check

The 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.

Docs

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 doctor reports 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-only and --out.
  • Shared presets — one versioned config for a fleet of repos, through extends.
  • Deep modules — the default convention init scaffolds, the struct and dead rules that hold it, and how to swap it.
  • Ordering in practice — what order and waves buy on a real repository, measured.
  • The contract — the surfaces consumers may rely on: exit codes, the summary.json schema discipline, the baseline file format, flags, exports, and the pin policy.
  • The Claude Code plugin — the bundled /checkride:check and /checkride:qa skills: install, what each one reads, and what they deliberately do not do.
  • Cursor — what agent-setup writes 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.

Commands

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.

The pipeline: slots and adapters

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.

Language packs

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.

Package managers

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.

The .check/ contract

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:

    {
      "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
      ]
    }

    A check that sat out carries skipped: true and a reason, and a check refused before its tool spawned (a tool the project never installed, say) fails with its reason. With targets, the summary lists them under targets, and each target's check is named <target>:<slot> (api:lint).

    ok: true with checks_run: 0 means 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 --strict turns 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>.jsonl for a tool that prints JSON lines, such as go 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; a go test check 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 the report: and digest: lines and any warning or hint: 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 as digest, so a --json run, 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 Claude Code plugin

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 own check script (or mise exec -- checkride in a repo with no package.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, baselined counts, skipped slots, exit_code: -1, stale artifacts — then opens exactly one raw file.
  • /checkride:qa — read the quality signal. It folds mutation.json, dead.json, dupes.json and health.json into 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.

Configuration

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."
    }
  }
}

description vs note

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

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.

Targets and per-pack config

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.

Custom checks

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.

Gating a custom check with detect

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.

Sharing presets with extends

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.

Timeouts

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.

Baseline

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.json

checkride.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>.json still 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 --bail that 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 baseline deliberately 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_with records 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, as toolchain_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 a baseline: 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, run checkride 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 and checkride 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.

Project shapes

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) and libs/* (reusable internals); libs may not import from apps.
  • hybrid — a root app in src/ plus internal packages under packages/*.

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.

Conventions

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.ts when it grows internals worth hiding — a one-file folder is just ceremony.
  • A folder module's index.ts is 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; .js extensions 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 struct enforces 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 go and init --lang rust start you off with their packs' dialect rules under rules/go/ and rules/rust/ (no dot imports, import aliases or init outside package main in Go; no glob imports or glob re-exports in Rust), and examples/python-project writes 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.

Tested envelope

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.

Programmatic API

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.

License

Apache-2.0

About

Give your coding agent a definition of done: one command that verifies types, lint, structure, tests, and docs, plus enforced module boundaries to keep it in its lane.

Topics

Resources

Contributing

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages