115 lines
3.8 KiB
JavaScript
115 lines
3.8 KiB
JavaScript
#!/usr/bin/env node
|
|
import fs from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
const FILENAME = fileURLToPath(import.meta.url);
|
|
const REPO_ROOT = path.resolve(FILENAME, "..", "..");
|
|
|
|
export const SOURCE_HEALTH_RULES = Object.freeze([
|
|
{ id: "api-app-composition", path: "apps/api/src/app.ts", maxLines: 450 },
|
|
{ id: "api-route-modules", path: "apps/api/src/routes", maxLines: 500, glob: /\.ts$/ },
|
|
]);
|
|
|
|
export const SOURCE_HEALTH_ALLOWLIST = Object.freeze([]);
|
|
|
|
export async function countLines(filePath) {
|
|
const content = await fs.readFile(filePath, "utf8");
|
|
if (content.length === 0) {
|
|
return 0;
|
|
}
|
|
return content.endsWith("\n") ? content.split("\n").length - 1 : content.split("\n").length;
|
|
}
|
|
|
|
async function listFiles(target, matcher) {
|
|
const fullPath = path.join(REPO_ROOT, target);
|
|
const stat = await fs.stat(fullPath);
|
|
if (stat.isFile()) {
|
|
return [fullPath];
|
|
}
|
|
|
|
const entries = await fs.readdir(fullPath, { withFileTypes: true });
|
|
return entries
|
|
.filter((entry) => entry.isFile() && matcher.test(entry.name))
|
|
.map((entry) => path.join(fullPath, entry.name))
|
|
.sort((left, right) => left.localeCompare(right));
|
|
}
|
|
|
|
export async function evaluateSourceHealth({ rules = SOURCE_HEALTH_RULES, allowlist = SOURCE_HEALTH_ALLOWLIST } = {}) {
|
|
const allowed = new Map(allowlist.map((entry) => [entry.path, entry]));
|
|
const matchedAllowlistPaths = new Set();
|
|
const results = [];
|
|
|
|
for (const rule of rules) {
|
|
const files = await listFiles(rule.path, rule.glob ?? /[\s\S]/);
|
|
for (const file of files) {
|
|
const relativePath = path.relative(REPO_ROOT, file);
|
|
const lineCount = await countLines(file);
|
|
const exception = allowed.get(relativePath);
|
|
if (exception) {
|
|
matchedAllowlistPaths.add(relativePath);
|
|
}
|
|
const allowlistReason = exception?.reason?.trim() ?? "";
|
|
results.push({
|
|
ruleId: rule.id,
|
|
path: relativePath,
|
|
lines: lineCount,
|
|
maxLines: rule.maxLines,
|
|
allowed: Boolean(exception && allowlistReason),
|
|
reason: allowlistReason || null,
|
|
problem: exception && !allowlistReason ? "allowlist entry requires a reason" : null,
|
|
pass: exception ? Boolean(allowlistReason) : lineCount <= rule.maxLines,
|
|
});
|
|
}
|
|
}
|
|
|
|
for (const entry of allowlist) {
|
|
if (!matchedAllowlistPaths.has(entry.path)) {
|
|
results.push({
|
|
ruleId: "source-health-allowlist",
|
|
path: entry.path,
|
|
lines: 0,
|
|
maxLines: 0,
|
|
allowed: false,
|
|
reason: entry.reason?.trim() || null,
|
|
problem: "stale allowlist entry",
|
|
pass: false,
|
|
});
|
|
}
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
export async function runSourceHealthGate({ logger = console } = {}) {
|
|
const results = await evaluateSourceHealth();
|
|
const failures = results.filter((result) => !result.pass);
|
|
|
|
for (const result of results) {
|
|
const status = result.pass ? "PASS" : "FAIL";
|
|
const allowance = result.allowed ? ` allowlisted=${JSON.stringify(result.reason)}` : "";
|
|
const problem = result.problem ? ` problem=${JSON.stringify(result.problem)}` : "";
|
|
const writer = result.pass ? logger.log : logger.error;
|
|
writer(`${status} source_health rule=${result.ruleId} path=${result.path} lines=${result.lines} max=${result.maxLines}${allowance}${problem}`);
|
|
}
|
|
|
|
logger.log(`Summary: ${results.length - failures.length} passed, ${failures.length} failed`);
|
|
return { results, failures };
|
|
}
|
|
|
|
async function main() {
|
|
const outcome = await runSourceHealthGate();
|
|
if (outcome.failures.length > 0) {
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
const invokedDirectly = process.argv[1] && path.resolve(process.argv[1]) === FILENAME;
|
|
if (invokedDirectly) {
|
|
main().catch((error) => {
|
|
// eslint-disable-next-line no-console -- CLI gate must surface fatal errors on stderr.
|
|
console.error(error);
|
|
process.exit(1);
|
|
});
|
|
}
|