colocated-test
Every source file under the scan root has a colocated, matching-named unit test — and on a pull request, a changed source changes its test with it. This page is the complete record of the check: why it exists, what it enforces per language, when it runs, and its configuration surface.
Why this check exists
Colocation makes the unit/integration boundary structural — by location rather than a tag or marker: a test that sits next to widget.py and is named widget_test.py is a unit test of widget, by construction. 1:1 naming makes an orphan visible: when every source file maps to exactly one test file, a source with no test is caught by a deterministic scan — and so is a test whose source went away. This is the first rung of the unit ladder — does a test exist? — with coverage and mutation asking the stronger questions above it.
The co-change mode exists because presence isn't enough on a pull request: a source edit that leaves the colocated test untouched lets the test silently go stale — it still exists, but it pins the old behavior.
What it enforces
The check is tree-wide presence: every source file under the scan root has its colocated, matching-named unit test.
A source file is a subject when it holds a function or control flow anywhere in it — inside a class body or an object literal included. A function has inputs and a branch depends on something, so a test can drive either and fail for a reason the source does not already state. A declaration-only module holds neither: it runs once, at import, and importing it is the whole test. It is skipped. The parser decides this from the file's contents, so no file name is special and no exemption is involved; a module becomes a subject the moment it gains a def, an arrow, a method, an if, or a ternary.
- Python —
foo.py→foo_test.py, side by side. A function isdef,async def, orlambda; control flow isif,for,while,try,with,match, a conditional expression, or a comprehension with anif. A re-export barrel__init__.py, a constants module (TIMEOUT = 30 * 60,logger = getLogger(__name__)), anEnum, a dataclass of fields, aTypedDict, and an annotation-only class are declaration-only. A class is a subject through its methods. - TypeScript —
foo.ts/.tsx/.mts/.cts→ a colocatedfoo.test.*of the matching extension. A function is a function declaration, a function expression, an arrow, or a method, each with a body (adeclareor overload signature compiles to nothing); control flow addsswitch,do, and the ternary. A re-export barrel (export { x } from './x',export * from './x'), anexport constof literals, anas constobject, an enum-only module, and a type-only module (type/interface/import type/export type) are declaration-only. Declaration files (*.d.ts) are ignored outright. - Rust — units are inline
#[cfg(test)]modules, not sibling files, so the check is presence of the inline module: asrcfile that defines a function with a body but has no#[cfg(test)]module is an orphan. A test module is one gated by a positively-requiredtest—#[cfg(test)]or#[cfg(all(test, …))]; a#[cfg(not(test))]module compiles in non-test builds, so it is production code and counts as behavior to test, never as the inline test. Module-declaration files (onlymod/use) and type-only files (nofn) aren't subjects;tests/,benches/,examples/, andbuild.rsare skipped.
and, or, ||, &&, and ?? short-circuit but are not control flow, so PORT = env.PORT ?? 3000 stays declaration-only. Empty or comment-only files are never subjects, and a file with a colocated-test exemption is deliberately omitted, with a reason.
Co-change, on pull requests
On pull requests the check also runs commit-scoped over the <base>...HEAD diff (Python and TypeScript):
- a modified source must have its colocated test in the diff too;
- a deleted source that had a test in the base tree must delete or update that test with it;
- an added source is not a subject — brand-new code is the changed-line coverage floor's concern.
A modification is a subject when it changes code the compiler sees. The file at the merge base and the file at HEAD are parsed and compared with comments and formatting whitespace normalized away, so a # / // / /* … */ comment edit, a blank-line change, and a trailing-whitespace change compare equal and pass on their own — the colocated test still pins the behavior the file has. Text inside a string literal, a Python docstring, and a TypeScript template literal is code, as is the block structure Python indentation carries, so an edit there is a subject; so is a comment edit that travels with a code change. Content that fails to parse on either side counts as changed and is held to its test.
Changing a test on its own always passes. Rust units are inline in the same file, so a sibling test can't go stale and co-change doesn't apply to Rust — a deliberate asymmetry.
A Rust binary's entry point
Rust requires a main in a binary root, so src/main.rs cannot be emptied the way a library module can. It needs no exemption. Two shapes read as declaration-only; both move the logic into the library, where a test reaches it.
Re-export the entry point. A pub use carries no function at all:
// src/main.rs
pub use mycrate::entrypoint::main;Rust accepts a re-export as the entry point as long as the item is a zero-argument function returning a Termination type.
Or delegate in a single call. A fn main is declaration-only when its body is one argument-free call and nothing else:
// src/main.rs
#[cfg(not(test))]
fn main() -> std::process::ExitCode {
mycrate::entrypoint::main()
}The shape is deliberately narrow, because anything wider can hide a decision. Exactly one statement, and that statement a call through a path taking no arguments. A second statement, an argument, an operator, a ?, a match, a method call — each makes the file a subject again, and then it needs its own inline #[cfg(test)] module.
#[cfg(not(test))] is not optional on this shape. Without it the function is instrumented and reads 0% — cargo test replaces a binary's main with the harness's own, so no unit test can ever execute it, and a 100% floor would be unreachable. The re-export needs no such attribute of its own, because a pub use emits no code regions. It does link the library's gated main into the binary target's test harness, where cfg(test) is unset and the item is compiled and instrumented; unit-coverage drops a gated item from the ratios, so both shapes measure the same.
That leaves one line outside the unit tier by construction — the process-boundary argv read the library's main performs. Keep it free of decisions, mark it #[cfg(not(test))] too, and let e2e-verify exercise the real binary.
When it runs
| Mode | Runs | As |
|---|---|---|
| Presence | always, tree-wide | a step of the Static checks (<language>) job |
| Co-change | pull requests only (Python, TypeScript), over <base>...HEAD | a step of the same job |
The scan covers every file under source, leaving <package root>/tests/ to the suite tiers. The gates input names it colocated-test; the diff-scoped co-change mode rides with it.
Configuration
The check takes no keys of its own. Its exemption names, each a [[<language>.exempt]] entry with a required reason:
| Exemption | Lifts |
|---|---|
colocated-test | the presence requirement for one file (a launcher shim, a process entry point) |
co-change | the co-change requirement for one file, independently of presence |
Both exemptions apply to whole files. A declaration-only module is never a subject, and a comment-only or whitespace-only edit is never a co-change subject — the files themselves decide both, with no configuration.
Learn more
- Explanation — Colocated tests: why colocation, and why a stale test is an invisible orphan.
- Respond to a red check: exempting a file, step by step.