diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 801641d83..8e23cd902 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -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 diff --git a/package.json b/package.json index a63abbb75..633c6e7c2 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/scripts/ci/changed-areas.mjs b/scripts/ci/changed-areas.mjs index 0269f53bf..0b7858f41 100644 --- a/scripts/ci/changed-areas.mjs +++ b/scripts/ci/changed-areas.mjs @@ -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', diff --git a/scripts/ci/changed-areas.test.mjs b/scripts/ci/changed-areas.test.mjs index 8070cfd1c..6d1d2ce08 100644 --- a/scripts/ci/changed-areas.test.mjs +++ b/scripts/ci/changed-areas.test.mjs @@ -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 }); }); diff --git a/scripts/lib/eslint-warnings.mjs b/scripts/lib/eslint-warnings.mjs new file mode 100644 index 000000000..ee74c89e4 --- /dev/null +++ b/scripts/lib/eslint-warnings.mjs @@ -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 }} ESLint + * @returns {(root: string) => Promise} + */ +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 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; +} diff --git a/scripts/lib/eslint-warnings.test.mjs b/scripts/lib/eslint-warnings.test.mjs new file mode 100644 index 000000000..dcdc555f6 --- /dev/null +++ b/scripts/lib/eslint-warnings.test.mjs @@ -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 =
{/* eslint no-empty: 1 */}
;\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, []); + }); +}); diff --git a/scripts/lib/format.mjs b/scripts/lib/format.mjs new file mode 100644 index 000000000..679c8fae9 --- /dev/null +++ b/scripts/lib/format.mjs @@ -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, check(text: string, options: object): Promise }} 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/ 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; +} diff --git a/scripts/lib/format.test.mjs b/scripts/lib/format.test.mjs new file mode 100644 index 000000000..f75234c82 --- /dev/null +++ b/scripts/lib/format.test.mjs @@ -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/, + ); + }); +}); diff --git a/scripts/lib/ratchet.mjs b/scripts/lib/ratchet.mjs new file mode 100644 index 000000000..5b499f412 --- /dev/null +++ b/scripts/lib/ratchet.mjs @@ -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/ 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} baseline + * @param {Record} 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} baseline + * @param {Record} 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/, 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; + } +} diff --git a/scripts/lib/ratchet.test.mjs b/scripts/lib/ratchet.test.mjs new file mode 100644 index 000000000..55d8d5c41 --- /dev/null +++ b/scripts/lib/ratchet.test.mjs @@ -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); + }); +}); diff --git a/scripts/lib/tree-fixture.mjs b/scripts/lib/tree-fixture.mjs new file mode 100644 index 000000000..4e60b60e3 --- /dev/null +++ b/scripts/lib/tree-fixture.mjs @@ -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 }), + }; +} diff --git a/server/scripts/size-lint.mjs b/server/scripts/size-lint.mjs index 8481f0873..1df9f51e9 100644 --- a/server/scripts/size-lint.mjs +++ b/server/scripts/size-lint.mjs @@ -25,121 +25,85 @@ * that is back under the limit * * --dir= 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); } -} +});