# Migrations
Upgrade notes for breaking changes. New entries go under `## Unreleased`.
On release, the section is renamed to `## v<OLD> → v<NEW>`.
Each entry has five sections, in order:
1. **Summary** — one paragraph: what changed and why.
2. **Required changes** — before/after for public API. "None" if purely additive.
3. **Deprecations removed** — anything previously warned about that's now gone.
4. **Behavior changes without code changes** — same API, different runtime behavior.
5. **Verification** — commands that confirm the upgrade worked, with expected output.
## Unreleased
### Summary
Adds the `config` module: a `Config` schema holding the per-language `coverage`
thresholds (`[python]` / `[typescript]` / `[rust]`), plus `load_config()`, which
reads one TOML file into it and validates the config itself (the self-guard) —
unknown keys and malformed TOML are rejected rather than silently accepted.
Purely additive; nothing consumes the parsed config yet.
Also reshapes the colocated-test rule's CLI (#22) and then renames the rule
itself (#55). The rule ships for two languages — `missing_unit_tests` walks a
directory and returns every source file with no colocated test, and the CLI runs
it and exits non-zero on any orphan (Python #15: `foo.py` → `foo_test.py`;
TypeScript #18: `foo-bar.ts` → `foo-bar.test.ts` across `.ts`/`.tsx`/`.mts`/`.cts`,
`*.d.ts`/`*.d.mts`/`*.d.cts` ignored). The **command surface changes**, though: the
previously released `unit-location [--lang …]` (v0.0.3 / v0.0.4) first became
`unit location --language <python|typescript> <PATH>` (#22 — rules nest under
their test kind, `--language` required, the `python` default gone), and is now
`unit colocated-test …` (#55 — the rule was renamed `location` → `colocated-test`
so its name says what it checks: that a source file has a colocated,
matching-named unit test). This is a breaking change for anyone on an earlier CLI.
(#22 left the library API untouched; #32 below then changes the
`missing_unit_tests` and `measure` signatures, and #55 renames the `location`
module to `colocated_test`.)
Also adds the Python coverage rule (#26): `unit coverage --language python
--config <CONFIG> <PATH>` runs the unit suite under `coverage.py` (branch on,
`*_test.py` omitted) and enforces the config's `[python].coverage` floor, with the
supporting `testing_conventions::coverage` module (`measure`, `evaluate`,
`parse_report`, and the `Thresholds` / `CoverageReport` / `Outcome` types). Purely
additive — a new subcommand and module; nothing existing changes.
Also adds TypeScript coverage (#31), the twin of #26: `unit coverage --language
typescript --config <CONFIG> <PATH>` runs the unit suite under `vitest` v8
coverage and enforces the four `[typescript].coverage` thresholds (`lines` /
`branches` / `functions` / `statements`), excluding `*.test.*` and declaration
files — and any `coverage`-exempt path — from the denominator. vitest reports four
independent metrics rather than Python's single total, so it adds its own
`coverage::{measure_typescript, evaluate_typescript, parse_vitest_report}` plus
the `TypeScriptThresholds` / `VitestReport` / `VitestTotals` / `VitestMetric`
types, sharing the existing `Outcome`. Purely additive — `--language typescript`
previously errored as unimplemented; nothing existing changes.
Also adds the `integration lint` command and its `lint` module (#48, #49): a
deterministic, AST-based lint on Python test files. `integration lint --language
python <PATH>` parses each test file (`*_test.py`, `test_*.py`, `conftest.py`) with
`rustpython_parser` and flags the mocking-mechanism lints — `no-monkeypatch` (a
test/fixture that declares pytest's `monkeypatch` parameter), `no-inline-patch` (a
`patch(...)` call in a test body, which belongs in a `pytest.fixture`),
`no-environ-mutation` (direct `os.environ` mutation; set env via `patch.dict`), and
`no-constant-patch` (patching a module-global UPPER_CASE constant), which is waivable
per file via a `--config` `exempt` entry (`rules = ["no-constant-patch"]`, reusing #32).
Purely additive: a new command group (`integration`), a `--config` flag on `integration
lint`, the `config::Rule::NoConstantPatch` value, and the `testing_conventions::lint`
module; nothing existing changes.
Finally, adds exemptions (#32) so the checker can be an honest blocking gate.
Exemptions are **config-driven and explicit** — there is no automatic name- or
shape-based exemption. `__init__.py`, re-export barrels, and launcher shims are
all subjects now; the only files exempt automatically are empty/comment-only ones
(no logic to test). For deliberate omissions the tool can't infer, list the file
in the one config file: a `[[<language>.exempt]]` entry with the `rules` it lifts
(`colocated-test` / `coverage`) and a required `reason`. A `colocated-test`
exemption keeps the file off the orphan list; a `coverage` exemption omits it from
the coverage denominator. The list is auditable (one place, in the config diff) and enforced:
a stale entry — a path that no longer exists — is a hard error, so it can't
silently rot. New config types `Rule` and `Exemption` plus `resolve_exempt()`;
`[<language>].coverage` becomes optional (a config can carry exemptions alone);
and `missing_unit_tests` / `coverage::measure` take the resolved exemptions
(signatures below).
Zero-config coverage (#80): `unit coverage` now enforces the language's sane
default floor when coverage isn't configured — a missing config file, or a config
without the `[<language>].coverage` table — instead of erroring (Python
`branch = true, fail_under = 85`; TypeScript `lines = 80, branches = 75,
functions = 80, statements = 80`, the reasonable floors from the internals style
guides). This matches how `unit colocated-test` and `integration lint` already
treat an absent config, and lets the reusable workflow opt a new library into
every check with no config file. Additive: `Config`,
`{Python,TypeScript,Rust}Config`, `PythonCoverage`, and `TypeScriptCoverage` gain
`Default` impls; no existing signature changes.
Adds the `packaging` rule's foundation (#70): the `packaging` command and its
`packaging` module. `packaging --language <python|typescript> <PATH>` scans the
built artifact at `<PATH>` (an already-unpacked wheel or `dist/`) for that
language's test-file glob — Python `*_test.py`, TypeScript `*.test.*` — and exits
non-zero if any are present, enforcing the README "Packaging" rule that test files
must not ship. `packaging::scan(root, globs)` is the deterministic core; the
per-language *build* step that produces the artifact lands in #72 / #73 / #74
(the last also adding `--language rust`). Purely additive — a new command and
module; no existing signature or behavior changes.
Also adds the Rust `unit isolation` rule (#44) and its `isolation`
module: a deterministic, `syn`-based lint on Rust test code. `unit isolation
--language rust <PATH>` parses each `*.rs` file under the crate root and flags a
call out of an inline `#[cfg(test)]` module's own module — `no-out-of-module-call`
(`crate::…`, `super::super::…`, an external crate from `Cargo.toml`, or effectful
`std`) — and a foreign `use` import — `no-out-of-module-import` (a glob of anything
but `super::*`, or a named import rooted at `crate::`, an external crate, or
effectful `std`). Purely additive: a new `unit` subcommand and module
(`testing_conventions::isolation::{find_violations, Violation, Language}`). The
shared `Violation` type moves to a new `violation` module and is re-exported from
`lint`, so `testing_conventions::lint::Violation` still resolves with **no code
change required**.
Also adds the Rust **integration** isolation lint (#44): `integration lint
--language rust <PATH>` flags `no-first-party-double` — a `#[double]`
(mockall_double) import of a first-party item (the crate under test or a `path`
dep) in a `tests/` integration crate, which must run first-party code for real;
doubling an external crate is fine. To add Rust without touching the file-pairing
`colocated_test::Language`, `integration lint`'s `--language` is now its own
`IntegrationLintLanguage` (python/typescript/rust). Purely additive — `--language
rust` is new, the python/typescript surface is unchanged, and the library gains
`testing_conventions::isolation::find_integration_violations`.
Also adds the first TypeScript lint (#43, #75): `integration lint --language
typescript <PATH>` extends the (previously Python-only) `integration lint` command
to TypeScript, parsing each `*.test.{ts,tsx,mts,cts}` file with `oxc` and flagging
**`no-first-party-mock`** — a `vi.mock()` / `vi.doMock()` of a first-party
(relative) module in an integration test, which must run first-party code for real
(third-party packages and Node built-ins may still be mocked). Purely additive: a
new `testing_conventions::ts` module (`find_integration_violations`, plus the shared
specifier classifier `classify` → `Origin`) and a new `--language typescript` arm on
`integration lint`; nothing existing changes.
Also extends `unit isolation` to TypeScript (#43, #76), the unit-direction
counterpart: `unit isolation --language typescript <PATH>` walks each
`*.test.{ts,tsx,mts,cts}` unit test and flags any runtime import that isn't
`vi.mock()`-ed (`unmocked-collaborator`), except the unit under test, type-only
imports, and the test runner (`vitest`). Additive — adds a `TypeScript` variant to
`isolation::Language` and the `testing_conventions::ts::find_unit_violations`
function; the Rust `unit isolation` behavior from #44 is unchanged.
Extends `packaging` to inspect a **Python wheel** (#72): `packaging --language
python <PATH>` now accepts a built `.whl` (a zip), unpacks it to a scratch
directory, and reuses `scan` to flag any `*_test.py` that shipped — `<PATH>` may
still be an already-unpacked directory. New library API `packaging::inspect(path,
globs)` (archive-or-directory → offenders relative to the artifact root); the
`packaging` command now calls it instead of `scan`. Additive — `scan` is
unchanged and the directory behavior is the same; new dependency `zip`.
Also extends `packaging` to a **TypeScript npm tarball** (#73): `packaging
--language typescript <PATH>` now accepts a built `.tgz` (an `npm pack` gzipped
tar), unpacks it, and reuses `scan` to flag any `*.test.*` that shipped in the
published `dist`. `inspect` now recognizes `.tgz` / `.tar.gz` in addition to
`.whl` and directories (the `.tar.gz` path is reused by #74's Rust `.crate` and
the Python sdist). Additive — new dependencies `flate2` + `tar`; no existing
signature changes.
Finally, `packaging --language rust` (#74) — the last packaging language.
`packaging` now accepts a Cargo `.crate` (`cargo package`, a gzipped tar) and
flags the crate-root **`tests/`** directory (`#[cfg(test)]` units compile out for
free; only the integration `tests/` needs a Cargo `exclude`). The scanner gains a
**directory pattern** (a pattern ending in `/` matches files under that dir)
alongside the file-name globs, and `colocated_test::Language` gains a `Rust`
variant so `--language rust` parses (`unit colocated-test` / `unit coverage` reject
it as separate items). Additive — a new enum variant and behavior; no existing
signature changes. Note for library consumers: matching exhaustively on the
public `Language` enum without a wildcard arm must add a `Rust` case.
Then `unit isolation --language typescript` also enforces **typed** mocks
(#43, #77): a `vi.mock(spec, factory)` whose factory carries no `vi.importActual<…>()`
type anchor is flagged `untyped-mock` (a bare `vi.mock(spec)` auto-mock and a typed
factory both pass). Behavior-only — `find_unit_violations` now reports the extra
rule; no signature changes. This completes #43's TypeScript isolation (#75/#76/#77).
Finally, adds the `workflow` guard (#92): a new `workflow` command and module that keeps the
reusable workflow's `@v0` consumption path from stranding. `workflow <PATH>` scans a
workflow file (or directory) for every `testing-conventions …` invocation and flags any
whose subcommand chain the binary no longer exposes (`no-unknown-subcommand`) — the failure
mode that broke `@v0` at 0.0.7 after the #55 `location` → `colocated-test` rename. Purely
additive: a new `testing_conventions::workflow` module (`invocations`, `unknown_subcommands`,
`check`, `Invocation`) and a `testing_conventions::command()` accessor for the binary's clap
command tree; nothing existing changes.
Also adds the e2e attestation nudge's first command (#17, #67): `e2e attest
'<command>'` runs the e2e suite, writes a committed `e2e-attestation.json` naming
the current commit (the command, a timestamp, the exit code, and the attested
SHA), and commits it on top — regardless of the command's outcome (force a run,
not a pass). Purely additive: a new `e2e` command group and the
`testing_conventions::e2e` module (`attest`, `Attestation`, `ATTESTATION_PATH`);
nothing existing changes. The CI-side `e2e verify` follows in #68.
Also makes the **isolation rules waivable** via the config `exempt` list (#102),
reusing the #32 machinery. `unit isolation` gains a `--config` flag (default
`testing-conventions.toml`); both it and `integration lint` now filter findings
against the config, so a `[[<lang>.exempt]]` entry naming an isolation rule —
`no-out-of-module-call`, `no-out-of-module-import`, `no-first-party-double`
(Rust), or `unmocked-collaborator`, `untyped-mock`, `no-first-party-mock` (TS) —
lifts it for that file (reason required; a stale entry still errors). Additive:
new `config::Rule` variants plus `Rule::id` / `Rule::from_id` and
`Config::rust_exemptions`; the `--config` flag is optional, so existing
invocations are unaffected.
### Required changes
The colocated-test CLI was renamed (twice, pre-1.0) and its language flag made
required. Update any invocation (CI steps, scripts, `npx`/`pip`/`cargo` wrappers)
to the current `unit colocated-test` form:
| `unit-location src/` (≤ v0.0.4) | `unit colocated-test --language python src/` |
| `unit-location --lang typescript src/` (≤ v0.0.4) | `unit colocated-test --language typescript src/` |
| `unit location --language python src/` (v0.0.5–v0.0.8) | `unit colocated-test --language python src/` |
- `unit-location` (flat, ≤ v0.0.4) / `unit location` (nested, v0.0.5–v0.0.8) →
`unit colocated-test` (#22, #55).
- `--lang` → `--language`, which is required: there is no longer a `python` default.
Exemptions (#32) change the library API, and #55 renames the module these
colocated-test items live in — `testing_conventions::location` →
`testing_conventions::colocated_test` (the `Language` enum moves with it). Callers
must update the import path *and* pass the new arguments:
| `missing_unit_tests` | `location::…(root, language)` | `colocated_test::…(root, language, exempt)` — `exempt: &BTreeSet<String>` of `colocated-test`-rule paths |
| `coverage::measure` | `(root, thresholds)` | `(root, thresholds, omit)` — `omit: &[String]` of `coverage`-rule paths |
Build both with `config::resolve_exempt(root, exemptions, rule)`. Passing an empty
set/slice preserves the prior behavior. `[<language>].coverage` is now an
`Option`, so `config.python.coverage` becomes `config.python.coverage` of type
`Option<PythonCoverage>` — match/`?` it before reading the thresholds.
Anyone relying on `__init__.py` being exempt must add it to the config: a
non-empty `__init__.py` (one with re-exports or code) is now a subject. An
**empty** `__init__.py` needs nothing — empty/comment-only files are not
subjects.
### Deprecations removed
The `--lang` flag and its implicit `python` default are gone — a clean break, not
a deprecation cycle (pre-1.0, so no prior warning was shipped).
### Behavior changes without code changes
Omitting the language is now a usage error (exit code `2`) instead of defaulting to
`python`. Before, running the check on a TypeScript project without a flag scanned
for `*.py`, found none, and exited `0` — a silent false green; now the language
must be stated explicitly.
Exemptions (#32) change runtime behavior:
- `__init__.py` is no longer auto-exempt — a non-empty one without a colocated
test (and without a config entry) is now reported as an orphan. Empty/comment-
only files (any language) are non-subjects and never reported.
- `unit colocated-test` and `unit coverage` honor the config `exempt` list: a
`colocated-test` entry keeps a file off the orphan list; a `coverage` entry omits
it from the denominator. A reason-less or stale entry makes the run **error**
rather than pass.
- CLI error output now prints the full cause chain (e.g. `error: exempt entry
\`ghost.py\` matches no file under \`…\`: …`) instead of only the outermost
context. Exit codes are unchanged.
- `unit coverage` no longer errors on a missing config file (or a config without
the `[<language>].coverage` table): it enforces the language's default floor
instead — Python 85 with branch on; TypeScript lines/functions/statements 80,
branches 75. A `[<language>].coverage` table still overrides it. (#80)
- `integration lint --language typescript` (#43, #75) previously errored
(`supports --language python only for now`); it now parses the TypeScript test
files and runs the `no-first-party-mock` lint.
### Verification
```
cd packages/rust && cargo test --test config_loader
```
Expected: the loader's integration tests pass — the canonical config loads, an
exempt-only config (no coverage thresholds) loads, and unknown-key, malformed,
missing-file, and reason-less-exemption configs are rejected.
```
cd packages/rust && cargo test --test colocated_test --test colocated_test_e2e
```
Expected: the colocated-test tests pass — clean fixtures report no orphans, red
fixtures report their missing twins, an empty `__init__.py` is not an orphan while a
content-bearing one is, config exemptions clear the listed files, and a stale
exempt entry errors. The renamed `unit colocated-test` subcommand parses while the
old `unit location` no longer does.
```
cd packages/rust && cargo test --test coverage
```
Expected: the coverage tests pass — including the `exempt_cov` codebase clearing a
100 floor once its shim is omitted by a `coverage` exemption. Requires `coverage`
+ `pytest` on `PATH`.
```
cd packages/rust && cargo test --test coverage_ts --test coverage_ts_e2e
```
Expected: the TypeScript coverage tests pass — `full` clears a 100 floor on all
four metrics, `above` fails 100 but clears the mid floor, `below` (100% lines but
~66% branches) fails the mid floor on branches, and `exempt_cov` clears 100 once
its shim is omitted by a `coverage` exemption. Requires Node with `vitest` +
`@vitest/coverage-v8` installed (run `npm ci` in
`tests/fixtures/unit_coverage/typescript`).
```
cd packages/rust && cargo test --test e2e_attest --test e2e_attest_e2e
```
Expected: the `e2e attest` tests pass — in a throwaway git repo, `attest` names
HEAD, writes `e2e-attestation.json`, and commits it on top, exiting `0` even when
the wrapped command fails (force a run, not a pass). Requires `git`.
```
cd packages/rust && cargo test --test integration_lint --test integration_lint_e2e
```
Expected: the lint's integration + e2e tests pass — the clean fixture reports no
violations and exits `0`, and the red fixture (a test taking `monkeypatch`) is
flagged and exits `1`.
```
cd packages/rust && cargo test --test ts_integration_lint --test ts_integration_lint_e2e
```
Expected: the TypeScript lint's integration + e2e tests pass — the clean fixture
(mocks only third-party packages and Node built-ins) reports no violations and exits
`0`, and the red fixture (a first-party `vi.mock` / `vi.doMock`) is flagged and exits
`1`.
```
cd packages/rust && cargo test --test unit_isolation --test unit_isolation_e2e
```
Expected: the TypeScript unit-isolation tests pass — both rules. For
`unmocked-collaborator`, the clean fixture (every collaborator `vi.mock()`-ed) exits
`0` and the red fixture (an un-mocked `./formatter` and `lodash`) is flagged. For
`untyped-mock` (#77), the `untyped_mock` red fixture (a `vi.mock` factory with no
`vi.importActual<…>` anchor) is flagged while its clean fixture (a typed factory and a
bare auto-mock) exits `0`.
```
cd packages/rust && cargo test --test coverage_e2e --test coverage_ts_e2e
```
Expected: the coverage e2e suites pass, including the zero-config cases (#80) — a
`--config` pointing at a nonexistent file falls back to the default floor: Python
`full` and `above_85` (85.71%) pass while `below_85` (71.43%) fails; TypeScript
`above` passes while `below` (66.66% branches) fails. Requires the coverage
toolchains (`coverage` + `pytest`; vitest installed in the TS fixture).
```
cd packages/rust && cargo test --test packaging --test packaging_e2e
```
Expected: the packaging foundation's integration + e2e suites pass — a fixture
artifact containing a test file (`python_red`'s `widget_test.py`,
`typescript_red`'s `button.test.ts`) is flagged and the built binary exits `1`,
while a clean artifact exits `0`. No toolchain required (the scanner reads the
tree directly).
```
cd packages/rust && cargo test --test isolation --test isolation_e2e
```
Expected: the isolation tests pass — the red fixture's four out-of-module forms
(first-party cross-module, effectful `std`, external crate, ancestor reach) are
each flagged and the crate exits `1`, while the clean fixture (`super::` + an
injected trait double + `Cursor`) reports nothing and exits `0`.
```
cd packages/rust && cargo test --test packaging_wheel --test packaging_wheel_e2e
```
Expected: the Python wheel suites pass — `red.whl` (which ships
`widget/core_test.py`) is flagged and the binary exits `1`, while `clean.whl`
exits `0`. The wheels are generated by the committed `make_wheels.py`. No Python
toolchain required (the checker unzips the wheel directly).
```
cd packages/rust && cargo test --test packaging_npm --test packaging_npm_e2e
```
Expected: the TypeScript npm-tarball suites pass — `red.tgz` (which ships
`package/dist/widget.test.js`) is flagged and the binary exits `1`, while
`clean.tgz` exits `0`. The tarballs are generated by the committed
`make_tarballs.py`. No Node toolchain required (the checker unpacks the tarball
directly).
```
cd packages/rust && cargo test --test packaging_crate --test packaging_crate_e2e
```
Expected: the Rust crate-tarball suites pass — `widget-0.1.0.crate` (which ships
`widget-0.1.0/tests/integration.rs`) is flagged and the binary exits `1`, while
`clean-0.1.0.crate` exits `0`. The crates are generated by the committed
`make_crates.py`. No Cargo toolchain required (the checker unpacks the `.crate`
directly).
```
cd packages/rust && cargo test --test workflow --test workflow_e2e
```
Expected: the workflow guard's integration + e2e suites pass — the clean fixture (only
live subcommands, version pins, a `\`-continuation, and a comment that must not be read as
a call) reports nothing and exits `0`, while the red fixture (`unit location` and the flat
`unit-location`) flags both and the built binary exits `1`.
cd packages/rust && cargo test --test rust_integration_lint --test rust_integration_lint_e2e
```
Expected: the Rust integration tests pass — the red fixture's `#[double] use
widget::Renderer` (doubling the crate under test) is flagged and exits `1`, while
the clean fixture (runs `gadget::compute` for real, doubles only `rand`) reports
nothing and exits `0`.
```
cd packages/rust && cargo test --test isolation --test rust_integration_lint waived
cd packages/rust && cargo test --test isolation stale_exempt
```
Expected: the waiver tests pass (#102) — a `unit/waived` out-of-module call and an
integration `waived` first-party double, each lifted by a `[[rust.exempt]]` entry,
exit `0`; a stale exempt entry makes the run error.