## 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-structure` requirement 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) and `openspec/AGENTS.md` so 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 from `vitest` / `bun:test` directly. - Not adding a `bun:test` re-export for non-infra packages — the runner subpath's `bun` condition 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/` — 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: 1. Walks `packages/*/*/package.json` to collect `name`s. 2. Skips entries in `EXEMPT_PACKAGES`. 3. For each remaining package, greps `src/**/*.{ts,tsx}` for `from ['"]vitest['"]` and `from ['"]bun:test['"]`. 4. 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, extend `packages/testing/testing/src/runner/vitest.ts` to re-export it (additive, no breaking change). - **[Risk] The `bun` condition resolves to `bun-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 format` reorders 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-package `bunx turbo test:coverage` after 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: 1. Run rewrite (`grep -rl 'from "vitest"' packages/ | xargs sed -i 's|from "vitest"|from "@suaveplan/testing/runner"|'`). 2. `bunx biome check --write packages/` to normalise. 3. `bunx turbo lint typecheck test:coverage --filter=./packages//*` — must be green at 100%. 4. Commit per domain (`refactor(): 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.