#!/usr/bin/env bun /** * check-test-runner-imports.ts * * CI gate enforcing the canonical test-runner-import rule defined in: * - openspec/specs/monorepo-structure/spec.md (Requirement: "Test files * import test-runner primitives via the canonical wrapper") * - openspec/AGENTS.md §7 "Test runner imports — mandatory" * - CLAUDE.md Critical Rules * * For every package under packages/// whose `package.json#name` * is NOT in the testing-infra exempt allowlist, this script scans * src/**\/*.{ts,tsx} for direct imports from "vitest" or "bun:test" and * exits non-zero with a per-file error list when any are found. * * Exempt allowlist (the only packages permitted to import vitest / bun:test * directly — they author or extend the @suaveplan/testing/runner wrapper): * * - @suaveplan/testing * - @suaveplan/testing-dom * - @suaveplan/testing-e2e * - @suaveplan/testing-integration * - @suaveplan/testing-integration-step-ca * * Adding a package to the allowlist requires its own OpenSpec change with * justification — extending fixtures or being a heavy @suaveplan/testing * consumer does NOT qualify. * * Usage: * bun scripts/lint/check-test-runner-imports.ts [--root ] * * Exit codes: * 0 — all non-exempt packages clean * 1 — at least one violation found (details printed to stderr) * 2 — internal error (cannot read repo, unparsable package.json, etc.) */ import { readdir, readFile, stat } from "node:fs/promises"; import { join, relative, resolve } from "node:path"; export const EXEMPT_PACKAGES: ReadonlySet = new Set([ "@suaveplan/testing", "@suaveplan/testing-dom", "@suaveplan/testing-e2e", "@suaveplan/testing-integration", "@suaveplan/testing-integration-step-ca", ]); const FORBIDDEN_IMPORT_REGEX = /^[^/]*\bimport\b[^;]*?\bfrom\s+(['"])(vitest|bun:test)\1/m; const FORBIDDEN_LINE_REGEX = /\bfrom\s+(['"])(vitest|bun:test)\1/; export interface Violation { readonly packageName: string; readonly file: string; readonly line: number; readonly source: string; } export interface CheckOptions { readonly repoRoot: string; readonly readDir?: typeof readdir; readonly readFileFn?: typeof readFile; readonly statFn?: typeof stat; } export async function findViolations( options: CheckOptions ): Promise { const readDirFn = options.readDir ?? readdir; const readFileImpl = options.readFileFn ?? readFile; const statImpl = options.statFn ?? stat; const violations: Violation[] = []; for (const root of [ "packages", // , "wip" ]) { const packagesDir = join(options.repoRoot, root); let categoryDirs: Awaited>; try { categoryDirs = await readDirFn(packagesDir, { withFileTypes: true, }); } catch { continue; } for (const categoryDirent of categoryDirs) { if (!categoryDirent.isDirectory()) continue; const categoryPath = join(packagesDir, categoryDirent.name); const pluginDirs = await readDirFn(categoryPath, { withFileTypes: true, }); for (const pluginDirent of pluginDirs) { if (!pluginDirent.isDirectory()) continue; const pluginPath = join(categoryPath, pluginDirent.name); const pkgDirs = await readDirFn(pluginPath, { withFileTypes: true, }); for (const pkgDirent of pkgDirs) { if (!pkgDirent.isDirectory()) continue; const pkgPath = join(pluginPath, pkgDirent.name); const manifestPath = join(pkgPath, "package.json"); let manifestRaw: string; try { manifestRaw = await readFileImpl(manifestPath, "utf8"); } catch { continue; } let manifest: { name?: string }; try { manifest = JSON.parse(manifestRaw) as { name?: string; }; } catch (cause) { throw new Error( `Cannot parse ${manifestPath}: ${(cause as Error).message}` ); } const name = manifest.name; if (typeof name !== "string" || name.length === 0) continue; if (EXEMPT_PACKAGES.has(name)) continue; const srcDir = join(pkgPath, "src"); try { await statImpl(srcDir); } catch { continue; } await walkAndScan({ dir: srcDir, packageName: name, repoRoot: options.repoRoot, violations, readDirFn, readFileImpl, }); } } } } return violations; } interface WalkArgs { readonly dir: string; readonly packageName: string; readonly repoRoot: string; readonly violations: Violation[]; readonly readDirFn: typeof readdir; readonly readFileImpl: typeof readFile; } async function walkAndScan(args: WalkArgs): Promise { const entries = await args.readDirFn(args.dir, { withFileTypes: true }); for (const entry of entries) { const fullPath = join(args.dir, entry.name); if (entry.isDirectory()) { await walkAndScan({ ...args, dir: fullPath }); continue; } if (!entry.isFile()) continue; if (!entry.name.endsWith(".ts") && !entry.name.endsWith(".tsx")) { continue; } const content = await args.readFileImpl(fullPath, "utf8"); if (!FORBIDDEN_IMPORT_REGEX.test(content)) continue; const lines = content.split("\n"); for (let i = 0; i < lines.length; i += 1) { const line = lines[i] ?? ""; if (/\bimport\b/.test(line) && FORBIDDEN_LINE_REGEX.test(line)) { // Skip lines that are obviously inside a backtick // template literal — `create-suaveplan-plugin` and // similar scaffolders embed test-runner imports as // template strings for the files they generate. // Heuristic: the line starts with optional indent // and a backtick character (template-literal opener). const trimmed = line.trimStart(); if (trimmed.startsWith("`") || trimmed.startsWith("\"`")) { continue; } args.violations.push({ packageName: args.packageName, file: relative(args.repoRoot, fullPath), line: i + 1, source: line.trim(), }); } } } } export function formatViolations(violations: readonly Violation[]): string { if (violations.length === 0) { return "All non-exempt packages use the canonical @suaveplan/testing/runner wrapper."; } const grouped = new Map(); for (const v of violations) { const list = grouped.get(v.packageName) ?? []; list.push(v); grouped.set(v.packageName, list); } const lines: string[] = [ "❌ Direct vitest / bun:test imports detected in non-exempt packages.", "", "Rule: openspec/AGENTS.md §7 'Test runner imports — mandatory'.", 'Fix: rewrite each line to `from "@suaveplan/testing/runner"`.', "", ]; for (const [pkg, items] of [...grouped].sort((a, b) => a[0].localeCompare(b[0]) )) { lines.push( ` ${pkg} (${items.length} violation${items.length === 1 ? "" : "s"})` ); for (const v of items) { lines.push(` ${v.file}:${v.line} ${v.source}`); } lines.push(""); } lines.push( `Total: ${violations.length} violation${violations.length === 1 ? "" : "s"} across ${grouped.size} package${grouped.size === 1 ? "" : "s"}.` ); return lines.join("\n"); } async function main(): Promise { const argv = process.argv.slice(2); let repoRoot = process.cwd(); for (let i = 0; i < argv.length; i += 1) { if (argv[i] === "--root" && i + 1 < argv.length) { repoRoot = resolve(argv[i + 1] ?? "."); i += 1; } } let violations: readonly Violation[]; try { violations = await findViolations({ repoRoot }); } catch (cause) { process.stderr.write( `check-test-runner-imports: internal error: ${(cause as Error).message}\n` ); process.exit(2); } const message = formatViolations(violations); if (violations.length === 0) { process.stdout.write(`${message}\n`); process.exit(0); } process.stderr.write(`${message}\n`); process.exit(1); } if (import.meta.main) { await main(); }