Isolation
Two checks — unit-lint and integration-lint — enforce one boundary from opposite sides. Isolation is about which collaborators a test may fake, and the answer is opposite for the two kinds of test:
- A unit test isolates one unit. Everything it depends on is mocked — first-party collaborators and external packages alike — so a failure points at the unit, not a collaborator. A unit test that touches a real collaborator behaves like an integration test: slower, and ambiguous when it fails.
- An integration test isolates the system. First-party code runs for real, and only the outside world is mocked. An integration test that mocks first-party code is testing a fiction: the assembled system it claims to exercise never actually runs.
The first-party/external line
Both checks turn on the same distinction, drawn deterministically with no module resolution:
| Specifier | Origin | Example |
|---|---|---|
| relative / absolute path | first-party | ./service, ../core |
the dist's own package (Python, from pyproject.toml [project].name; Rust, from Cargo.toml) | first-party | ourpkg.ledger |
node:-prefixed or a builtin name | external (built-in) | node:fs, child_process |
| any other bare specifier | external (third-party) | stripe, @scope/pkg |
"External" means more than third-party packages: it includes effectful standard-library APIs — the filesystem, the clock, randomness, the network, subprocess. An un-mocked external call is what makes a test slow, flaky, or a charge on someone's bill, so the boundary is drawn there. Pure stdlib (json, dataclasses, std::collections) is nobody's collaborator.
Where each check finds its subjects
The two checks split the tree by the standard's layout. unit-lint scans the colocated unit tests under the call's source, leaving <package root>/tests/ to the suite tiers. integration-lint derives its subjects from the package root — the nearest directory at or above source holding the language's manifest — per the standard layout: the integration suite in tests/integration/, the e2e suite in tests/e2e/. Both suites run first-party code for real, so both are held to the integration rules. (Rust's cargo layout keeps both out-of-crate suites in the crate root's tests/, so that whole directory is the subject there.) A test file under <package root>/tests/ outside a standard tier is flagged as unknown-tier: the layout is part of the standard, and a suite the scan would silently miss is an error instead. A tree with no manifest — loose scripts — is scanned at source directly.
What unit-lint flags
The unit suite's side: every collaborator is mocked.
- TypeScript —
unmocked-collaborator: any runtime import a unit test doesn'tvi.mock(). Named imports of the pureparsefunction fromjson5andyaml, and pure Node path functionsbasename,dirname,extname,join, andnormalizefromnode:pathorpath, are value preparation rather than collaborators. The allowlist applies to those named imports; namespace, default, side-effect, and other imports from the same modules remain checked. Three imports are never collaborators: the unit under test (widget.test.ts→./widget), type-only imports, and the test runner (vitest). A mock matches its import by resolved module, so the extension may differ between the two — Vitest resolves./formatterand./formatter.jsto the same module, so a.jsimport mocked bare (or the inverse) counts as mocked. Plusuntyped-mock: a mock factory with novi.importActual<typeof import(...)>()type anchor, so the double can't drift from the real module. - Python —
unmocked-collaborator: an imported collaborator the colocated*_test.pydoesn't mock, both first-party and external (a third-party package, or effectful stdlib such assocket,subprocess,random). Never collaborators: the unit under test, the test framework, pure stdlib (__future__and the other underscore-prefixed modules included), and type-only imports. Pure parsersast.parse,tomllib.loads, andjson.loadsare allowed from their standard-library modules. Third-party imports remain checked by default. The canonical unit test imports the unit under test and patches collaborators by string in a fixture. When a collaborator is imported, apatch(...)mocks it only when its target names that import's own module:from pkg.ledger import recordis mocked bypatch("pkg.ledger.record"), and each imported symbol must be patched — afrom pkg.ledger import record, erasethat patches onlyrecordleaveserasea real collaborator. A patch that merely shares a last segment with a different module (patch("other.record"),patch("json.dumps")) does not mock it. A re-export barrel is the unit under test too: in__init___test.py, a barefrom . import …names the package's own__init__.pysurface —Thing,__all__,__version__— so those names are what the test verifies, never collaborators. (Reaching around the barrel into a sibling module —from .core import Thingin__init___test.py— imports a real collaborator and is flagged.) This matches TypeScript, whereindex.test.tsimporting./index.jsis the unit under test. - Rust — the same intent, structurally:
no-out-of-module-callandno-out-of-module-importflag a unit test (an inline#[cfg(test)]module) that reaches out of its own module —crate::…, an external crate, or effectfulstd(net,thread,os,process::Commandand its siblings,env::varand its siblings, the clock). Exact named pure construction and parsing paths are allowed:clap::Command::new,clap::Arg::new,clap::Error::new,syn::parse_str,syn::parse_file,toml::from_str, archive fixture constructors inzip,flate2, andtar, andstd::process::ExitStatus::from_raw. Their named value-type imports are allowed separately; glob imports remain checked. Other calls and imports from those crates, includingstd::process::Command, remain checked. A singlesuper::(the unit under test),self, and purestdstay in-module. Inject a trait double for a collaborator instead. The filesystem is a Rust-specific carve-out:std::fsandstd::env::temp_dirstay in-module. Rust privacy is the reason. A private item is reachable only from its own module, so the inline#[cfg(test)]module is the only tier that can test a private path-walker — and that walker's argument is a directory that has to exist. Forbiddingfsthere forbids the one tier that can see the unit from exercising it. Python and TypeScript have no such constraint: a sibling test file reaches a module-private function directly, so its filesystem work can move out to the integration tier, and there it stays flagged.std::env::varis still a collaborator — it reads ambient state the test did not create;temp_dironly names a writable directory, andprocess::id— the other half of that naming idiom — only reports the runner's own PID. A unit test is a module gated by a positively-requiredtest(#[cfg(test)],#[cfg(all(test, …))]); a#[cfg(not(test))]module compiles in non-test builds, so it is production code and its out-of-module calls are left alone. The scan reads the crate's own unit source, skippingtests/integration crates and thetarget/build directory, so a locally-built crate is scanned the same as a fresh checkout.
What integration-lint flags
The integration suite's side: first-party code runs for real.
- TypeScript —
no-first-party-mock: avi.mock()/vi.doMock()of a first-party module. Mockingstripeornode:fsis fine; mocking../src/ledgeris the violation. - Python —
no-first-party-patch: apatch(...),patch.object(...), orpatch.dict(...)whose target is the dist's own package. A string target is read directly; an object target is resolved through the file's imports — afterfrom myproject import ledger,patch.object(ledger, "record")namesmyproject.ledger.record. An attribute reached through a first-party module is classified by what it names, read from that module's own top-level source: withasync_mod.pyholdingimport asyncio,patch.object(async_mod.asyncio, "to_thread")patches the stdlibasynciomodule and passes; with it holdingfrom . import helper,patch.object(async_mod.helper, "run")patches first-partymyproject.helperand is flagged. Patchingrequests.postorsubprocess.runis fine; patchingourpkg.ledger.recordis the violation. Four hygiene lints ride along, keeping the mechanism of mocking disciplined:no-monkeypatch(useunittest.mockin a fixture, so patches are declared, not sprinkled),no-inline-patch(a patch lives in a fixture, not a test body),no-environ-mutation(set env viapatch.dict(os.environ, ...), so it's restored), andno-constant-patch(inject config explicitly instead of patching a module global —patch("ourpkg.cfg.CACHE_DIR")andpatch.object(cfg, "CACHE_DIR")are the same patch). - Rust —
no-first-party-double: a#[double](mockall_double) of the crate under test or apathdependency. Only external crates andstdmay be doubled.
A target that resists static reading is left alone — the checks are deterministic first. vi.mock(name) and patch(target) hold the target in a variable, and a patch.object / patch.dict base bound by no import (patch.object(get_mod(), "x"), a local variable, a fixture argument) resolves to no module, so neither is classified. The same holds one level deeper: a module attribute whose module's top-level source leaves it unnamed — a dynamic assignment, a conflicting binding, a source file the scan cannot find — resolves to no target and is left alone.
When a lint fires on a real design constraint
Every one of these rules is waivable per file with a reason-required exemption — and the bar for using one is high: what feels untestable usually needs a technique (inject the dependency, patch by string in a fixture, drive the boundary directly), not a waiver. The reasoned entry exists so that when a file genuinely earns one, the omission is named and auditable rather than silent.