mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 15:36:33 +00:00
ci(server): hold source files to 1000 lines with a size baseline
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.
This commit is contained in:
@@ -17,8 +17,11 @@ npm run gen:plugin-facts # regenerate the plugin-protocol tables into plugin-sd
|
||||
npm run check:plugin-facts # CI gate — fails if the generated copies drifted
|
||||
node scripts/coverage-thresholds.mjs # after test:coverage — prints the ratchet block for vitest.config.ts
|
||||
npm run db:call-graph -- --sync --tx [--domains nest/days,…] # sync DB-touching methods + transaction sites (exit 1 if any)
|
||||
npm run lint:size # no file in src/ or scripts/ past 1000 lines, longer ones only shrink (CI gate)
|
||||
```
|
||||
|
||||
**`lint:size`** (`scripts/size-lint.mjs`) counts a line longer than 120 columns once per 120 columns it spans, so joining lines does not make room. Files that were already longer are held at their length in `scripts/size-baseline.json`; a file that needs to grow past its entry is split by concern instead. After a split, `npm run lint:size -- --update` lowers the baseline (it never raises or adds an entry).
|
||||
|
||||
Single test: `npx vitest run tests/unit/nest/weather.controller.test.ts`, or `npx vitest run -t "returns 401 without cookie"`.
|
||||
|
||||
**Build emits even with type errors** (`scripts/build.mjs` catches `tsc` failures and warns). `npm run build` succeeding does **not** mean the code typechecks — run `npm run typecheck`.
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"typecheck:tests": "tsc -p tsconfig.tests.json --noEmit",
|
||||
"typecheck:scripts": "tsc -p tsconfig.scripts.json --noEmit",
|
||||
"db:call-graph": "node scripts/db-call-graph.mjs",
|
||||
"lint:size": "node scripts/size-lint.mjs",
|
||||
"gen:plugin-facts": "node --import tsx scripts/gen-plugin-facts.ts",
|
||||
"check:plugin-facts": "node --import tsx scripts/gen-plugin-facts.ts --check",
|
||||
"gen:entities": "node --import tsx scripts/generate-entities.ts",
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"scripts/db-call-graph.mjs": 1036,
|
||||
"scripts/generate-entities.ts": 2186,
|
||||
"src/db/dialect/sql-functions.ts": 1007,
|
||||
"src/db/repositories/DayAssignments.repository.ts": 1030,
|
||||
"src/db/repositories/Places.repository.ts": 1483,
|
||||
"src/db/repositories/Reservations.repository.ts": 1806,
|
||||
"src/db/repositories/Trips.repository.ts": 1145,
|
||||
"src/db/repositories/Users.repository.ts": 1679,
|
||||
"src/nest/atlas/atlas-geo.ts": 1005,
|
||||
"src/nest/atlas/atlas.service.ts": 1182,
|
||||
"src/nest/auth/auth.service.ts": 1161,
|
||||
"src/nest/budget/budget.service.ts": 1703,
|
||||
"src/nest/collections/collections.service.ts": 1488,
|
||||
"src/nest/doc-sync/doc-sync.service.ts": 1060,
|
||||
"src/nest/doc-sync/providers/synology-drive.client.ts": 1160,
|
||||
"src/nest/journey/journey-domain.service.ts": 1806,
|
||||
"src/nest/maps/maps.service.ts": 3252,
|
||||
"src/nest/places/places.service.ts": 2022,
|
||||
"src/nest/plugins/plugin-runtime.service.ts": 1472,
|
||||
"src/nest/plugins/runtime/plugin-sdk.ts": 1094,
|
||||
"src/nest/reservations/reservations.service.ts": 1248,
|
||||
"src/nest/trips/trips.service.ts": 1014,
|
||||
"src/nest/vacay/vacay.service.ts": 1442
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
#!/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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* scripts/size-lint.mjs is the CI gate that keeps a server source file from
|
||||
* growing past 1000 lines (or past its baseline entry). A gate nobody tests
|
||||
* passes forever once it breaks, so this runs the real script as a child
|
||||
* process against throwaway server roots (--dir) and proves both that it
|
||||
* fails on a violation and that it refuses to run on a broken setup instead
|
||||
* of passing silently.
|
||||
*/
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
const SCRIPT = path.join(__dirname, '../../../scripts/size-lint.mjs');
|
||||
|
||||
interface ExecError {
|
||||
status: number | null;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
}
|
||||
|
||||
const roots: string[] = [];
|
||||
|
||||
function serverRoot(files: Record<string, string>, baseline: unknown = {}): string {
|
||||
// null writes no baseline file at all.
|
||||
const dir = mkdtempSync(path.join(tmpdir(), 'trek-size-lint-'));
|
||||
roots.push(dir);
|
||||
mkdirSync(path.join(dir, 'src'), { recursive: true });
|
||||
mkdirSync(path.join(dir, 'scripts'), { recursive: true });
|
||||
for (const [file, text] of Object.entries(files)) {
|
||||
mkdirSync(path.dirname(path.join(dir, file)), { recursive: true });
|
||||
writeFileSync(path.join(dir, file), text);
|
||||
}
|
||||
if (baseline !== null) {
|
||||
writeFileSync(
|
||||
path.join(dir, 'scripts/size-baseline.json'),
|
||||
typeof baseline === 'string' ? baseline : JSON.stringify(baseline),
|
||||
);
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
|
||||
function run(dir: string, ...args: string[]): { status: number | null; out: string } {
|
||||
try {
|
||||
const stdout = execFileSync(process.execPath, [SCRIPT, `--dir=${dir}`, ...args], {
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
return { status: 0, out: stdout };
|
||||
} catch (err) {
|
||||
const { status, stdout, stderr } = err as ExecError;
|
||||
return { status, out: `${stdout}${stderr}` };
|
||||
}
|
||||
}
|
||||
|
||||
const lines = (n: number, width = 10) => `${'x'.repeat(width)}\n`.repeat(n);
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of roots.splice(0)) rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('size-lint.mjs', () => {
|
||||
it('SIZE-001: passes when every file is at or under the limit', () => {
|
||||
const dir = serverRoot({ 'src/a.ts': lines(1000), 'scripts/b.mjs': lines(10) });
|
||||
const { status, out } = run(dir);
|
||||
expect(status).toBe(0);
|
||||
expect(out).toContain('size: 2 file(s), 0 over 1000 lines');
|
||||
});
|
||||
|
||||
it('SIZE-002: fails on a new file past the limit', () => {
|
||||
const dir = serverRoot({ 'src/nest/big/big.service.ts': lines(1001) });
|
||||
const { status, out } = run(dir);
|
||||
expect(status).toBe(1);
|
||||
expect(out).toContain('FAIL src/nest/big/big.service.ts: 1001 lines, the limit is 1000.');
|
||||
});
|
||||
|
||||
it('SIZE-003: fails when a baselined file grows past its entry, passes at or under it', () => {
|
||||
expect(run(serverRoot({ 'src/a.ts': lines(1201) }, { 'src/a.ts': 1200 })).status).toBe(1);
|
||||
expect(run(serverRoot({ 'src/a.ts': lines(1200) }, { 'src/a.ts': 1200 })).status).toBe(0);
|
||||
expect(run(serverRoot({ 'src/a.ts': lines(1100) }, { 'src/a.ts': 1200 })).out).toContain('run with --update');
|
||||
});
|
||||
|
||||
it('SIZE-004: counts a long line once per 120 columns, so joining lines buys nothing', () => {
|
||||
// 400 lines of 300 columns read like 1200 lines.
|
||||
const { status, out } = run(serverRoot({ 'src/dense.ts': lines(400, 300) }));
|
||||
expect(status).toBe(1);
|
||||
expect(out).toContain('src/dense.ts: 1200 lines');
|
||||
// Exactly 120 columns is still one line.
|
||||
expect(run(serverRoot({ 'src/edge.ts': lines(1000, 120) })).status).toBe(0);
|
||||
});
|
||||
|
||||
it('SIZE-005: a CRLF checkout counts the same as an LF one', () => {
|
||||
const crlf = lines(1001).replace(/\n/g, '\r\n');
|
||||
const { out } = run(serverRoot({ 'src/a.ts': crlf }));
|
||||
expect(out).toContain('src/a.ts: 1001 lines');
|
||||
});
|
||||
|
||||
it('SIZE-006: skips tests and non-source files, walks scripts/ as well as src/', () => {
|
||||
const dir = serverRoot({
|
||||
'src/a.test.ts': lines(2000),
|
||||
'src/notes.md': lines(2000),
|
||||
'scripts/tool.mjs': lines(1001),
|
||||
});
|
||||
const { status, out } = run(dir);
|
||||
expect(status).toBe(1);
|
||||
expect(out).toContain('scripts/tool.mjs');
|
||||
expect(out).not.toContain('a.test.ts');
|
||||
});
|
||||
|
||||
it('SIZE-007: fails closed on a missing, unparsable or malformed baseline', () => {
|
||||
const missing = serverRoot({ 'src/a.ts': lines(1) }, null);
|
||||
expect(run(missing).status).toBe(1);
|
||||
expect(run(missing).out).toContain('size-baseline.json cannot be read');
|
||||
|
||||
expect(run(serverRoot({ 'src/a.ts': lines(1) }, '{ not json')).status).toBe(1);
|
||||
expect(run(serverRoot({ 'src/a.ts': lines(1) }, '[]')).status).toBe(1);
|
||||
// An entry at or under the limit, or not a whole number, is a hand edit gone wrong.
|
||||
expect(run(serverRoot({ 'src/a.ts': lines(1) }, { 'src/a.ts': 900 })).status).toBe(1);
|
||||
expect(run(serverRoot({ 'src/a.ts': lines(1) }, { 'src/a.ts': '1200' })).status).toBe(1);
|
||||
});
|
||||
|
||||
it('SIZE-008: fails closed when a scanned root is missing', () => {
|
||||
const dir = serverRoot({ 'src/a.ts': lines(1) });
|
||||
rmSync(path.join(dir, 'src'), { recursive: true });
|
||||
const { status, out } = run(dir);
|
||||
expect(status).toBe(1);
|
||||
expect(out).toContain('src/ does not exist');
|
||||
});
|
||||
|
||||
it('SIZE-009: --update only lowers, drops files back under the limit, and never adds one', () => {
|
||||
const dir = serverRoot(
|
||||
{ 'src/shrunk.ts': lines(1100), 'src/fixed.ts': lines(50), 'src/grown.ts': lines(1500), 'src/new.ts': lines(1300) },
|
||||
{ 'src/shrunk.ts': 1200, 'src/fixed.ts': 1300, 'src/grown.ts': 1400, 'src/gone.ts': 1100 },
|
||||
);
|
||||
const { status } = run(dir, '--update');
|
||||
const baseline: unknown = JSON.parse(readFileSync(path.join(dir, 'scripts/size-baseline.json'), 'utf8'));
|
||||
expect(baseline).toEqual({ 'src/grown.ts': 1400, 'src/shrunk.ts': 1100 });
|
||||
// The grown file and the new one still fail after the update.
|
||||
expect(status).toBe(1);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user