Skip to content

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.

toml
[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.

KeyMeaning
coverageThe language's coverage floor.
one_function_per_fileThe one-function-per-file threshold.
exemptThe exemption entries, each its own [[<language>.exempt]] table.
build_commandThe build declaration a build-dependent job runs.
reasonA 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.

LanguageKeysDefault
Pythonbranch, fail_underbranch = 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.
TypeScriptlines, branches, functions, statementsAll four at 100, each enforced independently.
Rustlines, regions, functions, branchlines = 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:

KeyMeaningDefault
max_linesThe 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.

toml
[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:

FieldMeaning
pathThe 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.
rulesWhich 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.
linesThe lines a coverage / mutation exemption covers. Required with coverage / mutation, rejected with any other exemption name.
reasonWhy 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, an Enum, a type-only module. The parser decides from the file's contents, never from its name; the moment a file gains a def, an arrow, a method, an if, 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.

toml
[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_sdist hooks 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 standardizes pack (which runs prepare / prepack) but not the build script's name — it's build in one package and compile in the next — so the tool can't derive it, and this one line names it.
  • Rust — rarely: cargo compiles via build.rs and packages via cargo package from the manifest, so build_command is 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:

toml
[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.

toml
[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 vitestConfig from its root; extend it with mergeConfig from vitest/config. It carries the v8 provider, the src/** coverage scope (declaration files excluded), the 100/100/100/100 thresholds, and src/**/*.test.ts as the test include, so a local run collects the same colocated unit suite the checks scan. It also declares the text coverage reporter with skipFull: false, so the coverage table lists every file at the floor: Vitest 4 injects skipFull: true into the text reporter when an agent environment variable is present, and at 100% across the board that omits every row — the declared value wins. mergeConfig concatenates reporter lists, so declare only the reporters you add (reporter: ['lcov']) and the base's text entry stays. vitest is an optional peer dependency, and the import resolves to the library entry, separate from the CLI.
  • Python — the testing-conventions wheel auto-loads a pytest plugin that holds a pytest --cov run 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 — set branch, fail_under, or omit in any coverage config location and the plugin leaves it alone.