265 lines
9.1 KiB
TypeScript
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();
|
|
}
|