Files
suaveplan/scripts/lint/check-test-runner-imports.ts
2026-05-27 23:47:53 +01:00

265 lines
9.1 KiB
TypeScript

#!/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/<plugin>/<pkg>/ 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 <repo-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<string> = 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<Violation[]> {
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<ReturnType<typeof readDirFn>>;
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<void> {
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<string, Violation[]>();
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<void> {
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();
}