Configuration
One TOML file is the single source of truth for what the checks and rules require: coverage floors and reason-required exemptions. This page is the canonical record of its schema and every default. For the task, see Respond to a red check; for the design, Scoping and exemptions; for the per-check view — every key and exemption name that touches one check, on that check's page — see Checks.
The file is named by the workflow's config input (default testing-conventions.toml, resolved per call — a monorepo passes one per package). The loader validates the schema: unknown keys, malformed TOML, and reason-less exempt entries are rejected. An unknown-key error names the key, lists the accepted keys, and points at the package's MIGRATIONS.md, which records every key a release renamed or removed alongside its replacement. Each [python] / [typescript] / [rust] table is optional, and so is every key within it.
[python]
coverage = { branch = true, fail_under = 100 }
# The longest body a function may have and still share a file (default 1):
one_function_per_file = { max_lines = 5 }
# A whole-file presence exemption:
[[python.exempt]]
path = "mypkg/cli.py"
rules = ["colocated-test"]
reason = "thin launcher; logic in run(), tested in run_test.py"
# A line-scoped coverage/mutation exemption — `lines` is required, and never
# shares an entry with a whole-file check:
[[python.exempt]]
path = "mypkg/config/tomlcompat.py"
rules = ["coverage", "mutation"]
lines = [9, 10, "12-13"]
reason = "version-conditional tomllib/tomli import; one branch is dead on any single interpreter"
[typescript]
coverage = { lines = 100, branches = 100, functions = 100, statements = 100 }
[rust]
# Cargo features the suite-running Rust checks enable, so `#[cfg(feature = ...)]`
# code is compiled, measured, and mutated:
features = ["cli"]
coverage = { regions = 100, lines = 100 }The language tables
[python], [typescript], and [rust] take the same keys, one Rust-only addition aside. Each key has its own section below.
| Key | Meaning |
|---|---|
coverage | The language's coverage floor. |
one_function_per_file | The one-function-per-file threshold. |
exempt | The exemption entries, each its own [[<language>.exempt]] table. |
build_command | The build declaration a build-dependent job runs. |
reason | A free-form note on the table's build_command, kept as written. An exemption entry's required reason is a separate field. |
features | [rust] only: the cargo features the suite-running Rust checks enable. |
[e2e] is the fourth root table, language-agnostic, carrying extra_scope and exclude.
Coverage
Coverage floors apply to the unit suite only (test files are excluded from the denominator). Each language's default is a strict 100% floor; a [<language>].coverage table lowers it as a partial override — set only the fields you want to move, the rest keep their default, and a typo'd key is rejected. Why a 100% floor carries the rationale, including why Rust's extra metrics are opt-in.
| Language | Keys | Default |
|---|---|---|
| Python | branch, fail_under | branch = true, fail_under = 100. The suite always runs under coverage.py's branch instrumentation, so the whole-tree floor is its combined line + branch percent; a source with no branching constructs measures zero branches and is vacuously at full branch coverage. branch decides whether the diff-scoped and line-exempt ratios fold branch arcs in alongside lines. |
| TypeScript | lines, branches, functions, statements | All four at 100, each enforced independently. |
| Rust | lines, regions, functions, branch | lines = 100; regions, functions, and branch are opt-in floors. A branch floor adds --branch to the cargo llvm-cov run, which needs a nightly toolchain (pin one in rust-toolchain.toml with llvm-tools-preview, or set a rustup directory override); on stable the run fails with the requirement named. |
mutation has no percentage key — the gate is binary, not a score, and config can't loosen it. Its only tuning is a line-scoped mutation exemption (below); see Why mutation testing.
one_function_per_file
Each language table takes one_function_per_file, whose one key sets the threshold the one-function-per-file check counts against. Python and TypeScript apply the check with no table present; Rust applies it only when the table is there, because a Rust file is a module and grouping functions in one is idiomatic — see Rust is off until you opt in:
| Key | Meaning | Default |
|---|---|---|
max_lines | The longest body a module-scope function may have and still share a file. A function longer than this must be the file's only such function. | 1 — a one-line function is trivial and shares freely. |
Like coverage, the table is a partial override and a typo'd key is rejected; an absent table means the default. A function's length is the count of code lines in its body — blank lines, comments, and a Python docstring don't count, and neither does the signature.
[python]
one_function_per_file = { max_lines = 5 }
[typescript]
one_function_per_file = { max_lines = 20 }Raising max_lines moves the boundary between "trivial enough to share" and "substantial enough to own the file"; it never turns the check off. A file whose functions genuinely belong together takes a one-function-per-file exemption below.
Exemptions
A deliberate omission is a [[<language>.exempt]] entry:
| Field | Meaning |
|---|---|
path | The exempt file, relative to the scanned source of the call that loads this config — except for integration-lint's suite subjects, which resolve relative to the package root the tiers derive from (e.g. tests/integration/billing_test.py). Must point to a file that exists; a stale entry is a hard error, so the list can't silently rot. |
rules | Which exemptable units the entry lifts: check ids (colocated-test, one-function-per-file, coverage, mutation) and rule ids (the mocking, isolation, and layout assertions listed in the check pages). co-change names the diff-scoped mode of colocated-test. See the glossary. |
lines | The lines a coverage / mutation exemption covers. Required with coverage / mutation, rejected with any other exemption name. |
reason | Why the omission is deliberate. Required: an empty reason is rejected on load. |
Line-scoped exemptions
The measured-line checks — coverage and mutation — are never whole-file: their entries carry a lines list naming the exact lines they cover. Each element is a 1-based line number (a TOML integer) or an inclusive range (a "start-end" string). A determinism guard checks the list:
- A listed line that isn't failing — covered (
coverage), its mutants all caught (mutation), or carrying no measured code — is a hard error. - A failing line that isn't listed fails the gate as normal.
So the list is exactly the failing lines. The two entry kinds never mix: an entry naming coverage / mutation without lines is rejected on load, a lines key alongside a whole-file exemption is rejected, and a file exempt from both colocated-test and coverage is two entries. Under the diff-scoped mutation job, a listed line outside the diff isn't mutated and is left alone; the guard fires only on a listed line whose mutants were run and all caught.
Automatic exemptions
Two kinds of files are skipped with no configuration — the only non-explicit exclusions:
- Declaration-only modules — a file with no function and no control flow anywhere in it: an empty or comment-only file, a re-export barrel
__init__.py/index.ts, a constants module, anEnum, a type-only module. The parser decides from the file's contents, never from its name; the moment a file gains adef, an arrow, a method, anif, or a ternary, it becomes a subject. What it enforces carries the node list per language. - Declaration files (
*.d.ts/*.d.mts/*.d.cts) — they carry no runtime code.
[rust] features
The [rust] table takes features, a list of cargo features the suite-running Rust checks enable: unit-coverage passes it to cargo llvm-cov as --features, and mutation passes it to cargo-mutants as --features, so #[cfg(feature = ...)] code is compiled, measured, and mutated. The mutation run enables the features on every cargo invocation it makes — the build of the crate's test targets as much as the test run itself — so an integration test that names a feature-gated item compiles and judges the mutants of the code it covers. Cargo features are Rust's build-system concept with no Python/TypeScript analog, so the key is deliberately Rust-only — a documented asymmetry under the parity principle.
build_command
Each language table — [python], [typescript], [rust] — takes build_command, a shell command that runs at the package root — a relative path in the command resolves from there — in four jobs: unit-coverage, changed-line coverage, mutation, and packaging. Each of those jobs runs it after its own detected-language toolchain and dependencies are set up, and before it builds or imports the package. It supplies a necessary fact — how to build a package the ecosystem doesn't build for you — for a build the manifest structurally can't express: where an ecosystem standardizes the build, the tool derives it (a maturin/PEP 517 backend, Cargo's build.rs and cargo package, npm's prepare / prepack run by npm pack), and build_command names only the remainder — never a heuristic that guesses a script name that isn't standardized.
It is not an escape hatch and requires no justification: unlike gates (which skips a check) or rust_toolchain (which overrides a working default), it waives nothing — it just names the build. So it carries no required reason; an optional reason note is retained if you want to explain the build, but a bare build_command loads.
[typescript]
build_command = "pnpm build"The workflow discovers build_command in the package's own testing-conventions.toml at the package root, exactly like the config file itself — a config key, not a uses:-call input, and one per language table so a package names only the build its own language needs.
Where each language reaches for it:
- Python — the common case: a unit suite that imports a compiled module (a maturin/PyO3 extension) needs it built first, and PEP 517 backends expose only sandboxed
build_wheel/build_sdisthooks with no manifest-declared pre-build shell step (build_command = "uv run maturin develop"). - TypeScript — for packaging, when the compile-before-pack lives in a script npm doesn't run on
pack. npm standardizespack(which runsprepare/prepack) but not the build script's name — it'sbuildin one package andcompilein the next — so the tool can't derive it, and this one line names it. - Rust — rarely:
cargocompiles viabuild.rsand packages viacargo packagefrom the manifest, sobuild_commandis only for a pre-build step neither expresses.
A command naming another ecosystem's build
Each of the four jobs provisions the toolchain and dependencies of its own detected language only. A build_command that reaches into a sibling package written in a different language names that ecosystem's own setup itself, inline, as part of the command.
Node's runtime is on PATH in every one of the four jobs regardless of language — the reusable workflow installs it unconditionally to run the check tooling itself. A package manager beyond that bare runtime, a sibling package's own dependency install, and a build tool the runner doesn't preinstall (just, for one) are the command's own to arrange:
[python]
build_command = "corepack enable && pnpm --dir ../node install --frozen-lockfile && pnpm --dir ../node build"corepack enable puts pnpm on PATH, using the Node runtime already there; pnpm --dir ../node points each command at the sibling package without a cd, resolved relative to the package root the command itself runs from.
[e2e] extra_scope and exclude
The [e2e] table takes extra_scope, a list of repo-root-relative directories outside the package's own subtree whose changes join the e2e-verify scoped diff, and exclude, a list of feature-gated subtrees carved back out of that union. It is the declaration for a package whose e2e artifact is compiled from a shared source tree that sits beside it — a native core bound into several language bindings — which no --scope at-or-below the package root can reach.
[e2e]
extra_scope = ["packages/rust/src"]
exclude = ["packages/rust/src/cli", "packages/rust/src/bin"]The value is discovered in the package's own testing-conventions.toml at the package root, exactly like the config file itself — a fact about the package's build (my artifact is compiled from that tree), not a uses:-call input. detect renders the lists as repeated --extra-scope / --exclude arguments and the e2e-verify job appends them; a package declaring neither scopes the diff to its own source alone. Both are lists of directory paths, so a path with a space is not supported. This is git-level and language-agnostic — it holds across Python, TypeScript, and Rust identically. Like build_command, the tool's own config loader never acts on these keys — it accepts the table so the rest of the config still loads — while detect and the workflow read them.
Shared test configs
The coverage floor is also published as a config your own test runner extends, so a local run is held to the same floor CI enforces:
- TypeScript — the npm package exports
vitestConfigfrom its root; extend it withmergeConfigfromvitest/config. It carries the v8 provider, thesrc/**coverage scope (declaration files excluded), the100/100/100/100thresholds, andsrc/**/*.test.tsas the test include, so a local run collects the same colocated unit suite the checks scan. It also declares thetextcoverage reporter withskipFull: false, so the coverage table lists every file at the floor: Vitest 4 injectsskipFull: trueinto the text reporter when an agent environment variable is present, and at 100% across the board that omits every row — the declared value wins.mergeConfigconcatenates reporter lists, so declare only the reporters you add (reporter: ['lcov']) and the base'stextentry stays.vitestis an optional peer dependency, and the import resolves to the library entry, separate from the CLI. - Python — the
testing-conventionswheel auto-loads a pytest plugin that holds apytest --covrun to the same floor: branch coverage on,fail_under = 100, and test files (*_test.py/conftest.py) omitted from the denominator. It acts only when a coverage run is active, and your own setting always wins — setbranch,fail_under, oromitin any coverage config location and the plugin leaves it alone.