Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Configuration

Fairlead reads one file at the root of your repository: fairlead.toml, or fairlead.yaml if you prefer YAML. Both formats mean exactly the same thing. Every key has a default, so a repository without a config file works too.

fairlead config check          # validate every layer and the merged result
fairlead config show           # the merged config
fairlead config show --origin  # each value, with the layer that set it
fairlead config schema         # JSON Schema, for editor autocomplete

Layers

Later layers override earlier ones:

  1. Built-in defaults, then any framework packs the files below name in extends.
  2. Since 0.10.0: the workspace file, when the repository is in a workspace folder. Its [workspace] table is left out, and it’s ignored when the CI environment variable is set.
  3. The project file: fairlead.toml or fairlead.yaml, found by walking up from the current directory, never past the repository root (the first directory holding .git). Two in the same directory is an error.
  4. The environment’s shared file, fairlead.<name>.toml (or .yaml), when an environment is named. See Environments.
  5. The local file, fairlead.local.toml (or .yaml), next to the project file. Keep it out of git. It’s ignored when the CI environment variable is set.
  6. The environment’s local file, fairlead.<name>.local.toml, also out of git and ignored in CI.
  7. Environment variables: FAIRLEAD_ followed by the key path, with sections separated by a double underscore. So FAIRLEAD_TESTS__UNREACHED=all sets tests.unreached.
  8. --set key=value, for one run: fairlead config show --set tests.unreached=all.

For environment variables and --set, a value that parses as a TOML boolean, number or array (true, 30, ["a", "b"]) is used as that type; anything else, dates included, is a string. Environment variables apply in name order.

Environments

Name an environment with FAIRLEAD_ENV=staging, or --env staging on any command, and Fairlead layers fairlead.staging.toml over the project file and fairlead.staging.local.toml over the local one. A file with no .local is shared and belongs in git; one with .local is yours and stays out of it, like fairlead.local.toml.

fairlead.toml                 every environment
fairlead.staging.toml         staging, shared
fairlead.local.toml           you, every environment
fairlead.staging.local.toml   you, in staging

Environment files sit beside the project file, wherever you run from. An environment name is lowercase letters, digits and dashes, and can’t be local. Naming an environment with no file for it is an error, so a typo can’t fall back to the defaults silently. In CI only the shared file counts, so an environment CI uses needs one. replay run --config reads exactly the file it names, with no environment or local layers.

Lists

Lists append across layers, so your plan.run_all adds to the built-in one. To replace a list instead, write it as { replace = [...] }:

[tests]
match = { replace = ["test/**/*.ts"] }

So run_all = [] appends nothing and the built-in list stays; config check warns about an empty list written over one that already has items. { replace = [] } clears it.

Globs

* matches within a path segment and ** across segments. {a,b} is an alternation, and alternations nest: {src/**/*.test.{ts,tsx},tests/**}. A brace group of just a name, like {name}, captures that segment instead, where the key supports captures. [abc] is a character class. A backslash makes the next character literal, so a path with brackets is app/**/\[...slug\]/** (in TOML’s double-quoted strings the backslash itself is doubled: "app/**/\\[...slug\\]/**"). config check compiles every glob, so one that can’t compile fails there, not at plan time.

Errors

Unknown keys are errors, so a typo can’t silently switch something off. Every error names the file or layer it came from, and the key:

fairlead.toml: tests.unreachd: unknown field `unreachd`, expected one of ...

Keys

KeyDefaultMeaning
fairleadnoneThe oldest Fairlead version this config needs, such as "0.2". Since 0.7.0: a config that an older binary can’t read, because it uses keys added later, gets this version in the error instead of the unknown key.
modules.discover["workspaces"]Where modules come from
modules.define[]Extra modules: { pattern = "services/{name}/src" }
graph.tsconfig"auto"The nearest tsconfig to each file, or a path
graph.type_importstrueCount import type as an edge
graph.unresolved"warn"warn or fail on imports that don’t resolve
graph.conditions["import", "node", "default"]Package exports conditions, in order
graph.cachetrueKeep parse results under .git/fairlead so unchanged files aren’t parsed again
extends[]Since 0.10.0: framework packs layered under this config: a built-in pack’s name, such as "laravel", or a pack file’s path. See Framework packs
graph.edges[]{ from, to } rules: each file matching from depends on the files to matches. See Import graph. Since 0.10.0: find, a regex searched in each from file’s text, whose capture fills {1} in to. See Names in the code
graph.barrier[]Globs the walk reaches but doesn’t go past. See Import graph
graph.providers[]{ id, command, files, timeout_seconds }: external commands that print the graph for the files they claim. See Import graph
tests.match**/*.{test,spec}.{ts,tsx,js,jsx,mjs,cjs,mts,cts}Test files
tests.exclude**/node_modules/**Paths that are never test files
tests.unreached"module"What a changed file nothing reaches selects: module, all or warn
tests.runners[]id, match, exclude (files under match left to another runner), invoke (once or per-module), cwd, command. Since 0.7.1: all_command (the argv when everything runs) and exclude_arg (an argv fragment that leaves one file out), below. Since 0.8.0: from, the earliest CI stage it runs at, ready by default
tests.owners[]Tests that don’t import what they test: match, covers, and overrides_run_all to let a covered plan.run_all path select only those tests
tests.classes[]class (unit, own, demand, canary) for a match
checks[]Steps that aren’t tests: id, command, paths, modules, files. Since 0.8.0: from, the earliest CI stage it runs at, draft by default
quarantine[]A test (path) or check (check) that fails on one platform whatever the change: os, when (autocrlf, space-in-path), signature, reason, proved_in, until (Test plan)
plan.run_alllockfiles, root manifests, tsconfig, runner and CI configA changed path matching one selects everything. Since 0.8.0: except a change that only moves dependency versions and a workflow run only by hand or on a schedule (Changes that don’t run everything)
plan.lockfile"all"all: a changed lockfile selects everything; scope (opt-in): a changed root pnpm-lock.yaml selects the workspace packages whose resolved dependencies changed
plan.ignoreroot Markdown, the changesets tool’s folder (.changeset/**), docs/**, READMEs, changelogs, licencesA changed path matching one that reaches no test selects nothing, instead of falling to tests.unreached; one that reaches tests still selects them, and a changed test file always runs
replay.provider"github"Where CI history comes from
replay.window_days90How far back replay looks
replay.min_failures30Failures needed before a replay result counts
replay.failures[]runner, extractor (vitest, jest, bun, phpunit, pest, go, pytest, maven, gradle, regex), job, pattern
replay.checks[]Map a CI job and step to a check
replay.ignore[]CI job names (regexes) whose failures replay leaves out on purpose, such as a job that only aggregates others
replay.quarantine[]Tests declared flaky in named jobs: path, job, reason, until (Replay)
replay.ignore_steps[]CI step names (regexes): a job that failed only in such steps, such as an install, is left out
guard.baseline"fairlead-baseline.json"Ratcheted counts by file and rule, relative to the project root
guard.exclude[]Tracked files no guard rule reads
guard.findings"added"Which findings count at a commit: added, only those the change adds; all, every finding in a file it touches
guard.on_finding"deny"deny stops the commit on those findings; warn shows them and lets it through
guard.budget_ms40The write hook’s own time; past it the edit goes ahead and the event log says timed_out
guard.events"local"local records decisions in .git/fairlead/events.jsonl; off records nothing
guard.cite{}A note appended to a rule’s messages, by rule id
guard.sizeofffiles, exclude, file_lines, function_lines, test_hooks, and ratchet (default true). See Guard rules
guard.test_namesofffiles, exclude, file, titles_without, title_calls. See Guard rules
guard.citationsofffiles, exclude, pattern (with a code group), headings_in. See Guard rules
guard.migrationsofffiles, immutable (default true), base, unique_prefix. See Guard rules
guard.commands[]match, reason: commands an agent may not run; unless, a regex that lets a matched command through (Since 0.8.0). See Guard rules
guard.external[]id, command, stages (default ["check"], or commit), ratchet. See Guard rules
hooks.claude"shared"Where fairlead hooks install puts the Claude Code hooks: shared, .claude/settings.json; local, .claude/settings.local.json
done.tests"planned"What fairlead done runs of the plan’s test invocations: planned or none. See The done gate
done.checks"planned"The same for the [[checks]] the plan selects
done.always[][[checks]] ids fairlead done runs for every change
done.guardtrueWhether fairlead done ends with fairlead guard check
done.on_stop"ask"What the Claude Code Stop hook does while the tree hasn’t passed: off, ask (send the agent back once) or require. See The done gate
brief.per"session"Whether a second fairlead brief in the same session adds its paths to the first (session) or stands alone (call). See The brief
brief.nudgetrueWhether fairlead hooks install adds the PostToolUse note after an edit made with no brief
brief.resumetrueWhether fairlead hooks install adds the SessionStart hook that runs fairlead resume. See Context and resume. Since 0.8.0
memory.dir".fairlead/lessons"Where the lesson files live. Since 0.8.0
memory.review_days90Days from added to the review_by a new lesson gets
memory.max_lines12The longest body a lesson may have
memory.cap5Lessons a brief lists before “N more”
memory.learn"write"What fairlead learn does: write the file to the working tree, or ask, which prints it for a person to save
skills.imports1Hops along what a changed file imports, for skills and lessons alike. Since 0.8.0
skills.importers0Hops along the files that import a changed file; off by default
skills.cap8Skills a brief lists before “N more”
skills.targets["claude", "agents", "cursor"]The agents skills sync writes for
skills.routes[]{ skill, paths, modules, always }: the SKILL.md and the code it applies to. See Skills
agents.write"block"What fairlead agents sync does: block keeps the marked block in each file; never writes nothing and prints it for a person to copy in. See AGENTS.md and CLAUDE.md. Since 0.8.0
agents.files["AGENTS.md", "CLAUDE.md"]The files that carry the block, relative paths inside the repository
stages.environments["main"]Branches, by name or glob, whose pushes are the merge stage. See Stages. Since 0.8.0
stages.full_label"run-everything"A pull request label that makes its runs the full stage
stages.reusetrueAt merge, skip the ready stage’s steps when the pull request passed them on the same tree
ci.commentfalseWhether fairlead ci report also keeps one pull request comment up to date with the report. See Plans in CI
ci.escapes"report"What a failing test the merged change’s plan left out does once ci run --judge finds it: "report" lists it, "fail" also makes ci report exit 1. See Escapes
guard.commentsofffiles, exclude, tests, migrations, block_length, density, history, item_codes, agent_phrases, block_marker, and ratchet (default false). See Guard rules

Commands are always argv arrays, never shell strings, and {files} expands to one argument per file. {packages} expands to one ./dir per directory holding a selected test, or ./... when everything runs, for go test. Since 0.7.0: for Maven and Gradle, an argument holding {class} is repeated once per selected test class and {classes} joins them with commas; either is dropped when everything runs (Java and Kotlin). In tests.runners.cwd, {module} is the module’s root path and {module.id} its id.

Since 0.7.1: when everything runs, {files} expands to nothing, so bun test {files} becomes a bare bun test, which runs every test the tool finds, end-to-end specs included, not only the files the runner’s match claims. A runner’s all_command is the argv to use instead whenever everything runs, such as ["bun", "test", "test/unit"]; without one, command runs with no files, as before. It takes the same placeholders as command except {files}, {class} and {classes}, which have nothing to expand to there and fail config check: {packages} is ./..., and {module} and {module.id} are filled in per module. exclude_arg is an argv fragment holding {file}, such as ["--exclude", "{file}"] or ["--ignore={file}"], added once for each test a [[quarantine]] entry holds, so a run of everything can leave out the tests that run alone (Invocations).

config check validates every key’s type, the ids, placeholders and references between sections, and lists test files that match no runner or more than one. In tests.owners, a placeholder used in covers must be captured in match.