Files
TREK/server/scripts/size-lint.mjs
T
Maurice 4427c435a8 ci(server): module mocks, hand-built services and test file length in tests/ may only shrink
lint:test-mocks counts vi.mock/vi.doMock of src/config, src/nest/realtime and src/db per file under tests/, lint:test-new-service counts new XService(/new XRepository( there with every class whose constructor takes nothing allowed (derived from src/ and tests/helpers/), and lint:size holds files under tests/ to 2000 lines against their own baseline. Both counters share scripts/lib/count-ratchet.mjs, which gains a prepare hook for a check that reads the tree first. The three run as steps of the existing server lint job; the CLAUDE.md files and the module guide describe the test helpers they point to.
2026-10-10 20:16:12 +02:00

177 lines
6.6 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.
*
* Tests are held the same way under their own, wider limit: a file under
* tests/ (helpers and fixtures included) may hold TEST_LIMIT lines, the longer
* ones are listed in scripts/test-size-baseline.json, and a suite that needs
* to grow is split by the behaviour it covers.
*
* 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 source file without a baseline entry may hold. */
const LIMIT = 1000;
/** The most lines a test file without a baseline entry may hold. */
const TEST_LIMIT = 2000;
/** prettier's printWidth for the server: a line past it counts once per width it spans. */
const LINE_WIDTH = 120;
const SOURCE = /\.(?:[cm]?[jt]s)$/;
const TEST = /\.(?:test|spec)\./;
const nameOf = (key) => key.slice(key.lastIndexOf('/') + 1);
/**
* What the check holds. Each scope walks its trees (relative to the server
* root; each one must exist) against its own limit and baseline file.
*/
const SCOPES = [
{
label: 'size',
roots: ['src', 'scripts'],
limit: LIMIT,
baseline: 'size-baseline.json',
accepts: (key) => SOURCE.test(nameOf(key)) && !TEST.test(nameOf(key)),
advice: 'Split a concern into a service, helper or module of its own instead of growing the file.',
},
{
label: 'size (tests)',
roots: ['tests'],
limit: TEST_LIMIT,
baseline: 'test-size-baseline.json',
accepts: (key) => SOURCE.test(nameOf(key)),
advice: 'Split the suite by the behaviour it covers instead of growing the file.',
},
];
/**
* 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, scope) {
const counts = {};
for (const path of listFiles(serverDir, scope.roots, scope.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. */
const sizeEntries = (limit) => (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;
};
/** One scope against its baseline; answers whether it failed. */
function checkScope(serverDir, scope, update) {
const { limit } = scope;
const baselinePath = join(serverDir, 'scripts', scope.baseline);
const counts = scan(serverDir, scope);
let baseline = readBaseline(baselinePath, sizeEntries(limit));
if (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}. `) + scope.advice,
);
}
for (const { key, entry } of stale) {
console.error(
`FAIL ${key} is held at ${entry} in scripts/${scope.baseline}, ` +
(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(
`${scope.label}: ${Object.keys(counts).length} file(s), ` +
`${Object.keys(baseline).length} over ${limit} lines held at their baseline`,
);
return grown.length > 0 || stale.length > 0;
}
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 update = argv.includes('--update');
let failed = false;
for (const scope of SCOPES) failed = checkScope(serverDir, scope, update) || failed;
return failed ? 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);
}
});