Files
TREK/server/scripts/size-lint.mjs
T
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

142 lines
5.4 KiB
JavaScript

#!/usr/bin/env node
/*
* lint:size: keeps server source files from growing into the next god service.
*
* The same ratchet as client/scripts/size-lint.mjs. A file past a thousand
* lines holds several concerns that no longer fit in one reading (maps,
* places and journey are the cautionary tales), and every change to one of
* them risks the others. Each source file under src/ and scripts/ may hold
* LIMIT lines; the files that were already longer are listed in
* scripts/size-baseline.json with the length they had, and the check fails
* when one of them grows past its entry. A file that needs to grow is split
* by concern instead (trips into trips/trip-members/trip-membership/
* trip-invite/trip-read-model/calendar is the precedent). An entry above what
* its file holds now, or for a file that is gone, fails as well until
* --update lowers it: otherwise the file could grow back unseen, and a new
* file at a deleted path would inherit its allowance.
*
* Lines are counted the way they read, not the way they are stored: a line
* longer than LINE_WIDTH (prettier's printWidth) counts once per LINE_WIDTH
* columns it spans, so joining lines does not buy room under the limit.
*
* npm run lint:size check against the baseline (CI)
* npm run lint:size -- --update lower the baseline to what the files hold now;
* it never raises an entry, and drops a file
* that is back under the limit
*
* --dir=<path> points the check at another server root (the unit tests use it).
* Walking the tree and handling the baseline live in scripts/lib/ratchet.mjs
* at the repository root.
*/
import { join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import {
listFiles,
lowerCounts,
RatchetError,
readBaseline,
readText,
runCli,
staleCounts,
toKey,
writeBaseline,
} from '../../scripts/lib/ratchet.mjs';
/** The most lines a file without a baseline entry may hold. */
const LIMIT = 1000;
/** prettier's printWidth for the server: a line past it counts once per width it spans. */
const LINE_WIDTH = 120;
/** The trees the check walks, relative to the server root. Each one must exist. */
const ROOTS = ['src', 'scripts'];
const SOURCE = /\.(?:[cm]?[jt]s)$/;
const TEST = /\.(?:test|spec)\./;
const accepts = (key) => {
const name = key.slice(key.lastIndexOf('/') + 1);
return SOURCE.test(name) && !TEST.test(name);
};
/**
* Lines as an editor numbers them (a final newline does not start another),
* with every line longer than LINE_WIDTH weighted by the widths it spans.
*/
function lineCount(text) {
if (text === '') return 0;
const lines = text.split('\n');
if (lines[lines.length - 1] === '') lines.pop();
let total = 0;
for (const line of lines) total += Math.max(1, Math.ceil(line.length / LINE_WIDTH));
return total;
}
/** Weighted line counts keyed by the server-relative POSIX path. A missing root is an error. */
function scan(serverDir) {
const counts = {};
for (const path of listFiles(serverDir, ROOTS, accepts)) counts[toKey(serverDir, path)] = lineCount(readText(path));
return counts;
}
/** Every entry must be a whole number above the limit: anything else is a hand edit gone wrong. */
function sizeEntries(value) {
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
return 'it must be an object of file paths to line counts';
}
for (const [file, n] of Object.entries(value)) {
if (!Number.isInteger(n) || n <= LIMIT) return `${file} holds ${JSON.stringify(n)}, expected an integer above ${LIMIT}`;
}
return null;
}
function check(argv) {
const dirArg = argv.find((a) => a.startsWith('--dir='));
const serverDir = dirArg ? resolve(dirArg.slice('--dir='.length)) : fileURLToPath(new URL('..', import.meta.url));
const baselinePath = join(serverDir, 'scripts', 'size-baseline.json');
const counts = scan(serverDir);
let baseline = readBaseline(baselinePath, sizeEntries);
if (argv.includes('--update')) {
baseline = lowerCounts(baseline, counts, LIMIT);
writeBaseline(baselinePath, baseline);
}
const grown = Object.entries(counts).filter(([file, n]) => n > Math.max(baseline[file] ?? 0, LIMIT));
const stale = staleCounts(baseline, counts, LIMIT);
for (const [file, n] of grown) {
const entry = baseline[file];
console.error(
`FAIL ${file}: ${n} lines, ` +
(entry ? `its baseline is ${entry}. ` : `the limit is ${LIMIT}. `) +
'Split a concern into a service, helper or module of its own instead of growing the file.',
);
}
for (const { key, entry } of stale) {
console.error(
`FAIL ${key} is held at ${entry} in scripts/size-baseline.json, ` +
(key in counts ? `but it has ${counts[key]} lines now.` : 'but the file is gone.'),
);
}
if (stale.length) {
console.error(
'Run npm run lint:size -- --update to lower the baseline with the change that made it smaller: ' +
'an entry above the file lets it grow back unseen.',
);
}
console.log(
`size: ${Object.keys(counts).length} file(s), ${Object.keys(baseline).length} over ${LIMIT} lines held at their baseline`,
);
return grown.length || stale.length ? 1 : 0;
}
await runCli('lint:size', (argv) => {
try {
return check(argv);
} catch (err) {
// A scan or baseline problem the lib reports as a plain Error is still a failure of the check itself.
if (err instanceof RatchetError) throw err;
throw new RatchetError(err.message);
}
});