Files
2026-05-27 23:29:53 +01:00

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-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/<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:

  1. Walks packages/*/*/package.json to collect names.
  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/<domain> | xargs sed -i 's|from "vitest"|from "@suaveplan/testing/runner"|').
  2. bunx biome check --write packages/<domain> to normalise.
  3. bunx turbo lint typecheck test:coverage --filter=./packages/<domain>/* — must be green at 100%.
  4. 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.