build(scripts): server ratchets share one baseline library at the repository root

scripts/lib/ratchet.mjs carries the tree walk, the CRLF-safe reader and the
baseline handling the client ratchets already use, next to a warning tally and
a format comparison that take ESLint and Prettier from the calling workspace.
server lint:size now runs on it with the same messages and exit codes, and the
libraries have node:test suites that CI runs in the server job.
This commit is contained in:
Maurice
2026-10-10 11:45:27 +02:00
parent 5644e3d77e
commit 889afeae92
12 changed files with 978 additions and 88 deletions
+3 -1
View File
@@ -37,12 +37,14 @@ export const AREAS = {
// The type, lint, test, coverage, bundle and end-to-end jobs. npm workspaces
// hoist every dependency into the root lockfile, so a dependency bump touches
// none of the package directories. The Dockerfile and the entrypoint script
// are pinned by server/tests/unit/docker-entrypoint.test.ts.
// are pinned by server/tests/unit/docker-entrypoint.test.ts. scripts/lib/
// holds the ratchet code the server and shared gates run.
code: [
'server/',
'client/',
'shared/',
'plugin-sdk/',
'scripts/lib/',
'package.json',
'package-lock.json',
'.nvmrc',
+4
View File
@@ -48,6 +48,10 @@ describe('classify', () => {
assert.deepEqual(classify(['plugin-sdk/src/index.ts']), { code: true, image: false, deploy: false });
});
it('runs the tests but not the image for a change to the shared ratchet code', () => {
assert.deepEqual(classify(['scripts/lib/ratchet.mjs']), { code: true, image: false, deploy: false });
});
it('runs the image but not the tests for a .dockerignore change', () => {
assert.deepEqual(classify(['.dockerignore']), { code: false, image: true, deploy: false });
});
+204
View File
@@ -0,0 +1,204 @@
/*
* The tally and the comparison behind a workspace's lint:warnings
* (server/scripts/eslint-ratchet.mjs). The same logic as
* client/scripts/lib/eslint-warnings.mjs, with one difference: ESLint itself
* is handed in by the caller (see lintWith), because each workspace pins its
* own major and has to lint with the one its eslint.config.mjs was written
* for. This file never imports eslint, so it resolves no version of its own.
*/
import { join } from 'node:path';
import ts from 'typescript';
import {
countMap,
lowerCounts,
RatchetError,
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
* message an eslint-disable comment silenced, among the suppressed ones. An
* inline disable must not be the cheap way under the ratchet, and an inline
* rule config, which silences without a trace, fails outright (see tally).
*/
export const AREAS = ['src', 'tests', 'suppressed'];
const areaOf = (key) => (key.startsWith('tests/') || TEST_FILE.test(key) ? 'tests' : 'src');
const ruleOf = (message) =>
message.ruleId ?? (/eslint-disable/.test(message.message) ? 'unused-disable-directive' : '(no rule)');
/** A baseline: each area an object of rule → positive count. */
export function areaCounts(value) {
if (!value || typeof value !== 'object' || Array.isArray(value)) return 'it is not an object of area → rule counts';
for (const area of Object.keys(value))
if (!AREAS.includes(area)) return `"${area}" is not one of ${AREAS.join(', ')}`;
for (const area of AREAS) {
if (!(area in value)) return `the "${area}" area is missing`;
const problem = countMap(value[area]);
if (problem) return `in "${area}", ${problem}`;
}
return null;
}
const scriptKind = (file) =>
file.endsWith('.tsx')
? ts.ScriptKind.TSX
: /\.[cm]?ts$/.test(file)
? ts.ScriptKind.TS
: file.endsWith('.jsx')
? ts.ScriptKind.JSX
: ts.ScriptKind.JS;
/** ESLint's label for a block comment that configures rules, as opposed to eslint-disable and the like. */
const INLINE_CONFIG = /^\s*eslint(?:\s|$)/;
/**
* The block comments in a file that configure ESLint rules, such as
* `/* eslint no-empty: off *\/`, as `line:column comment`. Such a comment
* switches a rule off for the whole file without a single message reaching
* either the warnings or the suppressed ones, so it would walk past the
* ratchet unseen. The comments come from the TypeScript parser, so the same
* text inside a string or a template does not count.
*/
export function inlineConfigsOf(source, file) {
const sf = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true, scriptKind(file));
const seen = new Set();
const found = [];
const collect = (ranges) => {
for (const range of ranges ?? []) {
if (seen.has(range.pos)) continue;
seen.add(range.pos);
if (range.kind !== ts.SyntaxKind.MultiLineCommentTrivia) continue;
const text = source.slice(range.pos, range.end);
if (!INLINE_CONFIG.test(text.slice(2, -2))) continue;
const { line, character } = sf.getLineAndCharacterOfPosition(range.pos);
found.push(`${line + 1}:${character + 1} ${text.replace(/\s+/g, ' ').slice(0, 80)}`);
}
};
const visit = (node) => {
collect(ts.getLeadingCommentRanges(source, node.pos));
collect(ts.getTrailingCommentRanges(source, node.pos));
for (const child of node.getChildren(sf)) visit(child);
};
visit(sf);
return found;
}
/**
* The warnings per area and rule in ESLint's results, the files behind each
* count, and every error (an error, a parse failure included, always fails).
* An inline rule config comment (see inlineConfigsOf) is an error too: the
* place to change a rule is eslint.config.mjs.
*/
export function tally(results, root) {
const counts = Object.fromEntries(AREAS.map((area) => [area, {}]));
const files = Object.fromEntries(AREAS.map((area) => [area, {}]));
const errors = [];
const add = (area, rule, key) => {
counts[area][rule] = (counts[area][rule] ?? 0) + 1;
const where = (files[area][rule] ??= {});
where[key] = (where[key] ?? 0) + 1;
};
for (const result of results) {
const key = toKey(root, result.filePath);
for (const comment of result.inlineConfigs ?? [])
errors.push(`${key}:${comment} configures a rule inline; set it in eslint.config.mjs (inline-config)`);
for (const message of result.messages) {
if (message.fatal || message.severity === 2)
errors.push(`${key}:${message.line ?? 0}:${message.column ?? 0} ${message.message} (${ruleOf(message)})`);
else add(areaOf(key), ruleOf(message), key);
}
for (const message of result.suppressedMessages ?? []) add('suppressed', ruleOf(message), key);
}
return { counts, files, errors };
}
/**
* The part of an ESLint result the tally reads.
*
* @typedef {{ ruleId: string | null, severity: number, message: string, fatal?: boolean, line?: number, column?: number }} LintMessage
* @typedef {{ filePath: string, messages: LintMessage[], suppressedMessages?: LintMessage[], inlineConfigs?: string[] }} LintResult
*/
/**
* A lint function for check(): ESLint (the class the workspace imports) over
* the whole workspace with its own config, as `eslint .` runs it, each result
* with the inline rule configs of its file.
*
* @param {new (options: { cwd: string }) => { lintFiles(patterns: string[]): Promise<LintResult[]> }} ESLint
* @returns {(root: string) => Promise<LintResult[]>}
*/
export function lintWith(ESLint) {
return async (root) => {
const results = await new ESLint({ cwd: root }).lintFiles(['.']);
return results.map((result) => ({
...result,
inlineConfigs: inlineConfigsOf(readText(result.filePath), result.filePath),
}));
};
}
const sum = (map) => Object.values(map).reduce((a, b) => a + b, 0);
/**
* Runs lint (lintWith(ESLint) in a workspace, a stub in a test) and compares
* the warnings with the baseline at baselinePath. Returns the exit code.
*/
export async function check({
root,
baselinePath = join(root, 'scripts/eslint-baseline.json'),
update = false,
lint,
log = console.log,
error = console.error,
}) {
if (typeof lint !== 'function') throw new RatchetError('lint:warnings was started without a lint function');
let baseline = readBaseline(baselinePath, areaCounts);
const { counts, files, errors } = tally(await lint(root), root);
if (update) {
baseline = Object.fromEntries(AREAS.map((area) => [area, lowerCounts(baseline[area], counts[area])]));
writeBaseline(baselinePath, baseline);
}
for (const line of errors) error(`FAIL ${line}`);
let grown = 0;
let stale = 0;
for (const area of AREAS) {
for (const [rule, n] of Object.entries(counts[area])) {
const allowed = baseline[area][rule] ?? 0;
if (n <= allowed) continue;
grown++;
const top = Object.entries(files[area][rule])
.sort(([, a], [, b]) => b - a)
.slice(0, 5)
.map(([key, k]) => `${key} (${k})`);
error(`FAIL ${area}: ${n} ${rule} warning(s), baseline ${allowed}. Most in: ${top.join(', ')}`);
}
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(
'Fix the new warning rather than silencing it: an eslint-disable comment is counted under "suppressed". ' +
'Run npx eslint <file> to see each one.'
);
}
log(
`eslint: ${errors.length} error(s); warnings ${AREAS.map((area) => `${area} ${sum(counts[area])}/${sum(baseline[area])}`).join(', ')} (now/baseline)`
);
return errors.length || grown || stale ? 1 : 0;
}
+161
View File
@@ -0,0 +1,161 @@
import { ESLint } from 'eslint';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { afterEach, describe, it } from 'node:test';
import { areaCounts, check, inlineConfigsOf, lintWith, tally } from './eslint-warnings.mjs';
import { RatchetError } from './ratchet.mjs';
import { ratchetTree } from './tree-fixture.mjs';
let tree;
afterEach(() => tree?.remove());
const BASELINE = 'scripts/eslint-baseline.json';
const empty = { src: {}, tests: {}, suppressed: {} };
const warn = (ruleId, message = 'm') => ({ ruleId, severity: 1, message });
function result(root, key, messages, suppressed = []) {
return {
filePath: join(root, key),
messages: messages.map((m) => ({ message: 'm', ...m })),
suppressedMessages: suppressed.map((m) => ({ message: 'm', ...m })),
};
}
async function run(baseline, results, update = false) {
tree = ratchetTree({ [BASELINE]: JSON.stringify(baseline) });
return check({
root: tree.root,
baselinePath: tree.path(BASELINE),
update,
lint: async (root) => results(root),
...tree.out,
});
}
describe('scripts/lib/eslint-warnings', () => {
it('counts warnings per rule for app code, tests and suppressed messages', () => {
const root = join('/r');
const { counts, files, errors } = tally(
[
result(
root,
'src/a.ts',
[warn('no-empty'), warn('no-empty'), warn(null, 'Unused eslint-disable directive')],
[warn('no-console')],
),
result(root, 'src/a.test.ts', [warn('@typescript-eslint/no-explicit-any')]),
result(root, 'tests/unit/b.ts', [warn('@typescript-eslint/no-explicit-any')]),
result(root, 'src/c.ts', [warn(null, 'odd')]),
],
root,
);
assert.deepEqual(counts, {
src: { 'no-empty': 2, 'unused-disable-directive': 1, '(no rule)': 1 },
tests: { '@typescript-eslint/no-explicit-any': 2 },
suppressed: { 'no-console': 1 },
});
assert.deepEqual(files.src['no-empty'], { 'src/a.ts': 2 });
assert.deepEqual(errors, []);
});
it('passes when every count sits at its entry', async () => {
assert.equal(await run({ ...empty, src: { 'no-empty': 1 } }, (root) => [result(root, 'src/a.ts', [warn('no-empty')])]), 0);
assert.match(tree.log.join('\n'), /eslint: 0 error\(s\); warnings src 1\/1, tests 0\/0, suppressed 0\/0/);
});
it('fails a count that fell below its entry until --update lowers it', async () => {
const baseline = { ...empty, src: { 'no-empty': 2 }, suppressed: { 'no-console': 1 } };
const results = (root) => [result(root, 'src/a.ts', [warn('no-empty')])];
assert.equal(await run(baseline, results), 1);
const out = tree.error.join('\n');
assert.match(out, /src: no-empty is held at 2 in scripts\/eslint-baseline\.json, but there are 1 now/);
assert.match(out, /suppressed: no-console is held at 1 .*but there are 0 now/);
assert.match(out, /npm run lint:warnings -- --update/);
tree.remove();
assert.equal(await run(baseline, results, true), 0);
assert.deepEqual(JSON.parse(readFileSync(tree.path(BASELINE), 'utf8')), {
src: { 'no-empty': 1 },
suppressed: {},
tests: {},
});
});
it('fails a new warning, names the files behind it, and keeps app code off the test headroom', async () => {
const code = await run({ ...empty, src: { 'no-empty': 1 }, tests: { 'no-empty': 5 } }, (root) => [
result(root, 'src/a.ts', [warn('no-empty')]),
result(root, 'src/b.ts', [warn('no-empty')]),
]);
assert.equal(code, 1);
const out = tree.error.join('\n');
assert.match(out, /src: 2 no-empty warning\(s\), baseline 1\. Most in: src\/a\.ts \(1\), src\/b\.ts \(1\)/);
assert.match(out, /Fix the new warning rather than silencing it/);
});
it('counts a message an eslint-disable comment silenced', async () => {
assert.equal(await run(empty, (root) => [result(root, 'src/a.ts', [], [warn('no-console')])]), 1);
assert.match(tree.error.join('\n'), /suppressed: 1 no-console warning/);
});
it('fails on any error, a parse failure and an inline rule config included', async () => {
const code = await run(empty, (root) => [
result(root, 'src/a.ts', [{ ruleId: null, severity: 2, fatal: true, message: 'Parsing error', line: 3, column: 1 }]),
result(root, 'src/b.ts', [{ ruleId: 'no-undef', severity: 2, message: 'x is not defined' }]),
{ ...result(root, 'src/c.ts', []), inlineConfigs: ['1:1 /* eslint no-empty: off */'] },
]);
assert.equal(code, 1);
const out = tree.error.join('\n');
assert.match(out, /src\/a\.ts:3:1 {2}Parsing error/);
assert.match(out, /src\/b\.ts:0:0 {2}x is not defined \(no-undef\)/);
assert.match(out, /src\/c\.ts:1:1 {2}\/\* eslint no-empty: off \*\/ {2}configures a rule inline/);
});
it('stops on a baseline without all three areas, with a stray one, or without a lint function', async () => {
assert.match(areaCounts({ src: {}, tests: {} }), /"suppressed" area is missing/);
assert.match(areaCounts({ ...empty, other: {} }), /"other" is not one of/);
assert.match(areaCounts({ ...empty, src: { a: -1 } }), /in "src"/);
assert.match(areaCounts([]), /not an object/);
await assert.rejects(run({ src: {} }, () => []), RatchetError);
tree.remove();
tree = ratchetTree({ [BASELINE]: JSON.stringify(empty) });
await assert.rejects(check({ root: tree.root, ...tree.out }), /without a lint function/);
});
it('finds the block comments that configure a rule, and nothing in strings, templates or regexes', () => {
const source = [
'/* eslint @typescript-eslint/no-explicit-any: off */',
'const a = "/* eslint no-empty: off */";',
'const b = `${a} /* eslint no-empty: off */`;',
'/* eslint-disable no-empty */',
'/*eslint no-empty:0*/ const c = 1;',
'const r = /\\/\\* eslint no-empty/;',
'// eslint no-empty: off',
'/** eslint is the linter */',
].join('\n');
assert.deepEqual(inlineConfigsOf(source, 'src/a.ts'), [
'1:1 /* eslint @typescript-eslint/no-explicit-any: off */',
'5:1 /*eslint no-empty:0*/',
]);
assert.equal(inlineConfigsOf('export const a = 1 /* eslint no-empty: off */\n', 'src/a.mts').length, 1);
assert.deepEqual(inlineConfigsOf('const a = <div>{/* eslint no-empty: 1 */}</div>;\n', 'src/a.tsx').length, 1);
assert.deepEqual(inlineConfigsOf('const a = 1;\n', 'src/a.jsx'), []);
assert.deepEqual(inlineConfigsOf('export const a = 1\n', 'src/a.js'), []);
});
it('lints with the ESLint class it is handed and reads the inline rule configs of every file', async () => {
tree = ratchetTree({
'eslint.config.mjs': "export default [{ files: ['**/*.js'], rules: { 'no-empty': 'warn' } }];\n",
'src/a.js': '/* eslint no-empty: off */\nif (globalThis.x) {}\n',
'src/b.js': 'if (globalThis.x) {}\n',
});
const results = await lintWith(ESLint)(tree.root);
const byKey = Object.fromEntries(results.map((r) => [r.filePath.split('\\').join('/').split('/src/')[1], r]));
assert.deepEqual(byKey['a.js'].messages, []);
assert.deepEqual(byKey['a.js'].inlineConfigs, ['1:1 /* eslint no-empty: off */']);
assert.deepEqual(
byKey['b.js'].messages.map((m) => m.ruleId),
['no-empty'],
);
assert.deepEqual(byKey['b.js'].inlineConfigs, []);
});
});
+91
View File
@@ -0,0 +1,91 @@
/*
* The comparison behind a workspace's lint:format (server/scripts/format-lint.mjs,
* shared/scripts/format-lint.mjs): which files Prettier would rewrite, against
* the list of files that were unformatted when the check came in.
*
* The same logic as client/scripts/lib/format.mjs, with the directories, the
* file kinds and Prettier itself handed in by the caller: each workspace
* formats with the Prettier it installs and the .prettierrc next to it.
*/
import { join } from 'node:path';
import {
fileList,
listFiles,
lowerList,
RatchetError,
readBaseline,
readText,
toKey,
writeBaseline,
} from './ratchet.mjs';
/**
* Whether Prettier leaves the file as it is, with the config that applies to
* it. The text is read with LF line ends, so a Windows checkout agrees with CI
* about endOfLine. A file Prettier cannot parse is not formatted.
*
* @param {{ resolveConfig(path: string): Promise<object | null>, check(text: string, options: object): Promise<boolean> }} prettier
* @param {string} path
*/
export async function isFormatted(prettier, path) {
const options = await prettier.resolveConfig(path);
if (!options) throw new RatchetError(`no Prettier config applies to ${path}`);
try {
return await prettier.check(readText(path), { ...options, filepath: path });
} catch {
return false;
}
}
/** The files under root/<dirs> that accepts takes and Prettier would rewrite, keyed by their path from root. */
export async function unformatted({ prettier, root, dirs, accepts }) {
const found = [];
for (const path of listFiles(root, dirs, accepts)) {
if (!(await isFormatted(prettier, path))) found.push(toKey(root, path));
}
return found;
}
/**
* Runs the check against the list at baselinePath. A file outside the list
* must be formatted. A listed file that is formatted now, or gone, fails the
* check as well until --update takes it out: the list only shrinks, and a
* stale entry would let the next file at that path in unformatted. Once the
* whole workspace has been formatted, --update empties the list and the check
* is a plain prettier --check. Returns the exit code.
*/
export async function check({
prettier,
root,
dirs,
accepts,
baselinePath = join(root, 'scripts/format-baseline.json'),
update = false,
list = false,
log = console.log,
error = console.error,
}) {
const found = await unformatted({ prettier, root, dirs, accepts });
let baseline = readBaseline(baselinePath, fileList);
if (update) {
baseline = lowerList(baseline, found);
writeBaseline(baselinePath, baseline);
}
const allowed = new Set(baseline);
const fresh = found.filter((key) => !allowed.has(key));
const now = new Set(found);
const stale = baseline.filter((key) => !now.has(key));
if (list) for (const key of found) log(`${allowed.has(key) ? 'listed' : 'new '} ${key}`);
for (const key of fresh) {
error(`FAIL ${key} is not formatted. Run: npx prettier --write ${key}`);
}
for (const key of stale) {
error(`FAIL ${key} is listed in scripts/format-baseline.json but is formatted now or gone.`);
}
if (stale.length) error('Run npm run lint:format -- --update to take the formatted files off the list.');
log(`format: ${found.length} unformatted file(s), ${baseline.length} listed in the baseline`);
return fresh.length || stale.length ? 1 : 0;
}
+88
View File
@@ -0,0 +1,88 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { afterEach, describe, it } from 'node:test';
import * as prettier from 'prettier';
import { check, isFormatted } from './format.mjs';
import { RatchetError } from './ratchet.mjs';
import { ratchetTree } from './tree-fixture.mjs';
let tree;
afterEach(() => tree?.remove());
const BASELINE = 'scripts/format-baseline.json';
const CONFIG = { '.prettierrc': JSON.stringify({ semi: true, singleQuote: true, endOfLine: 'lf' }) };
const FORMATTED = "const a = 'x';\n";
const UNFORMATTED = 'const a = "x"\n';
const DIRS = ['src', 'tests'];
const accepts = (key) => key.endsWith('.ts');
function run(baseline, files, update = false, list = false) {
tree = ratchetTree({
...CONFIG,
[BASELINE]: JSON.stringify(baseline),
'src/keep.ts': FORMATTED,
'tests/keep.ts': FORMATTED,
...files,
});
return check({ prettier, root: tree.root, dirs: DIRS, accepts, baselinePath: tree.path(BASELINE), update, list, ...tree.out });
}
describe('scripts/lib/format', () => {
it('tells formatted from unformatted files, and reads CRLF as LF', async () => {
tree = ratchetTree({
...CONFIG,
'a.ts': FORMATTED,
'b.ts': UNFORMATTED,
'c.ts': "const a = 'x';\r\n",
'd.ts': 'const (',
});
assert.equal(await isFormatted(prettier, tree.path('a.ts')), true);
assert.equal(await isFormatted(prettier, tree.path('b.ts')), false);
assert.equal(await isFormatted(prettier, tree.path('c.ts')), true);
assert.equal(await isFormatted(prettier, tree.path('d.ts')), false);
});
it('passes when every unformatted file is listed, and --list names each one', async () => {
assert.equal(await run(['src/old.ts'], { 'src/old.ts': UNFORMATTED, 'src/new.ts': FORMATTED }, false, true), 0);
assert.deepEqual(tree.error, []);
assert.ok(tree.log.includes('listed src/old.ts'));
});
it('fails a new file that is not formatted, and leaves files it does not accept alone', async () => {
assert.equal(await run([], { 'tests/new.ts': UNFORMATTED, 'src/data.json': '{"a":1}' }), 1);
const out = tree.error.join('\n');
assert.match(out, /tests\/new\.ts is not formatted\. Run: npx prettier --write tests\/new\.ts/);
assert.doesNotMatch(out, /data\.json/);
});
it('fails a listed file that is formatted now or gone, until --update takes it off', async () => {
assert.equal(await run(['src/done.ts', 'src/gone.ts'], { 'src/done.ts': FORMATTED }), 1);
const out = tree.error.join('\n');
assert.match(out, /src\/done\.ts is listed .* formatted now or gone/);
assert.match(out, /src\/gone\.ts is listed/);
assert.match(out, /npm run lint:format -- --update/);
tree.remove();
assert.equal(await run(['src/done.ts', 'src/old.ts'], { 'src/done.ts': FORMATTED, 'src/old.ts': UNFORMATTED }, true), 0);
assert.deepEqual(JSON.parse(readFileSync(tree.path(BASELINE), 'utf8')), ['src/old.ts']);
});
it('never puts a file on the list with --update', async () => {
assert.equal(await run([], { 'src/new.ts': UNFORMATTED }, true), 1);
assert.deepEqual(JSON.parse(readFileSync(tree.path(BASELINE), 'utf8')), []);
});
it('stops on a missing baseline or a missing Prettier config', async () => {
tree = ratchetTree({ ...CONFIG, 'src/a.ts': FORMATTED, 'tests/a.ts': FORMATTED });
await assert.rejects(
check({ prettier, root: tree.root, dirs: DIRS, accepts, baselinePath: tree.path(BASELINE), ...tree.out }),
RatchetError,
);
tree.remove();
tree = ratchetTree({ [BASELINE]: '[]', 'src/a.ts': FORMATTED, 'tests/a.ts': FORMATTED });
const stub = { resolveConfig: async () => null, check: async () => true };
await assert.rejects(
check({ prettier: stub, root: tree.root, dirs: DIRS, accepts, baselinePath: tree.path(BASELINE), ...tree.out }),
/no Prettier config/,
);
});
});
+198
View File
@@ -0,0 +1,198 @@
/*
* The parts every ratchet shares: walking the tree, reading a file the same
* way on Windows and Linux, and reading, lowering and writing a baseline.
*
* This is the repository-wide copy. The server and shared ratchets
* (server/scripts/*-lint.mjs, shared/scripts/format-lint.mjs) import it from
* here; client/scripts/lib/ratchet.mjs is the same file and moves onto this
* one with the client's next tooling change. A baseline always lives in the
* scripts/ directory of its workspace, which is the path the messages name.
*
* A ratchet fails closed. A baseline that is missing, cannot be parsed or has
* the wrong shape stops the run with its reason instead of reading as empty
* (as empty, the check would fail with no hint at the cause, and --update
* would write an empty file over the real one). A directory that should be
* scanned and is not there, or a scan that finds no file at all, stops it too:
* a check that looked at nothing must not report green.
*/
import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
import { basename, join, relative } from 'node:path';
/** A problem with the check itself rather than a violation it found. */
export class RatchetError extends Error {}
export const TEST_FILE = /\.(?:test|spec)\.[cm]?[jt]sx?$/;
/** Every file under root/<dir> for each dir whose path passes accept, as absolute paths. */
export function listFiles(root, dirs, accept) {
const files = [];
const walk = (dir) => {
for (const name of readdirSync(dir)) {
if (name === 'node_modules') continue;
const path = join(dir, name);
if (statSync(path).isDirectory()) walk(path);
else if (accept(toKey(root, path))) files.push(path);
}
};
for (const dir of dirs) {
const path = join(root, dir);
if (!existsSync(path) || !statSync(path).isDirectory()) {
throw new RatchetError(`${dir}/ does not exist under ${root}, so there is nothing to check`);
}
walk(path);
}
if (!files.length) throw new RatchetError(`no file to check under ${dirs.map((d) => `${d}/`).join(', ')}`);
return files.sort();
}
/** A path relative to root with forward slashes, the form every baseline uses. */
export function toKey(root, path) {
return relative(root, path).split('\\').join('/');
}
/**
* A text file as the repository holds it: without a byte order mark and with
* LF line ends, so a Windows checkout with CRLF counts the same as CI.
*/
export function readText(path) {
const text = readFileSync(path, 'utf8');
return (text.charCodeAt(0) === 0xfeff ? text.slice(1) : text).replace(/\r\n?/g, '\n');
}
const isCount = (n) => Number.isInteger(n) && n > 0;
/** A JSON object of file → positive count. */
export function countMap(value) {
if (!value || typeof value !== 'object' || Array.isArray(value)) return 'it is not an object of file → count';
for (const [key, n] of Object.entries(value))
if (!isCount(n)) return `"${key}" holds ${JSON.stringify(n)}, not a positive whole number`;
return null;
}
/** A JSON array of distinct file paths. */
export function fileList(value) {
if (!Array.isArray(value)) return 'it is not an array of file paths';
const seen = new Set();
for (const entry of value) {
if (typeof entry !== 'string' || !entry) return `${JSON.stringify(entry)} is not a file path`;
if (seen.has(entry)) return `"${entry}" is listed twice`;
seen.add(entry);
}
return null;
}
/**
* The baseline as committed, checked against shape (one of the validators
* above, which returns null when the value fits and the reason otherwise).
*/
export function readBaseline(path, shape) {
const name = basename(path);
let text;
try {
text = readFileSync(path, 'utf8');
} catch (err) {
throw new RatchetError(`scripts/${name} cannot be read (${err.code ?? err.message}); restore it from git`);
}
let value;
try {
value = JSON.parse(text);
} catch (err) {
throw new RatchetError(`scripts/${name} is not valid JSON (${err.message}); restore it from git`);
}
const problem = shape(value);
if (problem) throw new RatchetError(`scripts/${name} has the wrong shape: ${problem}; restore it from git`);
return value;
}
/** Writes value as sorted, two-space JSON with a final newline. */
export function writeBaseline(path, value) {
const sorted = Array.isArray(value)
? [...value].sort()
: Object.fromEntries(Object.entries(value).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)));
writeFileSync(path, JSON.stringify(sorted, null, 2) + '\n');
}
/**
* The baseline lowered to the counts measured now. An entry never rises, a
* file that is not in the baseline never enters it, and an entry whose count
* fell to floor or below leaves. floor is a number, or a function of the key
* when the limit differs between files.
*
* @param {Record<string, number>} baseline
* @param {Record<string, number>} counts
* @param {number | ((key: string) => number)} [floor]
*/
export function lowerCounts(baseline, counts, floor = 0) {
const lowered = {};
for (const [key, allowed] of Object.entries(baseline)) {
const now = counts[key] ?? 0;
if (now > (typeof floor === 'function' ? floor(key) : floor)) lowered[key] = Math.min(allowed, now);
}
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);
return baseline.filter((file) => now.has(file));
}
/**
* Runs one ratchet as a command. main returns the exit code; a RatchetError is
* printed as a failure of the check itself. There is no "am I the entry
* point" guard to get wrong: the command files only ever run, and the logic
* they call lives in scripts/lib/ where tests import it.
*/
export async function runCli(name, main) {
try {
process.exitCode = await main(process.argv.slice(2));
} catch (err) {
if (!(err instanceof RatchetError)) throw err;
console.error(`FAIL ${name}: ${err.message}`);
process.exitCode = 1;
}
}
+142
View File
@@ -0,0 +1,142 @@
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { afterEach, describe, it } from 'node:test';
import {
countMap,
fileList,
listFiles,
lowerCounts,
lowerList,
RatchetError,
readBaseline,
readText,
reportStale,
staleCounts,
writeBaseline,
} from './ratchet.mjs';
import { ratchetTree } from './tree-fixture.mjs';
let tree;
afterEach(() => tree?.remove());
const rel = (root, files) => files.map((f) => f.slice(root.length + 1).split('\\').join('/'));
describe('scripts/lib/ratchet', () => {
it('lists the accepted files under each directory, sorted, and skips node_modules', () => {
tree = ratchetTree({
'src/b.ts': '',
'src/a/c.tsx': '',
'src/d.css': '',
'tests/e.ts': '',
'src/node_modules/x.ts': '',
});
const files = listFiles(tree.root, ['src', 'tests'], (key) => /\.tsx?$/.test(key));
assert.deepEqual(rel(tree.root, files), ['src/a/c.tsx', 'src/b.ts', 'tests/e.ts']);
});
it('stops the scan on a directory that is not there, and on a scan that finds nothing', () => {
tree = ratchetTree({ 'src/a.md': '' });
assert.throws(() => listFiles(tree.root, ['src', 'tests'], () => true), RatchetError);
assert.throws(() => listFiles(tree.root, ['src', 'tests'], () => true), /tests\/ does not exist/);
assert.throws(() => listFiles(tree.root, ['src'], (key) => key.endsWith('.ts')), /no file to check/);
});
it('reads CRLF and a byte order mark as the LF text git holds', () => {
tree = ratchetTree({ 'a.ts': '\uFEFFone\r\ntwo\r\n' });
assert.equal(readText(tree.path('a.ts')), 'one\ntwo\n');
});
it('treats a missing, broken or misshapen baseline as an error, never as an empty one', () => {
tree = ratchetTree({
'broken.json': '{ "a": ',
'array.json': '["a"]',
'zero.json': '{ "a": 0 }',
'twice.json': '["a", "a"]',
'blank.json': '[""]',
});
assert.throws(() => readBaseline(tree.path('missing.json'), countMap), /cannot be read/);
assert.throws(() => readBaseline(tree.path('broken.json'), countMap), /not valid JSON/);
assert.throws(() => readBaseline(tree.path('array.json'), countMap), /wrong shape/);
assert.throws(() => readBaseline(tree.path('zero.json'), countMap), /positive whole number/);
assert.throws(() => readBaseline(tree.path('twice.json'), fileList), /listed twice/);
assert.throws(() => readBaseline(tree.path('blank.json'), fileList), /not a file path/);
assert.match(fileList({}), /not an array/);
});
it('writes a baseline sorted, with a final newline, and reads it back', () => {
tree = ratchetTree();
writeBaseline(tree.path('b.json'), { 'src/z.ts': 2, 'src/a.ts': 1 });
assert.equal(readText(tree.path('b.json')), '{\n "src/a.ts": 1,\n "src/z.ts": 2\n}\n');
assert.deepEqual(readBaseline(tree.path('b.json'), countMap), { 'src/a.ts': 1, 'src/z.ts': 2 });
writeBaseline(tree.path('l.json'), ['b', 'a']);
assert.equal(readText(tree.path('l.json')), '[\n "a",\n "b"\n]\n');
});
it('lowers without ever raising or adding an entry, and drops what fell to the floor', () => {
const lowered = lowerCounts(
{ grew: 5, shrank: 5, gone: 5, floor: 5 },
{ grew: 9, shrank: 3, floor: 2, fresh: 7 },
2,
);
assert.deepEqual(lowered, { grew: 5, shrank: 3 });
assert.deepEqual(
lowerCounts({ a: 9 }, { a: 4 }, (key) => (key === 'a' ? 4 : 0)),
{},
);
});
it('calls an entry above the count, at the floor or for a gone file stale, one at the count not', () => {
assert.deepEqual(
staleCounts({ held: 5, shrank: 5, gone: 5, floor: 5 }, { held: 5, shrank: 3, floor: 2, fresh: 9 }, 2),
[
{ key: 'shrank', entry: 5, now: 3 },
{ key: 'gone', entry: 5, now: 0 },
{ key: 'floor', entry: 5, now: 2 },
],
);
assert.deepEqual(staleCounts({ a: 3 }, { a: 4 }), []);
});
it('reports a stale entry with its count, or as gone, and how to lower it', () => {
tree = ratchetTree({ 'src/here.ts': 'x\n' });
const lines = [];
const error = (line) => lines.push(line);
reportStale(
[
{ key: 'src/here.ts', entry: 4, now: 2 },
{ key: 'src/gone.ts', entry: 3, now: 0 },
],
{ file: 'b.json', command: 'lint:x', root: tree.root, error },
);
assert.equal(lines[0], 'FAIL src/here.ts is held at 4 in scripts/b.json, but there are 2 now.');
assert.equal(lines[1], 'FAIL src/gone.ts is held at 3 in scripts/b.json, but the file is gone.');
assert.match(lines[2], /^Run npm run lint:x -- --update to lower the baseline/);
lines.length = 0;
reportStale([], { file: 'b.json', command: 'lint:x', error });
assert.deepEqual(lines, []);
});
it('lets a file list only lose the files that stopped offending', () => {
assert.deepEqual(lowerList(['a', 'b', 'c'], ['c', 'a', 'new']), ['a', 'c']);
});
it('exits a command with what its check returns, and 1 when the check itself fails', () => {
const lib = new URL('./ratchet.mjs', import.meta.url).href;
const command = (body) =>
spawnSync(
process.execPath,
[
'--input-type=module',
'-e',
`import { runCli, RatchetError } from '${lib}'; await runCli('lint:x', ${body})`,
],
{ encoding: 'utf8' },
);
assert.equal(command('() => 0').status, 0);
assert.equal(command('async () => 1').status, 1);
const broken = command("() => { throw new RatchetError('baseline gone') }");
assert.equal(broken.status, 1);
assert.match(broken.stderr, /FAIL {2}lint:x: baseline gone/);
assert.notEqual(command("() => { throw new Error('bug') }").status, 0);
});
});
+32
View File
@@ -0,0 +1,32 @@
/*
* A throwaway tree for the tests of the libs in this directory: each key is a
* path from the tree's root, each value the file's text. The checks run
* against it with their output collected, so a test sees exactly what CI
* would print and the exit code it would end with.
*/
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
export function ratchetTree(files = {}) {
const root = mkdtempSync(join(tmpdir(), 'trek-ratchet-'));
const log = [];
const error = [];
const write = (more) => {
for (const [key, text] of Object.entries(more)) {
const path = join(root, key);
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, text);
}
};
write(files);
return {
root,
write,
path: (key) => join(root, key),
log,
error,
out: { log: (line) => log.push(line), error: (line) => error.push(line) },
remove: () => rmSync(root, { recursive: true, force: true }),
};
}