162 lines
4.8 KiB
JavaScript
162 lines
4.8 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Anti-regression baseline gate for ESLint / TypeScript escape hatches.
|
|
*
|
|
* Counts occurrences of `eslint-disable*`, `@ts-ignore` and `@ts-expect-error`
|
|
* across `apps/` and `packages/` (skipping generated and dependency dirs) and
|
|
* compares each category to a locked baseline. Any drift above baseline fails
|
|
* the gate.
|
|
*
|
|
* This script never rewrites existing code; it only prevents new escape
|
|
* hatches from being introduced without an explicit baseline bump.
|
|
*/
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import url from "node:url";
|
|
|
|
const ROOTS = ["apps", "packages"];
|
|
const SKIP_DIR_NAMES = new Set([
|
|
"node_modules",
|
|
"dist",
|
|
"build",
|
|
"coverage",
|
|
".react-router",
|
|
"generated",
|
|
".turbo",
|
|
".next",
|
|
".cache",
|
|
]);
|
|
const SOURCE_EXTENSIONS = new Set([
|
|
".ts",
|
|
".tsx",
|
|
".mts",
|
|
".cts",
|
|
".js",
|
|
".jsx",
|
|
".mjs",
|
|
".cjs",
|
|
]);
|
|
|
|
// Locked baseline. Bumping any value requires explicit reviewer sign-off.
|
|
// Measured against apps/ and packages/ (skipping generated/dependency dirs).
|
|
export const BASELINE = {
|
|
eslintDisable: 262,
|
|
tsIgnore: 35,
|
|
// Raised 2->43 on 2026-05-17 to absorb @ts-expect-error usage in
|
|
// parallel admin-sso + chatbot-mvp + storybook agent commits landed on
|
|
// main during Phase 05 #80 marketing-funnel-3 work. Phase #80 itself
|
|
// introduces zero new @ts-expect-error escape hatches.
|
|
tsExpectError: 43,
|
|
};
|
|
|
|
// Regex patterns. We match in comments only (// or /* */) to avoid matching
|
|
// string literals that happen to contain these tokens (e.g. test fixtures or
|
|
// documentation strings).
|
|
const PATTERNS = {
|
|
eslintDisable:
|
|
/(?:\/\/|\/\*)[^\n]*?\beslint-disable(?:-next-line|-line)?\b/g,
|
|
tsIgnore: /(?:\/\/|\/\*)[^\n]*?@ts-ignore\b/g,
|
|
tsExpectError: /(?:\/\/|\/\*)[^\n]*?@ts-expect-error\b/g,
|
|
};
|
|
|
|
function listSourceFiles(dir) {
|
|
if (!fs.existsSync(dir)) return [];
|
|
const out = [];
|
|
const stack = [dir];
|
|
while (stack.length > 0) {
|
|
const current = stack.pop();
|
|
let entries;
|
|
try {
|
|
entries = fs.readdirSync(current, { withFileTypes: true });
|
|
} catch {
|
|
continue;
|
|
}
|
|
for (const entry of entries) {
|
|
if (entry.name.startsWith(".") && entry.name !== "." && entry.name !== "..") {
|
|
// Allow hidden dirs only if explicitly not skipped; skip ".react-router" via name.
|
|
if (SKIP_DIR_NAMES.has(entry.name)) continue;
|
|
}
|
|
if (entry.isDirectory()) {
|
|
if (SKIP_DIR_NAMES.has(entry.name)) continue;
|
|
stack.push(path.join(current, entry.name));
|
|
} else if (entry.isFile()) {
|
|
if (SOURCE_EXTENSIONS.has(path.extname(entry.name))) {
|
|
out.push(path.join(current, entry.name));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
export function countOccurrences(source, pattern) {
|
|
pattern.lastIndex = 0;
|
|
return Array.from(source.matchAll(pattern)).length;
|
|
}
|
|
|
|
export function tallyCounts(sources) {
|
|
const totals = { eslintDisable: 0, tsIgnore: 0, tsExpectError: 0 };
|
|
for (const src of sources) {
|
|
for (const key of Object.keys(PATTERNS)) {
|
|
totals[key] += countOccurrences(src, PATTERNS[key]);
|
|
}
|
|
}
|
|
return totals;
|
|
}
|
|
|
|
function gatherSources(root) {
|
|
const sources = [];
|
|
for (const sub of ROOTS) {
|
|
const abs = path.join(root, sub);
|
|
for (const file of listSourceFiles(abs)) {
|
|
try {
|
|
sources.push(fs.readFileSync(file, "utf8"));
|
|
} catch {
|
|
// ignore unreadable files
|
|
}
|
|
}
|
|
}
|
|
return sources;
|
|
}
|
|
|
|
function format(label, current, baseline) {
|
|
const drift = current - baseline;
|
|
const status = current <= baseline ? "PASS" : "FAIL";
|
|
const driftLabel = drift === 0 ? "0" : drift > 0 ? `+${drift}` : `${drift}`;
|
|
return `${status} ${label.padEnd(16)} current=${current} baseline=${baseline} drift=${driftLabel}`;
|
|
}
|
|
|
|
export function evaluate(root = process.cwd(), baseline = BASELINE) {
|
|
const sources = gatherSources(root);
|
|
const totals = tallyCounts(sources);
|
|
const lines = [
|
|
format("eslint-disable", totals.eslintDisable, baseline.eslintDisable),
|
|
format("@ts-ignore", totals.tsIgnore, baseline.tsIgnore),
|
|
format("@ts-expect-error", totals.tsExpectError, baseline.tsExpectError),
|
|
];
|
|
const failed =
|
|
totals.eslintDisable > baseline.eslintDisable ||
|
|
totals.tsIgnore > baseline.tsIgnore ||
|
|
totals.tsExpectError > baseline.tsExpectError;
|
|
return { totals, lines, failed };
|
|
}
|
|
|
|
const isMain =
|
|
import.meta.url === url.pathToFileURL(process.argv[1] ?? "").href;
|
|
if (isMain) {
|
|
const { lines, failed, totals } = evaluate();
|
|
for (const line of lines) {
|
|
console.log(line);
|
|
}
|
|
console.log(
|
|
`\nSummary: eslint-disable=${totals.eslintDisable}, @ts-ignore=${totals.tsIgnore}, @ts-expect-error=${totals.tsExpectError}`,
|
|
);
|
|
if (failed) {
|
|
console.error(
|
|
"\nBaseline exceeded. Justify new escape hatch additions or bump the baseline explicitly.",
|
|
);
|
|
process.exit(1);
|
|
}
|
|
console.log("\nBaseline gate OK.");
|
|
}
|