ci(client): source files may not grow past their size baseline

lint:size counts the lines of every source file under client/src, tests
aside. A file may hold 1000 lines; the 24 files that are longer today are
listed in scripts/size-baseline.json with their current length and fail
the check when they grow. --update lowers an entry once a file shrinks
and never raises one, so a long file is split by concern instead of
growing. A baseline that cannot be read or parsed stops the check with
its reason, and --update leaves it as it is. It runs in the client job
next to the reading direction check.
This commit is contained in:
Maurice
2026-10-08 00:45:31 +00:00
parent bee500880e
commit 5189d7fcca
6 changed files with 137 additions and 1 deletions
+6
View File
@@ -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
+2 -1
View File
@@ -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:
+1
View File
@@ -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)
+1
View File
@@ -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",
+26
View File
@@ -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
}
+101
View File
@@ -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)
}