ci(client): fail the count ratchets on an entry above the tree

lint:size, lint:layers, lint:rtl (both baselines), lint:skips and
lint:warnings only failed a count that passed its entry. A count that
fell below its entry was logged and nothing more, so a file that shrank
could grow back to its old entry unseen, and the entry of a deleted
file stayed behind for the next file at that path. lint:format and
lint:offline already fail on such a stale entry, and the docs said
these counts may only shrink.

Each of them now fails on an entry that lowerCounts would lower or
drop, naming its count or saying the file is gone, and tells to run
--update, which still only lowers. staleCounts and reportStale in
lib/ratchet.mjs hold the shared part. Every baseline matches the tree
today, so none needed lowering. The client CLAUDE.md says so, and that
CI lints through lint:warnings rather than lint:check.
This commit is contained in:
Maurice
2026-10-08 13:56:19 +02:00
parent 4344dbe923
commit bd267c67f8
18 changed files with 210 additions and 37 deletions
+3 -1
View File
@@ -8,7 +8,9 @@
* of the kind that has already shown stale money. This runs ESLint over the
* client and counts the warnings per rule, separately for app code and
* tests, and the messages an eslint-disable comment silenced as a third
* group. Each count may not pass its entry in scripts/eslint-baseline.json.
* group. Each count may not pass its entry in scripts/eslint-baseline.json,
* and an entry above its count fails as well until --update lowers it, so a
* fixed warning cannot come back unseen.
* Any error fails as well, so the check can stand in for `npm run lint:check`,
* and so does a block comment that configures a rule inline (such as one that
* turns no-explicit-any off): it leaves no message to count, so it would
+2 -1
View File
@@ -10,7 +10,8 @@
* all. The imports that already broke a rule are counted per file in
* scripts/layers-baseline.json, and a file may hold no more of them than its
* entry (a file without an entry holds none). Type-only imports count: a type
* the lower layer needs belongs below it.
* the lower layer needs belongs below it. An entry above what its file holds
* now, or for a file that is gone, fails as well until --update lowers it.
*
* npm run lint:layers check against the baseline (CI)
* npm run lint:layers -- --list print every import against the layering
+21 -5
View File
@@ -4,7 +4,17 @@
import { ESLint } from 'eslint';
import { join } from 'node:path';
import ts from 'typescript';
import { countMap, lowerCounts, readBaseline, readText, TEST_FILE, toKey, writeBaseline } from './ratchet.mjs';
import {
countMap,
lowerCounts,
readBaseline,
readText,
reportStale,
staleCounts,
TEST_FILE,
toKey,
writeBaseline,
} from './ratchet.mjs';
/**
* Where a warning is counted: in the app code, in the tests, or, for a
@@ -151,7 +161,7 @@ export async function check({
for (const line of errors) error(`FAIL ${line}`);
let grown = 0;
let lowerable = 0;
let stale = 0;
for (const area of AREAS) {
for (const [rule, n] of Object.entries(counts[area])) {
const allowed = baseline[area][rule] ?? 0;
@@ -163,7 +173,14 @@ export async function check({
.map(([key, k]) => `${key} (${k})`);
error(`FAIL ${area}: ${n} ${rule} warning(s), baseline ${allowed}. Most in: ${top.join(', ')}`);
}
for (const [rule, allowed] of Object.entries(baseline[area])) if ((counts[area][rule] ?? 0) < allowed) lowerable++;
const fallen = staleCounts(baseline[area], counts[area]);
reportStale(fallen, {
file: 'eslint-baseline.json',
command: 'lint:warnings',
label: (rule) => `${area}: ${rule}`,
error,
});
stale += fallen.length;
}
if (grown) {
error(
@@ -171,9 +188,8 @@ export async function check({
'Run npx eslint <file> to see each one.'
);
}
if (lowerable && !update) log(`${lowerable} rule count(s) fell below the baseline: run with --update to lower it.`);
log(
`eslint: ${errors.length} error(s); warnings ${AREAS.map((area) => `${area} ${sum(counts[area])}/${sum(baseline[area])}`).join(', ')} (now/baseline)`
);
return errors.length || grown ? 1 : 0;
return errors.length || grown || stale ? 1 : 0;
}
+4 -6
View File
@@ -3,7 +3,7 @@
*/
import { join } from 'node:path';
import { sourceGraph } from './imports.mjs';
import { countMap, lowerCounts, readBaseline, writeBaseline } from './ratchet.mjs';
import { countMap, lowerCounts, readBaseline, reportStale, staleCounts, writeBaseline } from './ratchet.mjs';
const VIEW = ['components', 'mobile', 'pages', 'hooks'];
@@ -82,7 +82,7 @@ export function check({
}
const grown = Object.entries(counts).filter(([key, n]) => n > (baseline[key] ?? 0));
const lowerable = Object.entries(baseline).filter(([key, n]) => (counts[key] ?? 0) < n);
const stale = staleCounts(baseline, counts);
for (const [key, n] of grown) {
error(`FAIL ${key}: ${n} import(s) against the layering, baseline ${baseline[key] ?? 0}:`);
@@ -94,12 +94,10 @@ export function check({
'a hook under hooks/, a type under types/) instead of importing upwards.'
);
}
if (lowerable.length && !update) {
log(`${lowerable.length} file(s) hold fewer of these imports than the baseline: run with --update to lower it.`);
}
reportStale(stale, { file: 'layers-baseline.json', command: 'lint:layers', root, error });
const sum = (map) => Object.values(map).reduce((a, b) => a + b, 0);
log(
`layers: ${sum(counts)} import(s) against the layering in ${Object.keys(counts).length} file(s), baseline allows ${sum(baseline)}`
);
return grown.length ? 1 : 0;
return grown.length || stale.length ? 1 : 0;
}
+44
View File
@@ -126,6 +126,50 @@ export function lowerCounts(baseline, counts, floor = 0) {
return lowered;
}
/**
* The entries of a count baseline that allow more than is measured now: a
* count that fell below its entry, one back at floor or under it, or a file
* that is gone. Exactly the entries lowerCounts would lower or drop. Each one
* fails its check until --update lowers it, or the count could grow back to
* the old entry unseen, and a deleted file would hand its allowance to the
* next file at its path.
*
* @param {Record<string, number>} baseline
* @param {Record<string, number>} counts
* @param {number | ((key: string) => number)} [floor]
* @returns {{ key: string, entry: number, now: number }[]}
*/
export function staleCounts(baseline, counts, floor = 0) {
const lowered = lowerCounts(baseline, counts, floor);
return Object.entries(baseline)
.filter(([key, entry]) => lowered[key] !== entry)
.map(([key, entry]) => ({ key, entry, now: counts[key] ?? 0 }));
}
/**
* Prints a FAIL line for each stale entry (see staleCounts) of the baseline
* scripts/<file>, then how to lower them. With root, an entry whose file is
* not there is called gone; label names an entry that is not a file path.
*
* @param {{ key: string, entry: number, now: number }[]} stale
* @param {{ file: string, command: string, root?: string, label?: (key: string) => string, error: (line: string) => void }} options
*/
export function reportStale(stale, { file, command, root, label = (key) => key, error }) {
for (const { key, entry, now } of stale) {
const gone = root !== undefined && !existsSync(join(root, key));
error(
`FAIL ${label(key)} is held at ${entry} in scripts/${file}, ` +
(gone ? 'but the file is gone.' : `but there are ${now} now.`)
);
}
if (stale.length) {
error(
`Run npm run ${command} -- --update to lower the baseline with the change that made it smaller: ` +
'an entry above the count lets it grow back unseen.'
);
}
}
/** The baseline entries that are still offenders: a list only ever loses files. */
export function lowerList(baseline, offenders) {
const now = new Set(offenders);
+7 -7
View File
@@ -9,6 +9,8 @@ import {
lowerCounts,
readBaseline,
readText,
reportStale,
staleCounts,
TEST_FILE,
toKey,
writeBaseline,
@@ -163,9 +165,8 @@ export function check({
const grown = Object.entries(counts).filter(([file, n]) => n > (baseline[file] ?? 0));
const marked = Object.entries(markers).filter(([file, n]) => n > (allowedMarkers[file] ?? 0));
const lowerable =
Object.entries(baseline).filter(([file, n]) => (counts[file] ?? 0) < n).length +
Object.entries(allowedMarkers).filter(([file, n]) => (markers[file] ?? 0) < n).length;
const stale = staleCounts(baseline, counts);
const staleMarkers = staleCounts(allowedMarkers, markers);
for (const [file, n] of grown) {
error(
@@ -182,12 +183,11 @@ export function check({
'(a map, a measured position, a time axis) needs a reviewer to raise the entry by hand.'
);
}
if (lowerable && !update) {
log(`${lowerable} entr(ies) are above what the files hold now: run with --update to lower them.`);
}
reportStale(stale, { file: 'rtl-baseline.json', command: 'lint:rtl', root: join(root, 'src'), error });
reportStale(staleMarkers, { file: 'rtl-disable-baseline.json', command: 'lint:rtl', root: join(root, 'src'), error });
log(
`rtl: ${sum(counts)} physical side(s) in ${Object.keys(counts).length} file(s), baseline allows ${sum(baseline)}; ` +
`${sum(markers)} line(s) marked ${DISABLE}, baseline allows ${sum(allowedMarkers)}`
);
return grown.length || marked.length ? 1 : 0;
return grown.length || marked.length || stale.length || staleMarkers.length ? 1 : 0;
}
+8 -6
View File
@@ -14,6 +14,8 @@ import {
lowerCounts,
readBaseline,
readText,
reportStale,
staleCounts,
TEST_FILE,
toKey,
writeBaseline,
@@ -68,7 +70,9 @@ const limitOf = (key) => groupOf(key)?.limit ?? 0;
/**
* Runs the check against the baseline at baselinePath. With update, the
* baseline is lowered first. Returns the exit code.
* baseline is lowered first. An entry above the file's size now, or for a
* file that is gone, fails as well until --update lowers it. Returns the
* exit code.
*/
export function check({
root,
@@ -86,7 +90,7 @@ export function check({
}
const grown = Object.entries(sizes).filter(([key, n]) => n > Math.max(baseline[key] ?? 0, limitOf(key)));
const lowerable = Object.entries(baseline).filter(([key, n]) => (sizes[key] ?? 0) < n);
const stale = staleCounts(baseline, sizes, limitOf);
for (const [key, n] of grown) {
const entry = baseline[key];
@@ -96,11 +100,9 @@ export function check({
'Move a concern into a module of its own instead of growing the file or packing its lines.'
);
}
if (lowerable.length && !update) {
log(`${lowerable.length} file(s) are smaller than their baseline now: run with --update to lower it.`);
}
reportStale(stale, { file: 'size-baseline.json', command: 'lint:size', root, error });
log(
`size: ${Object.keys(sizes).length} file(s), ${Object.keys(baseline).length} past their limit held at their baseline`
);
return grown.length ? 1 : 0;
return grown.length || stale.length ? 1 : 0;
}
+5 -4
View File
@@ -9,6 +9,8 @@ import {
lowerCounts,
readBaseline,
readText,
reportStale,
staleCounts,
TEST_FILE,
toKey,
writeBaseline,
@@ -139,12 +141,11 @@ export function check({
if (grown.length) {
error('Fix the test, delete it if its feature is gone, or use skipIf/runIf when it depends on the environment.');
}
const lowerable = Object.entries(baseline).filter(([key, n]) => (counts[key] ?? 0) < n);
if (lowerable.length && !update)
log(`${lowerable.length} file(s) skip fewer tests than the baseline: run with --update to lower it.`);
const stale = staleCounts(baseline, counts);
reportStale(stale, { file: 'skip-baseline.json', command: 'lint:skips', root, error });
const sum = (map) => Object.values(map).reduce((a, b) => a + b, 0);
log(
`skips: ${sum(counts)} skipped or todo test(s), baseline allows ${sum(baseline)}; ${Object.keys(only).length} file(s) with .only`
);
return grown.length || Object.keys(only).length ? 1 : 0;
return grown.length || stale.length || Object.keys(only).length ? 1 : 0;
}
+3 -1
View File
@@ -17,7 +17,9 @@
* carries `rtl-lint-disable` in a comment (in a string it is no marker). The
* marked lines are counted too, against scripts/rtl-disable-baseline.json, so
* the marker cannot quietly take the place of the logical forms: a new one
* raises its file's entry by hand, where review sees it.
* raises its file's entry by hand, where review sees it. An entry of either
* baseline above what its file holds now, or for a file that is gone, fails
* as well until --update lowers it.
*
* npm run lint:rtl check against the baselines (CI)
* npm run lint:rtl -- --list print every counted use, file by file
+4 -1
View File
@@ -10,7 +10,10 @@
* that were already larger are listed in scripts/size-baseline.json with the
* size 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).
* page and road trip sidebar are the precedent). An entry above the file's
* size now, or for a file that is gone, fails as well until --update lowers
* it: otherwise the file could grow back unseen, and a new file at a deleted
* path would inherit its allowance.
*
* npm run lint:size check against the baseline (CI)
* npm run lint:size -- --update lower the baseline to what the files hold now;
+2
View File
@@ -7,6 +7,8 @@
* switched off and then forgotten (JourneyDetailPage.test.tsx has carried 35
* skipped blocks since April); the files that hold some are listed in
* scripts/skip-baseline.json with their count, and a file may hold no more.
* An entry above what its file holds now, or for a file that is gone, fails
* as well until --update lowers it.
* Playwright's test.fixme counts as a skip. Only a skip that decides at run
* time is left out: skipIf and runIf, and in a Playwright spec
* test.skip(condition, 'why'), unless the condition is a literal that always