6.3 KiB
Context
The testing capability spec (Requirement: "Central runner subpath") already mandates that @suaveplan/testing/runner is the single, runner-swappable entry point for describe, it, expect, vi, and friends. 107 of 131 packages follow this. 19 do not — they import directly from vitest. Two more (dev/testing, dev/testing-dom) import from bun:test, but those are the wrapper-author packages and stay exempt.
Audit at 2026-05-03 ~14:00 confirmed the violation surface is exactly four names across all 19 packages: { describe, expect, it, vi }. No type-only imports, no MockInstance/Mock types, no vi.hoisted mock-factory imports. The migration is therefore mechanical — a single regex rewrite per file with no shape changes.
The drift went unnoticed because nothing in CI flags direct vitest imports. The companion gate for export * (rule 10 / §6a) caught analogous drift; this change adds the equivalent gate for the runner contract.
Goals / Non-Goals
Goals:
- Migrate all 19 violator packages to
from "@suaveplan/testing/runner". - Codify the rule as a
monorepo-structurerequirement with an explicit allowlist of exempt testing-infra packages. - Add a CI grep gate that prevents regression (mirrors the §13 barrel-export gate).
- Document the rule in
CLAUDE.md(Critical Rules) andopenspec/AGENTS.mdso it is enforced at code-review time and applied during future package migrations.
Non-Goals:
- Not changing the runner subpath itself — the conditional export already works and is exercised by 107 packages.
- Not migrating the 5 testing-infra packages (
dev/testing,dev/testing-dom,dev/testing-e2e,dev/testing-integration,dev/testing-integration-step-ca). They author or extend the wrapper and MUST import fromvitest/bun:testdirectly. - Not adding a
bun:testre-export for non-infra packages — the runner subpath'sbuncondition handles that automatically. - Not changing test behaviour, test counts, coverage thresholds, or runner configuration (
vitest.config.ts).
Decisions
Decision: Single-regex rewrite, per file
from ['"]vitest['"] → from "@suaveplan/testing/runner". Apply with bunx biome after the rewrite to normalise import-name ordering. Verify per package with bunx turbo lint typecheck test:coverage --filter=@suaveplan/<pkg> — coverage MUST stay at 100% on touched modules (CLAUDE.md pre-publish gate).
Alternative considered: AST-based codemod (ts-morph). Rejected — the violation set is { describe, expect, it, vi } with no destructure variations and no edge cases; a codemod is overkill for ~150 lines of replacement.
Decision: Allowlist via package-name array, not path glob
The exemption list lives in the CI gate as EXEMPT_PACKAGES = ["@suaveplan/testing", "@suaveplan/testing-dom", "@suaveplan/testing-e2e", "@suaveplan/testing-integration", "@suaveplan/testing-integration-step-ca"] and is read from each package's package.json#name. Glob-on-path was considered but rejected — packages may move folders (the dev/ parent is itself a recent reshuffle) and the npm name is the stable identity per MANIFEST.md.
Decision: CI gate as a standalone script, not a Biome rule
A custom Biome rule would be ideal but Biome's plugin API is not yet stable enough for repo-local rules at our Biome version. Instead, ship scripts/lint/check-test-runner-imports.ts (Bun-native) and wire it into turbo.json under a new lint:test-runner-imports task that runs alongside the existing barrel-export check. The script:
- Walks
packages/*/*/package.jsonto collectnames. - Skips entries in
EXEMPT_PACKAGES. - For each remaining package, greps
src/**/*.{ts,tsx}forfrom ['"]vitest['"]andfrom ['"]bun:test['"]. - Exits non-zero with a per-file error list when any are found.
Runtime budget: <1s on the full repo (the existing barrel grep is ~400ms).
Alternative considered: ESLint with no-restricted-imports. Rejected — the repo uses Biome, not ESLint. Adding ESLint just for one rule is dependency bloat.
Decision: Migration order — by domain, not alphabetical
Migrate one domain at a time so each PR-equivalent unit is a coherent set: bitpacker/* (7 pkgs) → network/* (7 pkgs) → crypto/* (2 pkgs) → media/voice + storage/storage + audio/third-party-elevenlabs (3 pkgs). Verification gate runs after each domain. This bounds blast radius if a hidden vitest-only API surface appears (none expected, but cheap insurance).
Risks / Trade-offs
- [Risk] A test file imports a vitest symbol the runner barrel doesn't re-export. → Mitigation: audit confirmed the surface is
{ describe, expect, it, vi }only — all four are already re-exported. If a future violator emerges with a missing symbol, extendpackages/testing/testing/src/runner/vitest.tsto re-export it (additive, no breaking change). - [Risk] The
buncondition resolves tobun-test.ts, which may not export a symbol present in the vitest path. → Mitigation: the dual-runner conformance test (runner.spec-conformance.test.ts) already pins surface parity; no migrated package consumes a vitest-only API today. If divergence shows up in the future, the conformance test fails, not the migrated package. - [Risk]
bunx biome formatreorders imports in a way that breaks existing test isolation (e.g., side-effect import order). → Mitigation: test files have no side-effect imports today (verified by spot-check). Per-packagebunx turbo test:coverageafter migration catches any breakage immediately. - [Trade-off] The CI grep gate doesn't catch dynamic imports (
await import("vitest")). → Accepted: dynamic imports of vitest in test files are vanishingly rare and would be obvious in code review. The cost of a full AST scan is not justified.
Migration Plan
Per domain:
- Run rewrite (
grep -rl 'from "vitest"' packages/<domain> | xargs sed -i 's|from "vitest"|from "@suaveplan/testing/runner"|'). bunx biome check --write packages/<domain>to normalise.bunx turbo lint typecheck test:coverage --filter=./packages/<domain>/*— must be green at 100%.- Commit per domain (
refactor(<domain>): use canonical @suaveplan/testing/runner imports).
After all five domain waves are green, ship the rule + CI gate in a single follow-up commit. Rollback: a single git revert per domain commit; the CI gate commit is independently revertable.