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
@@ -67,12 +67,23 @@ describe('lint:warnings', () => {
expect(errors).toEqual([]);
});
it('ESLINT-002: passes when no count passes its entry', async () => {
const code = await run({ ...empty, src: { 'no-empty': 2 } }, (root) => [
it('ESLINT-002: passes when every count sits at its entry', async () => {
const code = await run({ ...empty, src: { 'no-empty': 1 } }, (root) => [
result(root, 'src/a.ts', [warn('no-empty')]),
]);
expect(code).toBe(0);
expect(tree.log.join('\n')).toMatch(/fell below the baseline/);
});
it('ESLINT-010: fails a count that fell below its entry, or a rule with none left, until --update', async () => {
const baseline = { ...empty, src: { 'no-empty': 2 }, suppressed: { 'react-hooks/exhaustive-deps': 1 } };
const results = (root: string) => [result(root, 'src/a.ts', [warn('no-empty')])];
expect(await run(baseline, results)).toBe(1);
const out = tree.error.join('\n');
expect(out).toMatch(/src: no-empty is held at 2 in scripts\/eslint-baseline\.json, but there are 1 now/);
expect(out).toMatch(/suppressed: react-hooks\/exhaustive-deps is held at 1 .*but there are 0 now/);
expect(out).toMatch(/npm run lint:warnings -- --update/);
tree.remove();
expect(await run(baseline, results, true)).toBe(0);
});
it('ESLINT-003: fails a new warning and names the files behind it', async () => {
@@ -120,6 +120,17 @@ describe('lint:layers', () => {
expect(JSON.parse(readFileSync(tree.path(BASELINE), 'utf8'))).toEqual({ 'src/components/A.tsx': 1 });
});
it('LAYERS-008: fails an entry above the imports a file holds, or for a file that is gone, until --update', () => {
const files = { 'src/components/A.tsx': "import { m } from '../pages/x/model'\n" };
expect(run({ 'src/components/A.tsx': 2, 'src/components/Gone.tsx': 1 }, files)).toBe(1);
const out = tree.error.join('\n');
expect(out).toMatch(/src\/components\/A\.tsx is held at 2 in scripts\/layers-baseline\.json, but there are 1 now/);
expect(out).toMatch(/src\/components\/Gone\.tsx is held at 1 .*but the file is gone/);
expect(out).toMatch(/npm run lint:layers -- --update/);
tree.remove();
expect(run({ 'src/components/A.tsx': 2, 'src/components/Gone.tsx': 1 }, files, true)).toBe(0);
});
it('LAYERS-007: help/ and vacay/ are helpers and import no view', () => {
for (const helper of ['help', 'vacay']) {
expect(RULES[helper as keyof typeof RULES]).toEqual(
+32
View File
@@ -11,6 +11,8 @@ import {
RatchetError,
readBaseline,
readText,
reportStale,
staleCounts,
writeBaseline,
} from '../../../scripts/lib/ratchet.mjs';
import { ratchetTree, type RatchetTree } from '../../helpers/ratchetFixture';
@@ -79,6 +81,36 @@ describe('scripts/lib/ratchet', () => {
expect(lowerCounts({ a: 9 }, { a: 4 }, (key: string) => (key === 'a' ? 4 : 0))).toEqual({});
});
it('RATCHET-010: an entry above the count, at the floor or for a gone file is stale, one at the count is not', () => {
expect(
staleCounts({ held: 5, shrank: 5, gone: 5, floor: 5 }, { held: 5, shrank: 3, floor: 2, fresh: 9 }, 2)
).toEqual([
{ key: 'shrank', entry: 5, now: 3 },
{ key: 'gone', entry: 5, now: 0 },
{ key: 'floor', entry: 5, now: 2 },
]);
expect(staleCounts({ a: 3 }, { a: 4 })).toEqual([]);
});
it('RATCHET-011: a stale entry is reported with its count, or as gone, and how to lower it', () => {
tree = ratchetTree({ 'src/here.ts': 'x\n' });
const lines: string[] = [];
const error = (line: string) => lines.push(line);
const stale = [
{ key: 'src/here.ts', entry: 4, now: 2 },
{ key: 'src/gone.ts', entry: 3, now: 0 },
];
reportStale(stale, { file: 'b.json', command: 'lint:x', root: tree.root, error });
expect(lines).toEqual([
'FAIL src/here.ts is held at 4 in scripts/b.json, but there are 2 now.',
'FAIL src/gone.ts is held at 3 in scripts/b.json, but the file is gone.',
expect.stringMatching(/^Run npm run lint:x -- --update to lower the baseline/),
]);
lines.length = 0;
reportStale([], { file: 'b.json', command: 'lint:x', error });
expect(lines).toEqual([]);
});
it('RATCHET-008: a file list only loses the files that stopped offending', () => {
expect(lowerList(['a', 'b', 'c'], ['c', 'a', 'new'])).toEqual(['a', 'c']);
});
@@ -76,6 +76,24 @@ describe('lint:rtl', () => {
expect(JSON.parse(readFileSync(tree.path(MARKERS), 'utf8'))).toEqual({ 'a.tsx': 1 });
});
it('RTL-009: fails an entry of either baseline above the file, or for a file that is gone, until --update', () => {
const files = { 'src/a.tsx': 'const a = <div className="ml-2" />\nconst s = { left: 0 } // rtl-lint-disable\n' };
expect(run({ 'a.tsx': 2 }, { 'a.tsx': 1 }, files)).toBe(1);
expect(tree.error.join('\n')).toMatch(/a\.tsx is held at 2 in scripts\/rtl-baseline\.json, but there are 1 now/);
tree.remove();
expect(run({ 'a.tsx': 1 }, { 'a.tsx': 2 }, files)).toBe(1);
expect(tree.error.join('\n')).toMatch(
/a\.tsx is held at 2 in scripts\/rtl-disable-baseline\.json, but there are 1 now/
);
tree.remove();
expect(run({ 'a.tsx': 1, 'gone.tsx': 3 }, { 'a.tsx': 1, 'gone.tsx': 1 }, files)).toBe(1);
const out = tree.error.join('\n');
expect(out).toMatch(/gone\.tsx is held at 3 in scripts\/rtl-baseline\.json, but the file is gone/);
expect(out).toMatch(/gone\.tsx is held at 1 in scripts\/rtl-disable-baseline\.json, but the file is gone/);
tree.remove();
expect(run({ 'a.tsx': 2, 'gone.tsx': 3 }, { 'a.tsx': 2, 'gone.tsx': 1 }, files, true)).toBe(0);
});
it('RTL-007: a missing or broken baseline stops the check instead of reading as empty', () => {
tree = ratchetTree({ [USES]: '{}', 'src/a.tsx': 'const a = 1\n' });
expect(() =>
@@ -94,6 +94,23 @@ describe('lint:size', () => {
expect(tree.error.join('\n')).toMatch(/src\/grew\.ts: 1150 lines/);
});
it('SIZE-011: fails an entry above the file it holds, or for a file that is gone, until --update', () => {
expect(run({ 'src/big.ts': 1200 }, { 'src/big.ts': lines(1100) })).toBe(1);
expect(tree.error.join('\n')).toMatch(
/src\/big\.ts is held at 1200 in scripts\/size-baseline\.json, but there are 1100 now/
);
expect(tree.error.join('\n')).toMatch(/npm run lint:size -- --update/);
tree.remove();
// A deleted file would hand its allowance to the next file at its path.
expect(run({ 'src/gone.ts': 1200 }, { 'src/a.ts': lines(1) })).toBe(1);
expect(tree.error.join('\n')).toMatch(/src\/gone\.ts is held at 1200 .*but the file is gone/);
tree.remove();
// Back under the limit is stale too: the entry would let it grow past the limit again.
expect(run({ 'src/big.ts': 1200 }, { 'src/big.ts': lines(900) })).toBe(1);
tree.remove();
expect(run({ 'src/big.ts': 1200, 'src/gone.ts': 1200 }, { 'src/big.ts': lines(1100) }, true)).toBe(0);
});
it('SIZE-009: a broken baseline stops the check', () => {
tree = ratchetTree({ [BASELINE]: '{', 'src/a.ts': lines(1), 'tests/a.ts': lines(1), 'e2e/a.ts': lines(1) });
expect(() => check({ root: tree.root, baselinePath: tree.path(BASELINE), ...tree.out })).toThrow(RatchetError);
@@ -93,6 +93,17 @@ describe('lint:skips', () => {
expect(JSON.parse(readFileSync(tree.path(BASELINE), 'utf8'))).toEqual({ 'src/a.test.ts': 2 });
});
it('SKIPS-010: fails an entry above the skips a file holds, or for a file that is gone, until --update', () => {
const files = { 'src/a.test.ts': "it.skip('a', () => {})\n" };
expect(run({ 'src/a.test.ts': 2, 'tests/gone.test.ts': 1 }, files)).toBe(1);
const out = tree.error.join('\n');
expect(out).toMatch(/src\/a\.test\.ts is held at 2 in scripts\/skip-baseline\.json, but there are 1 now/);
expect(out).toMatch(/tests\/gone\.test\.ts is held at 1 .*but the file is gone/);
expect(out).toMatch(/npm run lint:skips -- --update/);
tree.remove();
expect(run({ 'src/a.test.ts': 2, 'tests/gone.test.ts': 1 }, files, true)).toBe(0);
});
it('SKIPS-009: counts every vitest skip, whatever its title is', () => {
const source = [
"const T = 'a'",