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
+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)
}