mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
e5a5d167af
The client has had lint:size since #2612, the server had nothing, and the server is where the biggest files live: maps.service.ts holds 3231 lines and grew by 200 in a month, and nine more services are past 1100. scripts/size-lint.mjs carries the client's semantics over to src/ and scripts/: a file may hold 1000 lines, the 23 that already hold more are pinned at today's length in scripts/size-baseline.json, and --update only ever lowers an entry. A line past prettier's 120 columns counts once per 120 columns it spans, so joining lines does not make room under the limit. The check fails closed: a missing or malformed baseline or a missing src/ stops it instead of passing with nothing checked.
163 lines
6.5 KiB
JavaScript
163 lines
6.5 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).
|
|
*
|
|
* 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).
|
|
*/
|
|
import { existsSync, readdirSync, readFileSync, realpathSync, statSync, writeFileSync } from 'node:fs';
|
|
import { join, relative, resolve } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
/** The most lines a file without a baseline entry may hold. */
|
|
export const LIMIT = 1000;
|
|
|
|
/** prettier's printWidth for the server: a line past it counts once per width it spans. */
|
|
export const LINE_WIDTH = 120;
|
|
|
|
/** The trees the check walks, relative to the server root. Each one must exist. */
|
|
export const ROOTS = ['src', 'scripts'];
|
|
|
|
const SOURCE = /\.(?:[cm]?[jt]s)$/;
|
|
const TEST = /\.(?:test|spec)\./;
|
|
|
|
/**
|
|
* 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.
|
|
* A CRLF checkout counts the same as an LF one.
|
|
*/
|
|
export function lineCount(text) {
|
|
if (text === '') return 0;
|
|
const lines = text.replace(/\r\n?/g, '\n').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;
|
|
}
|
|
|
|
function walk(dir, files = []) {
|
|
for (const name of readdirSync(dir)) {
|
|
if (name === 'node_modules') continue;
|
|
const path = join(dir, name);
|
|
if (statSync(path).isDirectory()) walk(path, files);
|
|
else if (SOURCE.test(name) && !TEST.test(name)) files.push(path);
|
|
}
|
|
return files;
|
|
}
|
|
|
|
/** Weighted line counts keyed by the server-relative POSIX path. A missing root is an error. */
|
|
export function scan(serverDir) {
|
|
const counts = {};
|
|
for (const root of ROOTS) {
|
|
const dir = join(serverDir, root);
|
|
if (!existsSync(dir) || !statSync(dir).isDirectory()) {
|
|
throw new Error(`${root}/ does not exist under ${serverDir}: the check would pass without looking at anything`);
|
|
}
|
|
for (const path of walk(dir)) {
|
|
counts[relative(serverDir, path).split('\\').join('/')] = lineCount(readFileSync(path, 'utf8'));
|
|
}
|
|
}
|
|
return counts;
|
|
}
|
|
|
|
/**
|
|
* The baseline as committed. Missing, unreadable or malformed stops the run: read as
|
|
* empty, every long file would fail with no hint at the cause, and --update would
|
|
* replace it with an empty one.
|
|
*/
|
|
export function readBaseline(path) {
|
|
let parsed;
|
|
try {
|
|
parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
} catch (err) {
|
|
throw new Error(`scripts/size-baseline.json cannot be read: ${err.message}`);
|
|
}
|
|
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
throw new Error('scripts/size-baseline.json must be an object of file paths to line counts');
|
|
}
|
|
for (const [file, n] of Object.entries(parsed)) {
|
|
if (!Number.isInteger(n) || n <= LIMIT) {
|
|
throw new Error(`scripts/size-baseline.json: ${file} holds ${JSON.stringify(n)}, expected an integer above ${LIMIT}`);
|
|
}
|
|
}
|
|
return parsed;
|
|
}
|
|
|
|
/** The baseline lowered to what the files hold now. Never raises an entry, never adds one. */
|
|
export function lowerBaseline(baseline, counts) {
|
|
const lowered = {};
|
|
for (const [file, allowed] of Object.entries(baseline)) {
|
|
const now = counts[file] ?? 0;
|
|
if (now > LIMIT) lowered[file] = Math.min(allowed, now);
|
|
}
|
|
return Object.fromEntries(Object.entries(lowered).sort(([a], [b]) => a.localeCompare(b)));
|
|
}
|
|
|
|
/** Files over their allowance, and baseline entries the files have shrunk below. */
|
|
export function compare(baseline, counts) {
|
|
const grown = Object.entries(counts).filter(([file, n]) => n > Math.max(baseline[file] ?? 0, LIMIT));
|
|
const lowerable = Object.entries(baseline).filter(([file, n]) => (counts[file] ?? 0) < n);
|
|
return { grown, lowerable };
|
|
}
|
|
|
|
function main(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 update = argv.includes('--update');
|
|
|
|
const counts = scan(serverDir);
|
|
let baseline = readBaseline(baselinePath);
|
|
if (update) {
|
|
baseline = lowerBaseline(baseline, counts);
|
|
writeFileSync(baselinePath, JSON.stringify(baseline, null, 2) + '\n');
|
|
}
|
|
|
|
const { grown, lowerable } = compare(baseline, counts);
|
|
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.',
|
|
);
|
|
}
|
|
if (lowerable.length && !update) {
|
|
console.log(`${lowerable.length} file(s) are shorter than their baseline now: run with --update to lower it.`);
|
|
}
|
|
console.log(
|
|
`size: ${Object.keys(counts).length} file(s), ${Object.keys(baseline).length} over ${LIMIT} lines held at their baseline`,
|
|
);
|
|
return grown.length ? 1 : 0;
|
|
}
|
|
|
|
// Compared by real path, so the check still runs when the script is started through a symlink.
|
|
const isCli =
|
|
Boolean(process.argv[1]) && existsSync(process.argv[1]) && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
if (isCli) {
|
|
try {
|
|
process.exitCode = main(process.argv.slice(2));
|
|
} catch (err) {
|
|
console.error(`FAIL ${err.message}`);
|
|
process.exitCode = 1;
|
|
}
|
|
}
|