diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b13a71812..ebeca5eb7 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -241,6 +241,12 @@ jobs: # so new code uses the logical start/end forms (scripts/rtl-lint.mjs). run: cd client && npm run lint:rtl + - name: File size check + # A source file may hold no more lines than scripts/size-baseline.json + # allows (1000 for a file without an entry), so a big file is split by + # concern instead of growing (scripts/size-lint.mjs). + run: cd client && npm run lint:size + plugin-sdk-quality: name: Plugin SDK Types runs-on: ubuntu-latest diff --git a/CLAUDE.md b/CLAUDE.md index 12d937b81..19dfa9791 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,6 +35,7 @@ cd client && npm run test # client vitest run cd client && npm run e2e # Playwright (CI runs the public and app projects; the screenshot and help-media ones are local) cd client && npm run lint:pages # enforce the Page pattern (CI gate) cd client && npm run lint:rtl # physical left/right sides may only shrink per file (CI gate) +cd client && npm run lint:size # no source file past 1000 lines, longer ones only shrink (CI gate) cd client && npm run theme:lint # flag styling that bypasses appearance tokens (local only) npm run typecheck --workspace=server # tsc --noEmit (also client/shared; server also has typecheck:tests) ``` @@ -109,7 +110,7 @@ These principles come out of a full-repo audit and shape all new code: ## Quality gates in CI -`.github/workflows/test.yml` runs, per package: typecheck (server also `typecheck:tests`), `lint:check`, `lint:pages`, `lint:rtl`, `check:plugin-facts`, `i18n:parity:strict`, the S3 contract suite, the browser end-to-end flows (Chromium and WebKit), a client build with `check:gl-split`, coverage for all four packages, then a SonarCloud scan. `lint-prettier.yml` additionally runs `lint` + `format:check` on `shared/`. `plugin-sdk` has no lint script and is outside the eslint gates. **Not in CI**: `theme:lint` and the screenshot and help-media Playwright projects; run them locally. There are no git hooks; nothing runs before a commit except you. Other workflows build Docker images, scan them, and publish `trek-plugin-sdk` on `plugin-sdk-v*` tags. +`.github/workflows/test.yml` runs, per package: typecheck (server also `typecheck:tests`), `lint:check`, `lint:pages`, `lint:rtl`, `lint:size`, `check:plugin-facts`, `i18n:parity:strict`, the S3 contract suite, the browser end-to-end flows (Chromium and WebKit), a client build with `check:gl-split`, coverage for all four packages, then a SonarCloud scan. `lint-prettier.yml` additionally runs `lint` + `format:check` on `shared/`. `plugin-sdk` has no lint script and is outside the eslint gates. **Not in CI**: `theme:lint` and the screenshot and help-media Playwright projects; run them locally. There are no git hooks; nothing runs before a commit except you. Other workflows build Docker images, scan them, and publish `trek-plugin-sdk` on `plugin-sdk-v*` tags. **SonarCloud** (project `liketrek_TREK`, the built-in Sonar way gate; the run takes 20–25 min, so get it right before pushing) measures **new code only** on a PR; any failing condition blocks it: diff --git a/client/CLAUDE.md b/client/CLAUDE.md index 772a7da7e..abbee086d 100644 --- a/client/CLAUDE.md +++ b/client/CLAUDE.md @@ -11,6 +11,7 @@ npm run typecheck # tsc --noEmit (CI) npm run lint # eslint . (lint:check in CI) npm run lint:pages # enforce the Page pattern (CI gate) npm run lint:rtl # physical left/right sides may only shrink per file (CI gate; --list shows them, --update lowers the baseline) +npm run lint:size # no source file past 1000 lines; longer ones may only shrink (CI gate; --update lowers the baseline) npm run test # vitest run (tests/** + co-located src/**/*.test.{ts,tsx}); also test:unit / test:integration / test:coverage npm run e2e # Playwright (CI runs --project=public --project=app; e2e:report opens the last report) npm run shots # Playwright screenshot project (shots:promote to accept) diff --git a/client/package.json b/client/package.json index 9da0bacbe..3435b2d11 100644 --- a/client/package.json +++ b/client/package.json @@ -19,6 +19,7 @@ "lint:check": "eslint .", "lint:pages": "node scripts/check-page-pattern.mjs", "lint:rtl": "node scripts/rtl-lint.mjs", + "lint:size": "node scripts/size-lint.mjs", "check:gl-split": "node scripts/check-gl-split.mjs", "theme:lint": "node scripts/theme-lint.mjs", "theme:lint:strict": "node scripts/theme-lint.mjs --strict", diff --git a/client/scripts/size-baseline.json b/client/scripts/size-baseline.json new file mode 100644 index 000000000..6f7858f4a --- /dev/null +++ b/client/scripts/size-baseline.json @@ -0,0 +1,26 @@ +{ + "api/client.ts": 1685, + "components/Admin/AdminPluginsPanel.tsx": 2274, + "components/Budget/CostsPanel.tsx": 2107, + "components/Map/MapView.tsx": 1482, + "components/Map/MapViewGL.tsx": 2572, + "components/Map/RouteCalculator.ts": 1190, + "components/PDF/TripPDF.tsx": 1025, + "components/Planner/DayPlanSidebar.tsx": 3058, + "components/Planner/PlaceFormModal.tsx": 1633, + "components/Planner/PlaceInspector.tsx": 1273, + "components/Planner/TransportModal.tsx": 1199, + "components/Roadtrip/RoadtripSidebar.tsx": 2287, + "components/Studio/autoLayout.ts": 1676, + "components/Studio/bookTemplates.data.ts": 1279, + "components/Studio/TravelElements.tsx": 1745, + "mobile/screens/admin/MAdminPluginsPanel.tsx": 2105, + "mobile/screens/journey/MJourneyEntrySheet.tsx": 1060, + "mobile/screens/trip/sheets/MTransportFormSheet.tsx": 1127, + "mobile/screens/trip/tabs/MCostsTab.tsx": 1011, + "mobile/screens/trip/tabs/MPackingListTab.tsx": 1017, + "pages/atlas/useAtlas.ts": 1246, + "pages/LoginPage.tsx": 1340, + "pages/tripPlanner/useTripPlanner.ts": 3095, + "pages/TripPlannerPage.tsx": 1427 +} diff --git a/client/scripts/size-lint.mjs b/client/scripts/size-lint.mjs new file mode 100644 index 000000000..6447ad69c --- /dev/null +++ b/client/scripts/size-lint.mjs @@ -0,0 +1,101 @@ +#!/usr/bin/env node +/* + * lint:size: keeps source files from growing into the next god file. + * + * A file past a thousand lines holds several concerns that no longer fit in + * one reading, and every change to one of them risks the others. Each source + * file under src/ (tests aside) 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 (the trip planner hook, + * page and road trip sidebar are the precedent). + * + * 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 + */ +import { existsSync, readdirSync, readFileSync, realpathSync, statSync, writeFileSync } from 'node:fs' +import { join, relative } from 'node:path' +import { fileURLToPath } from 'node:url' + +const SRC = fileURLToPath(new URL('../src', import.meta.url)) +const BASELINE = fileURLToPath(new URL('./size-baseline.json', import.meta.url)) + +/** The most lines a file without a baseline entry may hold. */ +export const LIMIT = 1000 + +function walk(dir, files = []) { + for (const name of readdirSync(dir)) { + const path = join(dir, name) + if (statSync(path).isDirectory()) walk(path, files) + else if (/\.tsx?$/.test(name) && !/\.(?:test|spec)\./.test(name)) files.push(path) + } + return files +} + +/** Lines as an editor numbers them: a final newline does not start another. */ +export function lineCount(text) { + if (text === '') return 0 + const breaks = text.split('\n').length - 1 + return text.endsWith('\n') ? breaks : breaks + 1 +} + +function scan() { + const counts = {} + for (const path of walk(SRC)) counts[relative(SRC, path).split('\\').join('/')] = lineCount(readFileSync(path, 'utf8')) + return counts +} + +/** + * The baseline as committed. A missing file is an empty baseline. One that cannot be + * read or parsed stops the run with its reason: read as empty, every long file would + * fail with no hint at the cause, and --update would replace it with an empty one. + */ +function readBaseline() { + try { + return JSON.parse(readFileSync(BASELINE, 'utf8')) + } catch (err) { + if (err.code === 'ENOENT') return {} + console.error(`FAIL scripts/size-baseline.json cannot be read: ${err.message}`) + console.error('Restore the file from git before running the check or --update again.') + process.exit(1) + } +} + +// 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) { + const counts = scan() + let baseline = readBaseline() + + if (process.argv.includes('--update')) { + const lowered = {} + for (const [file, allowed] of Object.entries(baseline)) { + const now = counts[file] ?? 0 + if (now > LIMIT) lowered[file] = Math.min(allowed, now) + } + const sorted = Object.fromEntries(Object.entries(lowered).sort(([a], [b]) => a.localeCompare(b))) + writeFileSync(BASELINE, JSON.stringify(sorted, null, 2) + '\n') + baseline = sorted + } + + 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) + + 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}. `) + + 'Move a concern into a hook or component of its own instead of growing the file.', + ) + } + if (lowerable.length && !process.argv.includes('--update')) { + console.log(`${lowerable.length} file(s) are shorter than their baseline now: run with --update to lower it.`) + } + const listed = Object.keys(baseline).length + console.log(`size: ${Object.keys(counts).length} file(s), ${listed} over ${LIMIT} lines held at their baseline`) + if (grown.length) process.exit(1) +}