92 lines
3.4 KiB
JavaScript
92 lines
3.4 KiB
JavaScript
#!/usr/bin/env node
|
|
// Anti-regression gate: asserts a minimum test-file-per-source-file density
|
|
// per workspace. Catches "route added without test" without requiring full
|
|
// coverage tooling. Floors are deliberate snapshots of the current ratio;
|
|
// they must only go down via deliberate commits (= more tests added).
|
|
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
const repoRoot = path.resolve(fileURLToPath(import.meta.url), "..", "..");
|
|
|
|
const TARGETS = [
|
|
{ name: "apps/api", root: "apps/api/src", minRatio: 0.18 },
|
|
{ name: "apps/worker", root: "apps/worker/src", minRatio: 0.18 },
|
|
{ name: "apps/web app routes", root: "apps/web/app/routes", minRatio: 0.05 },
|
|
{ name: "apps/web app modules", root: "apps/web/app/modules", minRatio: 0.04 },
|
|
{ name: "apps/web app utils", root: "apps/web/app/utils", minRatio: 0.10 },
|
|
{ name: "packages/foundation", root: "packages/foundation/src", minRatio: 0.10 },
|
|
{ name: "packages/db", root: "packages/db/src", minRatio: 0.05 },
|
|
{ name: "packages/mail", root: "packages/mail/src", minRatio: 0.30 },
|
|
{ name: "packages/queue", root: "packages/queue/src", minRatio: 0.10 },
|
|
{ name: "packages/i18n", root: "packages/i18n/src", minRatio: 0.30 },
|
|
{ name: "packages/brand", root: "packages/brand/src", minRatio: 0.50 },
|
|
{ name: "packages/config", root: "packages/config/src", minRatio: 0.50 },
|
|
];
|
|
|
|
const ignoredDirs = new Set(["node_modules", "dist", "build", "coverage", ".react-router", "generated", "__generated__"]);
|
|
const sourcePattern = /\.(?:ts|tsx)$/;
|
|
const testPattern = /\.(?:test|spec)\.(?:ts|tsx)$/;
|
|
const declarationPattern = /\.d\.ts$/;
|
|
|
|
export function walk(dir, acc = { source: 0, test: 0 }) {
|
|
if (!fs.existsSync(dir)) return acc;
|
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
if (ignoredDirs.has(entry.name) || entry.name.startsWith(".")) continue;
|
|
const full = path.join(dir, entry.name);
|
|
if (entry.isDirectory()) {
|
|
walk(full, acc);
|
|
continue;
|
|
}
|
|
if (!entry.isFile()) continue;
|
|
if (declarationPattern.test(entry.name)) continue;
|
|
if (testPattern.test(entry.name)) {
|
|
acc.test += 1;
|
|
} else if (sourcePattern.test(entry.name)) {
|
|
acc.source += 1;
|
|
}
|
|
}
|
|
return acc;
|
|
}
|
|
|
|
export function evaluateTargets(targets, rootDir = repoRoot) {
|
|
const failures = [];
|
|
const passes = [];
|
|
|
|
for (const target of targets) {
|
|
const counts = walk(path.join(rootDir, target.root));
|
|
if (counts.source === 0) {
|
|
passes.push(`SKIP ${target.name} no source files`);
|
|
continue;
|
|
}
|
|
const ratio = counts.test / counts.source;
|
|
const line = `${target.name} source=${counts.source} tests=${counts.test} ratio=${ratio.toFixed(3)} min=${target.minRatio.toFixed(3)}`;
|
|
if (ratio >= target.minRatio) {
|
|
passes.push(`PASS ${line}`);
|
|
} else {
|
|
failures.push(`FAIL ${line} (drift=${(ratio - target.minRatio).toFixed(3)})`);
|
|
}
|
|
}
|
|
|
|
return { failures, passes };
|
|
}
|
|
|
|
export function formatSummary(result) {
|
|
return `Summary: ${result.passes.length} passed, 0 observed, ${result.failures.length} failed`;
|
|
}
|
|
|
|
function main() {
|
|
const result = evaluateTargets(TARGETS);
|
|
|
|
for (const line of result.passes) console.log(line);
|
|
for (const line of result.failures) console.error(line);
|
|
console.log(formatSummary(result));
|
|
|
|
if (result.failures.length > 0) process.exit(1);
|
|
}
|
|
|
|
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
main();
|
|
}
|