From e5a5d167af4cfb491162c8c68ec13910f6735ed4 Mon Sep 17 00:00:00 2001 From: Maurice Date: Thu, 8 Oct 2026 11:17:15 +0200 Subject: [PATCH] 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. --- server/CLAUDE.md | 3 + server/package.json | 1 + server/scripts/size-baseline.json | 25 +++ server/scripts/size-lint.mjs | 162 ++++++++++++++++++++ server/tests/unit/scripts/size-lint.test.ts | 142 +++++++++++++++++ 5 files changed, 333 insertions(+) create mode 100644 server/scripts/size-baseline.json create mode 100644 server/scripts/size-lint.mjs create mode 100644 server/tests/unit/scripts/size-lint.test.ts diff --git a/server/CLAUDE.md b/server/CLAUDE.md index 3389809ef..a12f0da72 100644 --- a/server/CLAUDE.md +++ b/server/CLAUDE.md @@ -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`. diff --git a/server/package.json b/server/package.json index d867de8b4..d7de13512 100644 --- a/server/package.json +++ b/server/package.json @@ -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", diff --git a/server/scripts/size-baseline.json b/server/scripts/size-baseline.json new file mode 100644 index 000000000..d652438fe --- /dev/null +++ b/server/scripts/size-baseline.json @@ -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 +} diff --git a/server/scripts/size-lint.mjs b/server/scripts/size-lint.mjs new file mode 100644 index 000000000..badf61941 --- /dev/null +++ b/server/scripts/size-lint.mjs @@ -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= 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; + } +} diff --git a/server/tests/unit/scripts/size-lint.test.ts b/server/tests/unit/scripts/size-lint.test.ts new file mode 100644 index 000000000..ba12eed55 --- /dev/null +++ b/server/tests/unit/scripts/size-lint.test.ts @@ -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, 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); + }); +});