Files
TREK/scripts/lib/ratchet.mjs
Maurice 889afeae92 build(scripts): server ratchets share one baseline library at the repository root
scripts/lib/ratchet.mjs carries the tree walk, the CRLF-safe reader and the
baseline handling the client ratchets already use, next to a warning tally and
a format comparison that take ESLint and Prettier from the calling workspace.
server lint:size now runs on it with the same messages and exit codes, and the
libraries have node:test suites that CI runs in the server job.
2026-10-10 11:45:27 +02:00

199 lines
7.8 KiB
JavaScript

/*
* The parts every ratchet shares: walking the tree, reading a file the same
* way on Windows and Linux, and reading, lowering and writing a baseline.
*
* This is the repository-wide copy. The server and shared ratchets
* (server/scripts/*-lint.mjs, shared/scripts/format-lint.mjs) import it from
* here; client/scripts/lib/ratchet.mjs is the same file and moves onto this
* one with the client's next tooling change. A baseline always lives in the
* scripts/ directory of its workspace, which is the path the messages name.
*
* A ratchet fails closed. A baseline that is missing, cannot be parsed or has
* the wrong shape stops the run with its reason instead of reading as empty
* (as empty, the check would fail with no hint at the cause, and --update
* would write an empty file over the real one). A directory that should be
* scanned and is not there, or a scan that finds no file at all, stops it too:
* a check that looked at nothing must not report green.
*/
import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
import { basename, join, relative } from 'node:path';
/** A problem with the check itself rather than a violation it found. */
export class RatchetError extends Error {}
export const TEST_FILE = /\.(?:test|spec)\.[cm]?[jt]sx?$/;
/** Every file under root/<dir> for each dir whose path passes accept, as absolute paths. */
export function listFiles(root, dirs, accept) {
const files = [];
const walk = (dir) => {
for (const name of readdirSync(dir)) {
if (name === 'node_modules') continue;
const path = join(dir, name);
if (statSync(path).isDirectory()) walk(path);
else if (accept(toKey(root, path))) files.push(path);
}
};
for (const dir of dirs) {
const path = join(root, dir);
if (!existsSync(path) || !statSync(path).isDirectory()) {
throw new RatchetError(`${dir}/ does not exist under ${root}, so there is nothing to check`);
}
walk(path);
}
if (!files.length) throw new RatchetError(`no file to check under ${dirs.map((d) => `${d}/`).join(', ')}`);
return files.sort();
}
/** A path relative to root with forward slashes, the form every baseline uses. */
export function toKey(root, path) {
return relative(root, path).split('\\').join('/');
}
/**
* A text file as the repository holds it: without a byte order mark and with
* LF line ends, so a Windows checkout with CRLF counts the same as CI.
*/
export function readText(path) {
const text = readFileSync(path, 'utf8');
return (text.charCodeAt(0) === 0xfeff ? text.slice(1) : text).replace(/\r\n?/g, '\n');
}
const isCount = (n) => Number.isInteger(n) && n > 0;
/** A JSON object of file → positive count. */
export function countMap(value) {
if (!value || typeof value !== 'object' || Array.isArray(value)) return 'it is not an object of file → count';
for (const [key, n] of Object.entries(value))
if (!isCount(n)) return `"${key}" holds ${JSON.stringify(n)}, not a positive whole number`;
return null;
}
/** A JSON array of distinct file paths. */
export function fileList(value) {
if (!Array.isArray(value)) return 'it is not an array of file paths';
const seen = new Set();
for (const entry of value) {
if (typeof entry !== 'string' || !entry) return `${JSON.stringify(entry)} is not a file path`;
if (seen.has(entry)) return `"${entry}" is listed twice`;
seen.add(entry);
}
return null;
}
/**
* The baseline as committed, checked against shape (one of the validators
* above, which returns null when the value fits and the reason otherwise).
*/
export function readBaseline(path, shape) {
const name = basename(path);
let text;
try {
text = readFileSync(path, 'utf8');
} catch (err) {
throw new RatchetError(`scripts/${name} cannot be read (${err.code ?? err.message}); restore it from git`);
}
let value;
try {
value = JSON.parse(text);
} catch (err) {
throw new RatchetError(`scripts/${name} is not valid JSON (${err.message}); restore it from git`);
}
const problem = shape(value);
if (problem) throw new RatchetError(`scripts/${name} has the wrong shape: ${problem}; restore it from git`);
return value;
}
/** Writes value as sorted, two-space JSON with a final newline. */
export function writeBaseline(path, value) {
const sorted = Array.isArray(value)
? [...value].sort()
: Object.fromEntries(Object.entries(value).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)));
writeFileSync(path, JSON.stringify(sorted, null, 2) + '\n');
}
/**
* The baseline lowered to the counts measured now. An entry never rises, a
* file that is not in the baseline never enters it, and an entry whose count
* fell to floor or below leaves. floor is a number, or a function of the key
* when the limit differs between files.
*
* @param {Record<string, number>} baseline
* @param {Record<string, number>} counts
* @param {number | ((key: string) => number)} [floor]
*/
export function lowerCounts(baseline, counts, floor = 0) {
const lowered = {};
for (const [key, allowed] of Object.entries(baseline)) {
const now = counts[key] ?? 0;
if (now > (typeof floor === 'function' ? floor(key) : floor)) lowered[key] = Math.min(allowed, now);
}
return lowered;
}
/**
* The entries of a count baseline that allow more than is measured now: a
* count that fell below its entry, one back at floor or under it, or a file
* that is gone. Exactly the entries lowerCounts would lower or drop. Each one
* fails its check until --update lowers it, or the count could grow back to
* the old entry unseen, and a deleted file would hand its allowance to the
* next file at its path.
*
* @param {Record<string, number>} baseline
* @param {Record<string, number>} counts
* @param {number | ((key: string) => number)} [floor]
* @returns {{ key: string, entry: number, now: number }[]}
*/
export function staleCounts(baseline, counts, floor = 0) {
const lowered = lowerCounts(baseline, counts, floor);
return Object.entries(baseline)
.filter(([key, entry]) => lowered[key] !== entry)
.map(([key, entry]) => ({ key, entry, now: counts[key] ?? 0 }));
}
/**
* Prints a FAIL line for each stale entry (see staleCounts) of the baseline
* scripts/<file>, then how to lower them. With root, an entry whose file is
* not there is called gone; label names an entry that is not a file path.
*
* @param {{ key: string, entry: number, now: number }[]} stale
* @param {{ file: string, command: string, root?: string, label?: (key: string) => string, error: (line: string) => void }} options
*/
export function reportStale(stale, { file, command, root, label = (key) => key, error }) {
for (const { key, entry, now } of stale) {
const gone = root !== undefined && !existsSync(join(root, key));
error(
`FAIL ${label(key)} is held at ${entry} in scripts/${file}, ` +
(gone ? 'but the file is gone.' : `but there are ${now} now.`)
);
}
if (stale.length) {
error(
`Run npm run ${command} -- --update to lower the baseline with the change that made it smaller: ` +
'an entry above the count lets it grow back unseen.'
);
}
}
/** The baseline entries that are still offenders: a list only ever loses files. */
export function lowerList(baseline, offenders) {
const now = new Set(offenders);
return baseline.filter((file) => now.has(file));
}
/**
* Runs one ratchet as a command. main returns the exit code; a RatchetError is
* printed as a failure of the check itself. There is no "am I the entry
* point" guard to get wrong: the command files only ever run, and the logic
* they call lives in scripts/lib/ where tests import it.
*/
export async function runCli(name, main) {
try {
process.exitCode = await main(process.argv.slice(2));
} catch (err) {
if (!(err instanceof RatchetError)) throw err;
console.error(`FAIL ${name}: ${err.message}`);
process.exitCode = 1;
}
}