mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
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:
@@ -259,6 +259,11 @@ jobs:
|
||||
# wrapped), so a big service is split by concern instead of growing.
|
||||
run: cd server && npm run lint:size
|
||||
|
||||
- name: Shared ratchet code
|
||||
# scripts/lib/ at the root holds the baseline handling, the warning
|
||||
# tally and the format comparison the server and shared gates run.
|
||||
run: npm run test:lib-scripts
|
||||
|
||||
- name: Import boundaries and cycles
|
||||
# No new load-time cycle, no new reach into another domain's
|
||||
# internals, src/db stays below src/nest. Today's violations sit in
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
"test:cov": "npm run test:coverage --workspace=shared && npm run test:coverage --workspace=server && npm run test:coverage --workspace=client && npm run test:coverage --prefix plugin-sdk",
|
||||
"test:e2e": "npm run test:e2e --workspace=server",
|
||||
"test:ci-scripts": "node --test scripts/ci/*.test.mjs",
|
||||
"test:lib-scripts": "node --test scripts/lib/*.test.mjs",
|
||||
"lint": "npm run lint --workspace=shared && npm run lint --workspace=server && npm run lint --workspace=client",
|
||||
"format": "npm run format --workspace=shared && npm run format --workspace=server && npm run format --workspace=client",
|
||||
"format:check": "npm run format:check --workspace=shared && npm run format:check --workspace=server && npm run format:check --workspace=client"
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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, []);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 }),
|
||||
};
|
||||
}
|
||||
@@ -25,121 +25,85 @@
|
||||
* that is back under the limit
|
||||
*
|
||||
* --dir=<path> points the check at another server root (the unit tests use it).
|
||||
* Walking the tree and handling the baseline live in scripts/lib/ratchet.mjs
|
||||
* at the repository root.
|
||||
*/
|
||||
import { existsSync, readdirSync, readFileSync, realpathSync, statSync, writeFileSync } from 'node:fs';
|
||||
import { join, relative, resolve } from 'node:path';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import {
|
||||
listFiles,
|
||||
lowerCounts,
|
||||
RatchetError,
|
||||
readBaseline,
|
||||
readText,
|
||||
runCli,
|
||||
staleCounts,
|
||||
toKey,
|
||||
writeBaseline,
|
||||
} from '../../scripts/lib/ratchet.mjs';
|
||||
|
||||
/** The most lines a file without a baseline entry may hold. */
|
||||
export const LIMIT = 1000;
|
||||
const LIMIT = 1000;
|
||||
|
||||
/** prettier's printWidth for the server: a line past it counts once per width it spans. */
|
||||
export const LINE_WIDTH = 120;
|
||||
const LINE_WIDTH = 120;
|
||||
|
||||
/** The trees the check walks, relative to the server root. Each one must exist. */
|
||||
export const ROOTS = ['src', 'scripts'];
|
||||
const ROOTS = ['src', 'scripts'];
|
||||
|
||||
const SOURCE = /\.(?:[cm]?[jt]s)$/;
|
||||
const TEST = /\.(?:test|spec)\./;
|
||||
|
||||
const accepts = (key) => {
|
||||
const name = key.slice(key.lastIndexOf('/') + 1);
|
||||
return SOURCE.test(name) && !TEST.test(name);
|
||||
};
|
||||
|
||||
/**
|
||||
* Lines as an editor numbers them (a final newline does not start another),
|
||||
* with every line longer than LINE_WIDTH weighted by the widths it spans.
|
||||
* A CRLF checkout counts the same as an LF one.
|
||||
*/
|
||||
export function lineCount(text) {
|
||||
function lineCount(text) {
|
||||
if (text === '') return 0;
|
||||
const lines = text.replace(/\r\n?/g, '\n').split('\n');
|
||||
const lines = text.split('\n');
|
||||
if (lines[lines.length - 1] === '') lines.pop();
|
||||
let total = 0;
|
||||
for (const line of lines) total += Math.max(1, Math.ceil(line.length / LINE_WIDTH));
|
||||
return total;
|
||||
}
|
||||
|
||||
function walk(dir, files = []) {
|
||||
for (const name of readdirSync(dir)) {
|
||||
if (name === 'node_modules') continue;
|
||||
const path = join(dir, name);
|
||||
if (statSync(path).isDirectory()) walk(path, files);
|
||||
else if (SOURCE.test(name) && !TEST.test(name)) files.push(path);
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
/** Weighted line counts keyed by the server-relative POSIX path. A missing root is an error. */
|
||||
export function scan(serverDir) {
|
||||
function scan(serverDir) {
|
||||
const counts = {};
|
||||
for (const root of ROOTS) {
|
||||
const dir = join(serverDir, root);
|
||||
if (!existsSync(dir) || !statSync(dir).isDirectory()) {
|
||||
throw new Error(`${root}/ does not exist under ${serverDir}: the check would pass without looking at anything`);
|
||||
}
|
||||
for (const path of walk(dir)) {
|
||||
counts[relative(serverDir, path).split('\\').join('/')] = lineCount(readFileSync(path, 'utf8'));
|
||||
}
|
||||
}
|
||||
for (const path of listFiles(serverDir, ROOTS, accepts)) counts[toKey(serverDir, path)] = lineCount(readText(path));
|
||||
return counts;
|
||||
}
|
||||
|
||||
/**
|
||||
* The baseline as committed. Missing, unreadable or malformed stops the run: read as
|
||||
* empty, every long file would fail with no hint at the cause, and --update would
|
||||
* replace it with an empty one.
|
||||
*/
|
||||
export function readBaseline(path) {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(readFileSync(path, 'utf8'));
|
||||
} catch (err) {
|
||||
throw new Error(`scripts/size-baseline.json cannot be read: ${err.message}`);
|
||||
/** Every entry must be a whole number above the limit: anything else is a hand edit gone wrong. */
|
||||
function sizeEntries(value) {
|
||||
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return 'it must be an object of file paths to line counts';
|
||||
}
|
||||
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
throw new Error('scripts/size-baseline.json must be an object of file paths to line counts');
|
||||
for (const [file, n] of Object.entries(value)) {
|
||||
if (!Number.isInteger(n) || n <= LIMIT) return `${file} holds ${JSON.stringify(n)}, expected an integer above ${LIMIT}`;
|
||||
}
|
||||
for (const [file, n] of Object.entries(parsed)) {
|
||||
if (!Number.isInteger(n) || n <= LIMIT) {
|
||||
throw new Error(`scripts/size-baseline.json: ${file} holds ${JSON.stringify(n)}, expected an integer above ${LIMIT}`);
|
||||
}
|
||||
}
|
||||
return parsed;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The baseline lowered to what the files hold now. Never raises an entry, never adds one. */
|
||||
export function lowerBaseline(baseline, counts) {
|
||||
const lowered = {};
|
||||
for (const [file, allowed] of Object.entries(baseline)) {
|
||||
const now = counts[file] ?? 0;
|
||||
if (now > LIMIT) lowered[file] = Math.min(allowed, now);
|
||||
}
|
||||
return Object.fromEntries(Object.entries(lowered).sort(([a], [b]) => a.localeCompare(b)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Files over their allowance, and the baseline entries that allow more than
|
||||
* their file holds now (shrunk, back under the limit, or gone): exactly the
|
||||
* entries lowerBaseline would lower or drop.
|
||||
*/
|
||||
export function compare(baseline, counts) {
|
||||
const grown = Object.entries(counts).filter(([file, n]) => n > Math.max(baseline[file] ?? 0, LIMIT));
|
||||
const lowered = lowerBaseline(baseline, counts);
|
||||
const stale = Object.entries(baseline).filter(([file, n]) => lowered[file] !== n);
|
||||
return { grown, stale };
|
||||
}
|
||||
|
||||
function main(argv) {
|
||||
function check(argv) {
|
||||
const dirArg = argv.find((a) => a.startsWith('--dir='));
|
||||
const serverDir = dirArg ? resolve(dirArg.slice('--dir='.length)) : fileURLToPath(new URL('..', import.meta.url));
|
||||
const baselinePath = join(serverDir, 'scripts', 'size-baseline.json');
|
||||
const update = argv.includes('--update');
|
||||
|
||||
const counts = scan(serverDir);
|
||||
let baseline = readBaseline(baselinePath);
|
||||
if (update) {
|
||||
baseline = lowerBaseline(baseline, counts);
|
||||
writeFileSync(baselinePath, JSON.stringify(baseline, null, 2) + '\n');
|
||||
let baseline = readBaseline(baselinePath, sizeEntries);
|
||||
if (argv.includes('--update')) {
|
||||
baseline = lowerCounts(baseline, counts, LIMIT);
|
||||
writeBaseline(baselinePath, baseline);
|
||||
}
|
||||
|
||||
const { grown, stale } = compare(baseline, counts);
|
||||
const grown = Object.entries(counts).filter(([file, n]) => n > Math.max(baseline[file] ?? 0, LIMIT));
|
||||
const stale = staleCounts(baseline, counts, LIMIT);
|
||||
for (const [file, n] of grown) {
|
||||
const entry = baseline[file];
|
||||
console.error(
|
||||
@@ -148,10 +112,10 @@ function main(argv) {
|
||||
'Split a concern into a service, helper or module of its own instead of growing the file.',
|
||||
);
|
||||
}
|
||||
for (const [file, entry] of stale) {
|
||||
for (const { key, entry } of stale) {
|
||||
console.error(
|
||||
`FAIL ${file} is held at ${entry} in scripts/size-baseline.json, ` +
|
||||
(file in counts ? `but it has ${counts[file]} lines now.` : 'but the file is gone.'),
|
||||
`FAIL ${key} is held at ${entry} in scripts/size-baseline.json, ` +
|
||||
(key in counts ? `but it has ${counts[key]} lines now.` : 'but the file is gone.'),
|
||||
);
|
||||
}
|
||||
if (stale.length) {
|
||||
@@ -166,14 +130,12 @@ function main(argv) {
|
||||
return grown.length || stale.length ? 1 : 0;
|
||||
}
|
||||
|
||||
// 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) {
|
||||
await runCli('lint:size', (argv) => {
|
||||
try {
|
||||
process.exitCode = main(process.argv.slice(2));
|
||||
return check(argv);
|
||||
} catch (err) {
|
||||
console.error(`FAIL ${err.message}`);
|
||||
process.exitCode = 1;
|
||||
// A scan or baseline problem the lib reports as a plain Error is still a failure of the check itself.
|
||||
if (err instanceof RatchetError) throw err;
|
||||
throw new RatchetError(err.message);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user