From 0344a86aeca98219c2eb7e03fca61457c1145265 Mon Sep 17 00:00:00 2001 From: Maurice Date: Thu, 8 Oct 2026 12:24:37 +0200 Subject: [PATCH] style(client): format the ratchet scripts with Prettier The checker scripts were written without semicolons, against the semi: true in .prettierrc, so every one of them failed the formatting rule lint:format enforces on src/ and tests/. This is prettier --write over the scripts this branch added or rewrote, nothing else. --- client/scripts/eslint-ratchet.mjs | 10 +- client/scripts/format-lint.mjs | 12 +- client/scripts/layer-lint.mjs | 12 +- client/scripts/lib/eslint-warnings.mjs | 92 +++++++------- client/scripts/lib/format.mjs | 61 ++++++---- client/scripts/lib/imports.mjs | 65 +++++----- client/scripts/lib/layers.mjs | 59 ++++----- client/scripts/lib/offline.mjs | 54 ++++----- client/scripts/lib/ratchet.mjs | 99 +++++++-------- client/scripts/lib/rtl.mjs | 159 +++++++++++++------------ client/scripts/lib/size.mjs | 84 ++++++++----- client/scripts/lib/skips.mjs | 122 ++++++++++--------- client/scripts/offline-lint.mjs | 12 +- client/scripts/rtl-lint.mjs | 10 +- client/scripts/size-lint.mjs | 10 +- client/scripts/skip-lint.mjs | 10 +- 16 files changed, 473 insertions(+), 398 deletions(-) diff --git a/client/scripts/eslint-ratchet.mjs b/client/scripts/eslint-ratchet.mjs index 57f9fce32..73183999f 100644 --- a/client/scripts/eslint-ratchet.mjs +++ b/client/scripts/eslint-ratchet.mjs @@ -15,9 +15,9 @@ * npm run lint:warnings -- --update lower the baseline to today's counts; * it never raises an entry */ -import { fileURLToPath } from 'node:url' -import { check } from './lib/eslint-warnings.mjs' -import { runCli } from './lib/ratchet.mjs' +import { fileURLToPath } from 'node:url'; +import { check } from './lib/eslint-warnings.mjs'; +import { runCli } from './lib/ratchet.mjs'; -const root = fileURLToPath(new URL('..', import.meta.url)) -await runCli('lint:warnings', (args) => check({ root, update: args.includes('--update') })) +const root = fileURLToPath(new URL('..', import.meta.url)); +await runCli('lint:warnings', (args) => check({ root, update: args.includes('--update') })); diff --git a/client/scripts/format-lint.mjs b/client/scripts/format-lint.mjs index e005fce26..2f0a7c1b6 100644 --- a/client/scripts/format-lint.mjs +++ b/client/scripts/format-lint.mjs @@ -18,9 +18,11 @@ * To format a file: npx prettier --write . The comparison lives in * scripts/lib/format.mjs. */ -import { fileURLToPath } from 'node:url' -import { check } from './lib/format.mjs' -import { runCli } from './lib/ratchet.mjs' +import { fileURLToPath } from 'node:url'; +import { check } from './lib/format.mjs'; +import { runCli } from './lib/ratchet.mjs'; -const root = fileURLToPath(new URL('..', import.meta.url)) -await runCli('lint:format', (args) => check({ root, update: args.includes('--update'), list: args.includes('--list') })) +const root = fileURLToPath(new URL('..', import.meta.url)); +await runCli('lint:format', (args) => + check({ root, update: args.includes('--update'), list: args.includes('--list') }) +); diff --git a/client/scripts/layer-lint.mjs b/client/scripts/layer-lint.mjs index 1b5028f23..6d16c256f 100644 --- a/client/scripts/layer-lint.mjs +++ b/client/scripts/layer-lint.mjs @@ -17,9 +17,11 @@ * npm run lint:layers -- --update lower the baseline to what the files hold now; * it never raises an entry */ -import { fileURLToPath } from 'node:url' -import { check } from './lib/layers.mjs' -import { runCli } from './lib/ratchet.mjs' +import { fileURLToPath } from 'node:url'; +import { check } from './lib/layers.mjs'; +import { runCli } from './lib/ratchet.mjs'; -const root = fileURLToPath(new URL('..', import.meta.url)) -await runCli('lint:layers', (args) => check({ root, update: args.includes('--update'), list: args.includes('--list') })) +const root = fileURLToPath(new URL('..', import.meta.url)); +await runCli('lint:layers', (args) => + check({ root, update: args.includes('--update'), list: args.includes('--list') }) +); diff --git a/client/scripts/lib/eslint-warnings.mjs b/client/scripts/lib/eslint-warnings.mjs index f62d663f8..998fa28c9 100644 --- a/client/scripts/lib/eslint-warnings.mjs +++ b/client/scripts/lib/eslint-warnings.mjs @@ -1,32 +1,33 @@ /* * The tally and the comparison behind lint:warnings (scripts/eslint-ratchet.mjs). */ -import { join } from 'node:path' -import { ESLint } from 'eslint' -import { countMap, lowerCounts, readBaseline, TEST_FILE, toKey, writeBaseline } from './ratchet.mjs' +import { ESLint } from 'eslint'; +import { join } from 'node:path'; +import { countMap, lowerCounts, readBaseline, 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. */ -export const AREAS = ['src', 'tests', 'suppressed'] +export const AREAS = ['src', 'tests', 'suppressed']; -const areaOf = (key) => (key.startsWith('tests/') || TEST_FILE.test(key) ? 'tests' : 'src') +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)') + 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(', ')}` + 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}` + if (!(area in value)) return `the "${area}" area is missing`; + const problem = countMap(value[area]); + if (problem) return `in "${area}", ${problem}`; } - return null + return null; } /** @@ -34,23 +35,24 @@ export function areaCounts(value) { * count, and every error (an error, a parse failure included, always fails). */ export function tally(results, root) { - const counts = Object.fromEntries(AREAS.map((area) => [area, {}])) - const files = Object.fromEntries(AREAS.map((area) => [area, {}])) - const errors = [] + 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 - } + 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) + const key = toKey(root, result.filePath); 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) + 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) + for (const message of result.suppressedMessages ?? []) add('suppressed', ruleOf(message), key); } - return { counts, files, errors } + return { counts, files, errors }; } /** @@ -67,10 +69,10 @@ export function tally(results, root) { * @returns {Promise} */ export async function lintClient(root) { - return new ESLint({ cwd: root }).lintFiles(['.']) + return new ESLint({ cwd: root }).lintFiles(['.']); } -const sum = (map) => Object.values(map).reduce((a, b) => a + b, 0) +const sum = (map) => Object.values(map).reduce((a, b) => a + b, 0); /** * Runs ESLint (or lint, in a test) and compares the warnings with the @@ -84,39 +86,39 @@ export async function check({ log = console.log, error = console.error, }) { - let baseline = readBaseline(baselinePath, areaCounts) - const { counts, files, errors } = tally(await lint(root), root) + 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) + 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 lowerable = 0 + for (const line of errors) error(`FAIL ${line}`); + let grown = 0; + let lowerable = 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 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(', ')}`) + .map(([key, k]) => `${key} (${k})`); + error(`FAIL ${area}: ${n} ${rule} warning(s), baseline ${allowed}. Most in: ${top.join(', ')}`); } - for (const [rule, allowed] of Object.entries(baseline[area])) if ((counts[area][rule] ?? 0) < allowed) lowerable++ + for (const [rule, allowed] of Object.entries(baseline[area])) if ((counts[area][rule] ?? 0) < allowed) lowerable++; } 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.', - ) + 'Run npx eslint to see each one.' + ); } - if (lowerable && !update) log(`${lowerable} rule count(s) fell below the baseline: run with --update to lower it.`) + if (lowerable && !update) log(`${lowerable} rule count(s) fell below the baseline: run with --update to lower it.`); log( - `eslint: ${errors.length} error(s); warnings ${AREAS.map((area) => `${area} ${sum(counts[area])}/${sum(baseline[area])}`).join(', ')} (now/baseline)`, - ) - return errors.length || grown ? 1 : 0 + `eslint: ${errors.length} error(s); warnings ${AREAS.map((area) => `${area} ${sum(counts[area])}/${sum(baseline[area])}`).join(', ')} (now/baseline)` + ); + return errors.length || grown ? 1 : 0; } diff --git a/client/scripts/lib/format.mjs b/client/scripts/lib/format.mjs index 4bfd06344..3e3bd1f96 100644 --- a/client/scripts/lib/format.mjs +++ b/client/scripts/lib/format.mjs @@ -3,13 +3,22 @@ * Prettier would rewrite, against the list of files that were unformatted * when the check came in. */ -import { join } from 'node:path' -import * as prettier from 'prettier' -import { fileList, listFiles, lowerList, RatchetError, readBaseline, readText, toKey, writeBaseline } from './ratchet.mjs' +import { join } from 'node:path'; +import * as prettier from 'prettier'; +import { + fileList, + listFiles, + lowerList, + RatchetError, + readBaseline, + readText, + toKey, + writeBaseline, +} from './ratchet.mjs'; -export const DIRS = ['src', 'tests'] +export const DIRS = ['src', 'tests']; -export const accepts = (key) => /\.(?:tsx?|css)$/.test(key) +export const accepts = (key) => /\.(?:tsx?|css)$/.test(key); /** * Whether Prettier leaves the file as it is, with the config that applies to @@ -17,20 +26,20 @@ export const accepts = (key) => /\.(?:tsx?|css)$/.test(key) * about endOfLine. A file Prettier cannot parse is not formatted. */ export async function isFormatted(path) { - const options = await prettier.resolveConfig(path) - if (!options) throw new RatchetError(`no Prettier config applies to ${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 }) + return await prettier.check(readText(path), { ...options, filepath: path }); } catch { - return false + return false; } } /** The files under root that Prettier would rewrite, keyed by their path from root. */ export async function unformatted(root) { - const found = [] - for (const path of listFiles(root, DIRS, accepts)) if (!(await isFormatted(path))) found.push(toKey(root, path)) - return found + const found = []; + for (const path of listFiles(root, DIRS, accepts)) if (!(await isFormatted(path))) found.push(toKey(root, path)); + return found; } /** @@ -48,27 +57,27 @@ export async function check({ log = console.log, error = console.error, }) { - const found = await unformatted(root) - let baseline = readBaseline(baselinePath, fileList) + const found = await unformatted(root); + let baseline = readBaseline(baselinePath, fileList); if (update) { - baseline = lowerList(baseline, found) - writeBaseline(baselinePath, baseline) + 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)) + 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}`) + 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}`) + 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.`) + 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 + 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/client/scripts/lib/imports.mjs b/client/scripts/lib/imports.mjs index f3821176e..c5d48752e 100644 --- a/client/scripts/lib/imports.mjs +++ b/client/scripts/lib/imports.mjs @@ -3,48 +3,53 @@ * regular expression, so an import split over lines, a dynamic import() and a * re-export count, and an import inside a comment or a string does not. */ -import { dirname, join, relative, resolve } from 'node:path' -import ts from 'typescript' -import { listFiles, readText, TEST_FILE, toKey } from './ratchet.mjs' +import { dirname, join, relative, resolve } from 'node:path'; +import ts from 'typescript'; +import { listFiles, readText, TEST_FILE, toKey } from './ratchet.mjs'; -const bindingsAreTypes = (elements) => elements.length > 0 && elements.every((el) => el.isTypeOnly) +const bindingsAreTypes = (elements) => elements.length > 0 && elements.every((el) => el.isTypeOnly); /** * Every module the file names, with the line it is named on and whether the * import only brings in types (erased from the bundle, so no runtime path). */ export function importsOf(source, file) { - const kind = file.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS - const sf = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true, kind) - const found = [] + const kind = file.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS; + const sf = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true, kind); + const found = []; const add = (node, literal, typeOnly) => { - if (!literal || !ts.isStringLiteralLike(literal)) return - found.push({ specifier: literal.text, line: sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1, typeOnly }) - } + if (!literal || !ts.isStringLiteralLike(literal)) return; + found.push({ + specifier: literal.text, + line: sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1, + typeOnly, + }); + }; const visit = (node) => { if (ts.isImportDeclaration(node)) { - const clause = node.importClause - const named = clause?.namedBindings && ts.isNamedImports(clause.namedBindings) ? clause.namedBindings.elements : null - const typeOnly = Boolean(clause && (clause.isTypeOnly || (!clause.name && named && bindingsAreTypes(named)))) - add(node, node.moduleSpecifier, typeOnly) + const clause = node.importClause; + const named = + clause?.namedBindings && ts.isNamedImports(clause.namedBindings) ? clause.namedBindings.elements : null; + const typeOnly = Boolean(clause && (clause.isTypeOnly || (!clause.name && named && bindingsAreTypes(named)))); + add(node, node.moduleSpecifier, typeOnly); } else if (ts.isExportDeclaration(node) && node.moduleSpecifier) { - const named = node.exportClause && ts.isNamedExports(node.exportClause) ? node.exportClause.elements : null - add(node, node.moduleSpecifier, node.isTypeOnly || Boolean(named && bindingsAreTypes(named))) + const named = node.exportClause && ts.isNamedExports(node.exportClause) ? node.exportClause.elements : null; + add(node, node.moduleSpecifier, node.isTypeOnly || Boolean(named && bindingsAreTypes(named))); } else if (ts.isImportEqualsDeclaration(node) && ts.isExternalModuleReference(node.moduleReference)) { - add(node, node.moduleReference.expression, node.isTypeOnly) + add(node, node.moduleReference.expression, node.isTypeOnly); } else if (ts.isImportTypeNode(node) && ts.isLiteralTypeNode(node.argument)) { - add(node, node.argument.literal, true) + add(node, node.argument.literal, true); } else if ( ts.isCallExpression(node) && (node.expression.kind === ts.SyntaxKind.ImportKeyword || (ts.isIdentifier(node.expression) && node.expression.text === 'require')) ) { - add(node, node.arguments[0], false) + add(node, node.arguments[0], false); } - ts.forEachChild(node, visit) - } - visit(sf) - return found + ts.forEachChild(node, visit); + }; + visit(sf); + return found; } /** @@ -53,18 +58,20 @@ export function importsOf(source, file) { * or a path outside src/. */ export function layerOf(srcRoot, fromFile, specifier) { - if (!specifier.startsWith('.')) return null - const target = relative(srcRoot, resolve(dirname(fromFile), specifier)).split('\\').join('/') - if (!target || target.startsWith('..')) return null - return target.split('/')[0] + if (!specifier.startsWith('.')) return null; + const target = relative(srcRoot, resolve(dirname(fromFile), specifier)) + .split('\\') + .join('/'); + if (!target || target.startsWith('..')) return null; + return target.split('/')[0]; } /** Every source file under root/src outside the tests, with its key, its layer and its imports resolved to layers. */ export function sourceGraph(root) { - const src = join(root, 'src') + const src = join(root, 'src'); return listFiles(root, ['src'], (key) => /\.tsx?$/.test(key) && !TEST_FILE.test(key)).map((path) => ({ key: toKey(root, path), layer: toKey(src, path).split('/')[0], imports: importsOf(readText(path), path).map((imp) => ({ ...imp, layer: layerOf(src, path, imp.specifier) })), - })) + })); } diff --git a/client/scripts/lib/layers.mjs b/client/scripts/lib/layers.mjs index eb867e123..637258cae 100644 --- a/client/scripts/lib/layers.mjs +++ b/client/scripts/lib/layers.mjs @@ -1,11 +1,11 @@ /* * The rules and the comparison behind lint:layers (scripts/layer-lint.mjs). */ -import { join } from 'node:path' -import { countMap, lowerCounts, readBaseline, writeBaseline } from './ratchet.mjs' -import { sourceGraph } from './imports.mjs' +import { join } from 'node:path'; +import { sourceGraph } from './imports.mjs'; +import { countMap, lowerCounts, readBaseline, writeBaseline } from './ratchet.mjs'; -const VIEW = ['components', 'mobile', 'pages', 'hooks'] +const VIEW = ['components', 'mobile', 'pages', 'hooks']; /** * For each directory under src/, the directories its files may not import @@ -36,21 +36,22 @@ export const RULES = { vacay: VIEW, repo: [...VIEW, 'store'], db: [...VIEW, 'store', 'repo', 'sync', 'api'], -} +}; /** * Every import that crosses a boundary the wrong way, per file. A type-only * import counts too: a type the lower layer needs belongs below it. */ export function violations(root, rules = RULES) { - const found = {} + const found = {}; for (const file of sourceGraph(root)) { - const banned = rules[file.layer] - if (!banned) continue - const wrong = file.imports.filter((imp) => imp.layer && banned.includes(imp.layer)) - if (wrong.length) found[file.key] = wrong.map((imp) => `${imp.line}: '${imp.specifier}' (${file.layer} into ${imp.layer})`) + const banned = rules[file.layer]; + if (!banned) continue; + const wrong = file.imports.filter((imp) => imp.layer && banned.includes(imp.layer)); + if (wrong.length) + found[file.key] = wrong.map((imp) => `${imp.line}: '${imp.specifier}' (${file.layer} into ${imp.layer})`); } - return found + return found; } /** Runs the check against the baseline at baselinePath and returns the exit code. */ @@ -63,39 +64,41 @@ export function check({ log = console.log, error = console.error, }) { - const found = violations(root, rules) - const counts = Object.fromEntries(Object.entries(found).map(([key, list]) => [key, list.length])) - let baseline = readBaseline(baselinePath, countMap) + const found = violations(root, rules); + const counts = Object.fromEntries(Object.entries(found).map(([key, list]) => [key, list.length])); + let baseline = readBaseline(baselinePath, countMap); if (list) { for (const [key, wrong] of Object.entries(found)) { - log(`${key} (${wrong.length}, baseline ${baseline[key] ?? 0})`) - for (const line of wrong) log(` ${line}`) + log(`${key} (${wrong.length}, baseline ${baseline[key] ?? 0})`); + for (const line of wrong) log(` ${line}`); } } if (update) { - baseline = lowerCounts(baseline, counts) - writeBaseline(baselinePath, baseline) + baseline = lowerCounts(baseline, counts); + writeBaseline(baselinePath, baseline); } - const grown = Object.entries(counts).filter(([key, n]) => n > (baseline[key] ?? 0)) - const lowerable = Object.entries(baseline).filter(([key, n]) => (counts[key] ?? 0) < n) + const grown = Object.entries(counts).filter(([key, n]) => n > (baseline[key] ?? 0)); + const lowerable = Object.entries(baseline).filter(([key, n]) => (counts[key] ?? 0) < n); for (const [key, n] of grown) { - error(`FAIL ${key}: ${n} import(s) against the layering, baseline ${baseline[key] ?? 0}:`) - for (const line of found[key]) error(` ${line}`) + error(`FAIL ${key}: ${n} import(s) against the layering, baseline ${baseline[key] ?? 0}:`); + for (const line of found[key]) error(` ${line}`); } if (grown.length) { error( 'Move what both sides need down into a layer they may share (a model or helper under utils/, ' + - 'a hook under hooks/, a type under types/) instead of importing upwards.', - ) + 'a hook under hooks/, a type under types/) instead of importing upwards.' + ); } if (lowerable.length && !update) { - log(`${lowerable.length} file(s) hold fewer of these imports than the baseline: run with --update to lower it.`) + log(`${lowerable.length} file(s) hold fewer of these imports than the baseline: run with --update to lower it.`); } - const sum = (map) => Object.values(map).reduce((a, b) => a + b, 0) - log(`layers: ${sum(counts)} import(s) against the layering in ${Object.keys(counts).length} file(s), baseline allows ${sum(baseline)}`) - return grown.length ? 1 : 0 + const sum = (map) => Object.values(map).reduce((a, b) => a + b, 0); + log( + `layers: ${sum(counts)} import(s) against the layering in ${Object.keys(counts).length} file(s), baseline allows ${sum(baseline)}` + ); + return grown.length ? 1 : 0; } diff --git a/client/scripts/lib/offline.mjs b/client/scripts/lib/offline.mjs index ec24d1660..9b39ca78f 100644 --- a/client/scripts/lib/offline.mjs +++ b/client/scripts/lib/offline.mjs @@ -1,12 +1,12 @@ /* * The comparison behind lint:offline (scripts/offline-lint.mjs). */ -import { join } from 'node:path' -import { fileList, lowerList, readBaseline, writeBaseline } from './ratchet.mjs' -import { sourceGraph } from './imports.mjs' +import { join } from 'node:path'; +import { sourceGraph } from './imports.mjs'; +import { fileList, lowerList, readBaseline, writeBaseline } from './ratchet.mjs'; /** The layers that render or drive a view, and so must not hold a network call. */ -export const VIEW_LAYERS = ['components', 'mobile', 'pages', 'hooks'] +export const VIEW_LAYERS = ['components', 'mobile', 'pages', 'hooks']; /** * The view files that import from src/api/ for anything but types, each with @@ -14,13 +14,13 @@ export const VIEW_LAYERS = ['components', 'mobile', 'pages', 'hooks'] * carries no request, so it does not count. */ export function bypasses(root) { - const found = {} + const found = {}; for (const file of sourceGraph(root)) { - if (!VIEW_LAYERS.includes(file.layer)) continue - const direct = file.imports.filter((imp) => imp.layer === 'api' && !imp.typeOnly) - if (direct.length) found[file.key] = direct.map((imp) => `${imp.line}: '${imp.specifier}'`) + if (!VIEW_LAYERS.includes(file.layer)) continue; + const direct = file.imports.filter((imp) => imp.layer === 'api' && !imp.typeOnly); + if (direct.length) found[file.key] = direct.map((imp) => `${imp.line}: '${imp.specifier}'`); } - return found + return found; } /** @@ -38,40 +38,40 @@ export function check({ log = console.log, error = console.error, }) { - const found = bypasses(root) - const offenders = Object.keys(found) - let baseline = readBaseline(baselinePath, fileList) + const found = bypasses(root); + const offenders = Object.keys(found); + let baseline = readBaseline(baselinePath, fileList); if (update) { - baseline = lowerList(baseline, offenders) - writeBaseline(baselinePath, baseline) + baseline = lowerList(baseline, offenders); + writeBaseline(baselinePath, baseline); } - const allowed = new Set(baseline) - const fresh = offenders.filter((key) => !allowed.has(key)) - const stale = baseline.filter((key) => !found[key]) + const allowed = new Set(baseline); + const fresh = offenders.filter((key) => !allowed.has(key)); + const stale = baseline.filter((key) => !found[key]); if (list) { for (const [key, direct] of Object.entries(found)) { - log(`${allowed.has(key) ? 'listed' : 'new '} ${key}`) - for (const line of direct) log(` ${line}`) + log(`${allowed.has(key) ? 'listed' : 'new '} ${key}`); + for (const line of direct) log(` ${line}`); } } for (const key of fresh) { - error(`FAIL ${key} imports src/api/ directly:`) - for (const line of found[key]) error(` ${line}`) + error(`FAIL ${key} imports src/api/ directly:`); + for (const line of found[key]) error(` ${line}`); } if (fresh.length) { error( 'A view reads through a repo (src/repo/) or a store and writes through the mutation queue, ' + 'so it keeps working offline. A domain that is online-only on purpose still gets its calls ' + - 'in a store or a repo, not in the view. See client/CLAUDE.md, "Data flow".', - ) + 'in a store or a repo, not in the view. See client/CLAUDE.md, "Data flow".' + ); } for (const key of stale) { - error(`FAIL ${key} is listed in scripts/offline-baseline.json but no longer imports src/api/ (or is gone).`) + error(`FAIL ${key} is listed in scripts/offline-baseline.json but no longer imports src/api/ (or is gone).`); } - if (stale.length) error('Run npm run lint:offline -- --update to take it off the list.') - log(`offline: ${offenders.length} view file(s) import src/api/ directly, ${baseline.length} listed in the baseline`) - return fresh.length || stale.length ? 1 : 0 + if (stale.length) error('Run npm run lint:offline -- --update to take it off the list.'); + log(`offline: ${offenders.length} view file(s) import src/api/ directly, ${baseline.length} listed in the baseline`); + return fresh.length || stale.length ? 1 : 0; } diff --git a/client/scripts/lib/ratchet.mjs b/client/scripts/lib/ratchet.mjs index 57ba97091..e6bd2085e 100644 --- a/client/scripts/lib/ratchet.mjs +++ b/client/scripts/lib/ratchet.mjs @@ -10,39 +10,39 @@ * 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' +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?$/ +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 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) + 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) + 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`) + throw new RatchetError(`${dir}/ does not exist under ${root}, so there is nothing to check`); } - walk(path) + walk(path); } - if (!files.length) throw new RatchetError(`no file to check under ${dirs.map((d) => `${d}/`).join(', ')}`) - return files.sort() + 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('/') + return relative(root, path).split('\\').join('/'); } /** @@ -50,29 +50,30 @@ export function toKey(root, path) { * 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 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 +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 + 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() + 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) + 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 + return null; } /** @@ -80,30 +81,30 @@ export function fileList(value) { * above, which returns null when the value fits and the reason otherwise). */ export function readBaseline(path, shape) { - const name = basename(path) - let text + const name = basename(path); + let text; try { - text = readFileSync(path, 'utf8') + text = readFileSync(path, 'utf8'); } catch (err) { - throw new RatchetError(`scripts/${name} cannot be read (${err.code ?? err.message}); restore it from git`) + throw new RatchetError(`scripts/${name} cannot be read (${err.code ?? err.message}); restore it from git`); } - let value + let value; try { - value = JSON.parse(text) + value = JSON.parse(text); } catch (err) { - throw new RatchetError(`scripts/${name} is not valid JSON (${err.message}); restore it from git`) + 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 + 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') + : Object.fromEntries(Object.entries(value).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))); + writeFileSync(path, JSON.stringify(sorted, null, 2) + '\n'); } /** @@ -117,18 +118,18 @@ export function writeBaseline(path, value) { * @param {number | ((key: string) => number)} [floor] */ export function lowerCounts(baseline, counts, floor = 0) { - const lowered = {} + 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) + const now = counts[key] ?? 0; + if (now > (typeof floor === 'function' ? floor(key) : floor)) lowered[key] = Math.min(allowed, now); } - return lowered + return lowered; } /** 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)) + const now = new Set(offenders); + return baseline.filter((file) => now.has(file)); } /** @@ -139,10 +140,10 @@ export function lowerList(baseline, offenders) { */ export async function runCli(name, main) { try { - process.exitCode = await main(process.argv.slice(2)) + 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 + if (!(err instanceof RatchetError)) throw err; + console.error(`FAIL ${name}: ${err.message}`); + process.exitCode = 1; } } diff --git a/client/scripts/lib/rtl.mjs b/client/scripts/lib/rtl.mjs index e31450548..a7f0a7710 100644 --- a/client/scripts/lib/rtl.mjs +++ b/client/scripts/lib/rtl.mjs @@ -1,9 +1,18 @@ /* * The matching and the comparison behind lint:rtl (scripts/rtl-lint.mjs). */ -import { join } from 'node:path' -import ts from 'typescript' -import { countMap, listFiles, lowerCounts, readBaseline, readText, TEST_FILE, toKey, writeBaseline } from './ratchet.mjs' +import { join } from 'node:path'; +import ts from 'typescript'; +import { + countMap, + listFiles, + lowerCounts, + readBaseline, + readText, + TEST_FILE, + toKey, + writeBaseline, +} from './ratchet.mjs'; // Tailwind utilities that name a physical side, with any variant prefix // (sm:, hover:, group-hover:) and the negative form (-ml-2). @@ -18,15 +27,15 @@ const CLASS = new RegExp( String.raw`space-x-[\w.]+`, ].join('|') + ')', - 'g', -) + 'g' +); // The same sides as React style keys, and as properties in a stylesheet. const STYLE = - /\b(?:marginLeft|marginRight|paddingLeft|paddingRight|borderLeft\w*|borderRight\w*|border(?:Top|Bottom)(?:Left|Right)Radius|left|right)\s*:|\b(?:textAlign|float)\s*:\s*['"](?:left|right)['"]/g + /\b(?:marginLeft|marginRight|paddingLeft|paddingRight|borderLeft\w*|borderRight\w*|border(?:Top|Bottom)(?:Left|Right)Radius|left|right)\s*:|\b(?:textAlign|float)\s*:\s*['"](?:left|right)['"]/g; const CSS = - /(? { - for (let i = pos; i < end; i++) if (chars[i] !== '\n') chars[i] = ' ' - } + for (let i = pos; i < end; i++) if (chars[i] !== '\n') chars[i] = ' '; + }; if (file.endsWith('.css')) { - for (const m of source.matchAll(/\/\*[\s\S]*?\*\//g)) blank(m.index, m.index + m[0].length) - return chars.join('') + for (const m of source.matchAll(/\/\*[\s\S]*?\*\//g)) blank(m.index, m.index + m[0].length); + return chars.join(''); } - const kind = file.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS - const sf = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true, kind) - const seen = new Set() + const kind = file.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS; + const sf = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true, kind); + const seen = new Set(); const take = (ranges) => { for (const r of ranges ?? []) { - if (seen.has(r.pos)) continue - seen.add(r.pos) - blank(r.pos, r.end) + if (seen.has(r.pos)) continue; + seen.add(r.pos); + blank(r.pos, r.end); } - } + }; const visit = (node) => { - take(ts.getLeadingCommentRanges(source, node.pos)) - take(ts.getTrailingCommentRanges(source, node.end)) + take(ts.getLeadingCommentRanges(source, node.pos)); + take(ts.getTrailingCommentRanges(source, node.end)); // JSX text holds no comments, and asking for them there would read `//` in a URL as one. - if (!ts.isJsxText(node)) for (const child of node.getChildren(sf)) visit(child) - } - visit(sf) - take(ts.getLeadingCommentRanges(source, sf.endOfFileToken.pos)) - return chars.join('') + if (!ts.isJsxText(node)) for (const child of node.getChildren(sf)) visit(child); + }; + visit(sf); + take(ts.getLeadingCommentRanges(source, sf.endOfFileToken.pos)); + return chars.join(''); } /** Whether the line carries the marker inside a comment; in a string or in code it is no marker. */ function markedInComment(line, code) { for (let at = line.indexOf(DISABLE); at !== -1; at = line.indexOf(DISABLE, at + 1)) { - if (!code.slice(at, at + DISABLE.length).trim()) return true + if (!code.slice(at, at + DISABLE.length).trim()) return true; } - return false + return false; } /** @@ -77,45 +86,47 @@ function markedInComment(line, code) { * marked as physical on purpose, and how many lines carry that mark. */ export function inspect(source, file) { - const isCss = file.endsWith('.css') - const uses = [] - let markers = 0 - const lines = source.split('\n') - withoutComments(source, file).split('\n').forEach((code, i) => { - if (markedInComment(lines[i], code)) { - markers++ - return - } - const patterns = isCss ? [CSS] : [CLASS, STYLE] - for (const re of patterns) for (const m of code.match(re) ?? []) uses.push(`${i + 1}: ${m.trim()}`) - }) - return { uses, markers } + const isCss = file.endsWith('.css'); + const uses = []; + let markers = 0; + const lines = source.split('\n'); + withoutComments(source, file) + .split('\n') + .forEach((code, i) => { + if (markedInComment(lines[i], code)) { + markers++; + return; + } + const patterns = isCss ? [CSS] : [CLASS, STYLE]; + for (const re of patterns) for (const m of code.match(re) ?? []) uses.push(`${i + 1}: ${m.trim()}`); + }); + return { uses, markers }; } /** Every physical use in one file, as `line: match`. */ export function physicalUses(source, file) { - return inspect(source, file).uses + return inspect(source, file).uses; } /** The physical uses and the disable markers per file under root/src, keyed by the path from src/. */ export function scan(root) { - const counts = {} - const listed = {} - const markers = {} - const accept = (key) => /\.(?:tsx?|css)$/.test(key) && !TEST_FILE.test(key) + const counts = {}; + const listed = {}; + const markers = {}; + const accept = (key) => /\.(?:tsx?|css)$/.test(key) && !TEST_FILE.test(key); for (const path of listFiles(root, ['src'], accept)) { - const found = inspect(readText(path), path) - const key = toKey(join(root, 'src'), path) + const found = inspect(readText(path), path); + const key = toKey(join(root, 'src'), path); if (found.uses.length) { - counts[key] = found.uses.length - listed[key] = found.uses + counts[key] = found.uses.length; + listed[key] = found.uses; } - if (found.markers) markers[key] = found.markers + if (found.markers) markers[key] = found.markers; } - return { counts, listed, markers } + return { counts, listed, markers }; } -const sum = (counts) => Object.values(counts).reduce((a, b) => a + b, 0) +const sum = (counts) => Object.values(counts).reduce((a, b) => a + b, 0); /** * Runs the check against both baselines: rtl-baseline.json for the physical @@ -132,51 +143,51 @@ export function check({ log = console.log, error = console.error, }) { - const { counts, listed, markers } = scan(root) - let baseline = readBaseline(baselinePath, countMap) - let allowedMarkers = readBaseline(disableBaselinePath, countMap) + const { counts, listed, markers } = scan(root); + let baseline = readBaseline(baselinePath, countMap); + let allowedMarkers = readBaseline(disableBaselinePath, countMap); if (list) { for (const [file, uses] of Object.entries(listed).sort()) { - log(`${file} (${uses.length}, baseline ${baseline[file] ?? 0})`) - for (const use of uses) log(` ${use}`) + log(`${file} (${uses.length}, baseline ${baseline[file] ?? 0})`); + for (const use of uses) log(` ${use}`); } } if (update) { - baseline = lowerCounts(baseline, counts) - allowedMarkers = lowerCounts(allowedMarkers, markers) - writeBaseline(baselinePath, baseline) - writeBaseline(disableBaselinePath, allowedMarkers) + baseline = lowerCounts(baseline, counts); + allowedMarkers = lowerCounts(allowedMarkers, markers); + writeBaseline(baselinePath, baseline); + writeBaseline(disableBaselinePath, allowedMarkers); } - const grown = Object.entries(counts).filter(([file, n]) => n > (baseline[file] ?? 0)) - const marked = Object.entries(markers).filter(([file, n]) => n > (allowedMarkers[file] ?? 0)) + const grown = Object.entries(counts).filter(([file, n]) => n > (baseline[file] ?? 0)); + const marked = Object.entries(markers).filter(([file, n]) => n > (allowedMarkers[file] ?? 0)); const lowerable = Object.entries(baseline).filter(([file, n]) => (counts[file] ?? 0) < n).length + - Object.entries(allowedMarkers).filter(([file, n]) => (markers[file] ?? 0) < n).length + Object.entries(allowedMarkers).filter(([file, n]) => (markers[file] ?? 0) < n).length; for (const [file, n] of grown) { error( `FAIL ${file}: ${n} physical side(s), baseline ${baseline[file] ?? 0}. ` + 'Use ms-/me-/ps-/pe-/start-/end-/text-start/text-end/border-s/border-e/rounded-s/rounded-e ' + 'or marginInlineStart/paddingInlineEnd/insetInlineStart/textAlign: start. ' + - `If the side is geometry, mark the line with ${DISABLE} in a comment.`, - ) + `If the side is geometry, mark the line with ${DISABLE} in a comment.` + ); } for (const [file, n] of marked) { error( `FAIL ${file}: ${n} line(s) marked ${DISABLE}, scripts/rtl-disable-baseline.json allows ${allowedMarkers[file] ?? 0}. ` + 'Use the logical form where the side follows the text. A side that is geometry on purpose ' + - '(a map, a measured position, a time axis) needs a reviewer to raise the entry by hand.', - ) + '(a map, a measured position, a time axis) needs a reviewer to raise the entry by hand.' + ); } if (lowerable && !update) { - log(`${lowerable} entr(ies) are above what the files hold now: run with --update to lower them.`) + log(`${lowerable} entr(ies) are above what the files hold now: run with --update to lower them.`); } log( `rtl: ${sum(counts)} physical side(s) in ${Object.keys(counts).length} file(s), baseline allows ${sum(baseline)}; ` + - `${sum(markers)} line(s) marked ${DISABLE}, baseline allows ${sum(allowedMarkers)}`, - ) - return grown.length || marked.length ? 1 : 0 + `${sum(markers)} line(s) marked ${DISABLE}, baseline allows ${sum(allowedMarkers)}` + ); + return grown.length || marked.length ? 1 : 0; } diff --git a/client/scripts/lib/size.mjs b/client/scripts/lib/size.mjs index dd4af41e9..cbd878195 100644 --- a/client/scripts/lib/size.mjs +++ b/client/scripts/lib/size.mjs @@ -7,10 +7,19 @@ * lines, and the trip planner page already held a 3760 character one; WIDTH is * the printWidth in .prettierrc, so formatted code counts what it shows. */ -import { join } from 'node:path' -import { countMap, listFiles, lowerCounts, readBaseline, readText, TEST_FILE, toKey, writeBaseline } from './ratchet.mjs' +import { join } from 'node:path'; +import { + countMap, + listFiles, + lowerCounts, + readBaseline, + readText, + TEST_FILE, + toKey, + writeBaseline, +} from './ratchet.mjs'; -export const WIDTH = 120 +export const WIDTH = 120; /** * The kinds of file the check covers, each with the size a file without a @@ -18,61 +27,78 @@ export const WIDTH = 120 * it covers and holds fixtures, but past LIMIT it is a god file too. */ export const GROUPS = [ - { name: 'test', limit: 2000, accepts: (key) => /\.tsx?$/.test(key) && (key.startsWith('tests/') || TEST_FILE.test(key)) }, - { name: 'source', limit: 1000, accepts: (key) => key.startsWith('src/') && /\.tsx?$/.test(key) && !TEST_FILE.test(key) }, + { + name: 'test', + limit: 2000, + accepts: (key) => /\.tsx?$/.test(key) && (key.startsWith('tests/') || TEST_FILE.test(key)), + }, + { + name: 'source', + limit: 1000, + accepts: (key) => key.startsWith('src/') && /\.tsx?$/.test(key) && !TEST_FILE.test(key), + }, { name: 'stylesheet', limit: 1000, accepts: (key) => key.startsWith('src/') && key.endsWith('.css') }, -] +]; -export const DIRS = ['src', 'tests'] +export const DIRS = ['src', 'tests']; export function groupOf(key) { - return GROUPS.find((group) => group.accepts(key)) ?? null + return GROUPS.find((group) => group.accepts(key)) ?? null; } /** The size of a text as wrapped at WIDTH; a final newline does not start another line. */ export function sizeOf(text) { - if (text === '') return 0 - const lines = text.replace(/\r\n?/g, '\n').split('\n') - if (text.endsWith('\n')) lines.pop() - return lines.reduce((sum, line) => sum + Math.max(1, Math.ceil(line.length / WIDTH)), 0) + if (text === '') return 0; + const lines = text.replace(/\r\n?/g, '\n').split('\n'); + if (text.endsWith('\n')) lines.pop(); + return lines.reduce((sum, line) => sum + Math.max(1, Math.ceil(line.length / WIDTH)), 0); } /** Every covered file under root with its size, keyed by its path from root. */ export function measure(root) { - const sizes = {} - for (const path of listFiles(root, DIRS, (key) => groupOf(key) !== null)) sizes[toKey(root, path)] = sizeOf(readText(path)) - return sizes + const sizes = {}; + for (const path of listFiles(root, DIRS, (key) => groupOf(key) !== null)) + sizes[toKey(root, path)] = sizeOf(readText(path)); + return sizes; } -const limitOf = (key) => groupOf(key)?.limit ?? 0 +const limitOf = (key) => groupOf(key)?.limit ?? 0; /** * Runs the check against the baseline at baselinePath. With update, the * baseline is lowered first. Returns the exit code. */ -export function check({ root, baselinePath = join(root, 'scripts/size-baseline.json'), update = false, log = console.log, error = console.error }) { - const sizes = measure(root) - let baseline = readBaseline(baselinePath, countMap) +export function check({ + root, + baselinePath = join(root, 'scripts/size-baseline.json'), + update = false, + log = console.log, + error = console.error, +}) { + const sizes = measure(root); + let baseline = readBaseline(baselinePath, countMap); if (update) { - baseline = lowerCounts(baseline, sizes, limitOf) - writeBaseline(baselinePath, baseline) + baseline = lowerCounts(baseline, sizes, limitOf); + writeBaseline(baselinePath, baseline); } - const grown = Object.entries(sizes).filter(([key, n]) => n > Math.max(baseline[key] ?? 0, limitOf(key))) - const lowerable = Object.entries(baseline).filter(([key, n]) => (sizes[key] ?? 0) < n) + const grown = Object.entries(sizes).filter(([key, n]) => n > Math.max(baseline[key] ?? 0, limitOf(key))); + const lowerable = Object.entries(baseline).filter(([key, n]) => (sizes[key] ?? 0) < n); for (const [key, n] of grown) { - const entry = baseline[key] + const entry = baseline[key]; error( `FAIL ${key}: ${n} lines (wrapped at ${WIDTH} columns), ` + (entry ? `its baseline is ${entry}. ` : `the limit for a ${groupOf(key).name} file is ${limitOf(key)}. `) + - 'Move a concern into a module of its own instead of growing the file or packing its lines.', - ) + 'Move a concern into a module of its own instead of growing the file or packing its lines.' + ); } if (lowerable.length && !update) { - log(`${lowerable.length} file(s) are smaller than their baseline now: run with --update to lower it.`) + log(`${lowerable.length} file(s) are smaller than their baseline now: run with --update to lower it.`); } - log(`size: ${Object.keys(sizes).length} file(s), ${Object.keys(baseline).length} past their limit held at their baseline`) - return grown.length ? 1 : 0 + log( + `size: ${Object.keys(sizes).length} file(s), ${Object.keys(baseline).length} past their limit held at their baseline` + ); + return grown.length ? 1 : 0; } diff --git a/client/scripts/lib/skips.mjs b/client/scripts/lib/skips.mjs index 118c1227f..3998a5334 100644 --- a/client/scripts/lib/skips.mjs +++ b/client/scripts/lib/skips.mjs @@ -1,31 +1,39 @@ /* * The matching and the comparison behind lint:skips (scripts/skip-lint.mjs). */ -import { join } from 'node:path' -import ts from 'typescript' -import { countMap, listFiles, lowerCounts, readBaseline, readText, TEST_FILE, toKey, writeBaseline } from './ratchet.mjs' +import { join } from 'node:path'; +import ts from 'typescript'; +import { + countMap, + listFiles, + lowerCounts, + readBaseline, + readText, + TEST_FILE, + toKey, + writeBaseline, +} from './ratchet.mjs'; /** The test functions of vitest and Playwright a modifier can hang off. */ -const RUNNERS = new Set(['it', 'test', 'describe', 'suite', 'bench']) +const RUNNERS = new Set(['it', 'test', 'describe', 'suite', 'bench']); /** The jasmine-style shorthands vitest also knows. */ -const SHORTHAND = new Set(['xit', 'xtest', 'xdescribe']) +const SHORTHAND = new Set(['xit', 'xtest', 'xdescribe']); -export const DIRS = ['src', 'tests', 'e2e'] +export const DIRS = ['src', 'tests', 'e2e']; -const accepts = (key) => - /\.tsx?$/.test(key) && (key.startsWith('src/') ? TEST_FILE.test(key) : true) +const accepts = (key) => /\.tsx?$/.test(key) && (key.startsWith('src/') ? TEST_FILE.test(key) : true); /** The identifier a chain like test.describe.skip.each starts from. */ function rootOf(node) { - while (ts.isPropertyAccessExpression(node) || ts.isCallExpression(node)) node = node.expression - return ts.isIdentifier(node) ? node.text : null + while (ts.isPropertyAccessExpression(node) || ts.isCallExpression(node)) node = node.expression; + return ts.isIdentifier(node) ? node.text : null; } /** Playwright specs live under e2e/; everything else runs under vitest. */ -const isPlaywright = (file) => file.startsWith('e2e/') +const isPlaywright = (file) => file.startsWith('e2e/'); -const isTitle = (node) => Boolean(node) && (ts.isStringLiteralLike(node) || ts.isTemplateExpression(node)) -const isFunction = (node) => Boolean(node) && (ts.isArrowFunction(node) || ts.isFunctionExpression(node)) +const isTitle = (node) => Boolean(node) && (ts.isStringLiteralLike(node) || ts.isTemplateExpression(node)); +const isFunction = (node) => Boolean(node) && (ts.isArrowFunction(node) || ts.isFunctionExpression(node)); /** * Whether a skip or fixme call switches tests off for good. Under vitest it @@ -37,9 +45,9 @@ const isFunction = (node) => Boolean(node) && (ts.isArrowFunction(node) || ts.is * the literal true. */ function switchesOff(call, file) { - const [first, second] = call.arguments - if (!isPlaywright(file) || !first || isTitle(first) || isFunction(second)) return true - return first.kind === ts.SyntaxKind.TrueKeyword + const [first, second] = call.arguments; + if (!isPlaywright(file) || !first || isTitle(first) || isFunction(second)) return true; + return first.kind === ts.SyntaxKind.TrueKeyword; } /** @@ -55,41 +63,42 @@ function switchesOff(call, file) { * file is the path from the client root: it decides vitest or Playwright. */ export function modifiers(source, file) { - const kind = file.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS - const sf = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true, kind) - const skipped = [] - const only = [] - const at = (node) => `${sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1}: ${node.getText(sf).slice(0, 60)}` + const kind = file.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS; + const sf = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true, kind); + const skipped = []; + const only = []; + const at = (node) => + `${sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1}: ${node.getText(sf).slice(0, 60)}`; const visit = (node) => { if (ts.isPropertyAccessExpression(node) && RUNNERS.has(rootOf(node.expression) ?? '')) { - const name = node.name.text - if (name === 'only') only.push(at(node)) - else if (name === 'todo') skipped.push(at(node)) + const name = node.name.text; + if (name === 'only') only.push(at(node)); + else if (name === 'todo') skipped.push(at(node)); else if (name === 'skip' || name === 'fixme') { - const call = ts.isCallExpression(node.parent) && node.parent.expression === node ? node.parent : null + const call = ts.isCallExpression(node.parent) && node.parent.expression === node ? node.parent : null; // test.skip.each(table)(...) declares skipped tests without being called directly. - if (!call || switchesOff(call, file)) skipped.push(at(node)) + if (!call || switchesOff(call, file)) skipped.push(at(node)); } } else if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && SHORTHAND.has(node.expression.text)) { - skipped.push(at(node.expression)) + skipped.push(at(node.expression)); } - ts.forEachChild(node, visit) - } - visit(sf) - return { skipped, only } + ts.forEachChild(node, visit); + }; + visit(sf); + return { skipped, only }; } /** The skipped and focused tests per file under src/, tests/ and e2e/, keyed by the path from root. */ export function scan(root) { - const skipped = {} - const only = {} + const skipped = {}; + const only = {}; for (const path of listFiles(root, DIRS, accepts)) { - const key = toKey(root, path) - const found = modifiers(readText(path), key) - if (found.skipped.length) skipped[key] = found.skipped - if (found.only.length) only[key] = found.only + const key = toKey(root, path); + const found = modifiers(readText(path), key); + if (found.skipped.length) skipped[key] = found.skipped; + if (found.only.length) only[key] = found.only; } - return { skipped, only } + return { skipped, only }; } /** @@ -103,32 +112,33 @@ export function check({ log = console.log, error = console.error, }) { - const { skipped, only } = scan(root) - const counts = Object.fromEntries(Object.entries(skipped).map(([key, list]) => [key, list.length])) - let baseline = readBaseline(baselinePath, countMap) + const { skipped, only } = scan(root); + const counts = Object.fromEntries(Object.entries(skipped).map(([key, list]) => [key, list.length])); + let baseline = readBaseline(baselinePath, countMap); if (update) { - baseline = lowerCounts(baseline, counts) - writeBaseline(baselinePath, baseline) + baseline = lowerCounts(baseline, counts); + writeBaseline(baselinePath, baseline); } for (const [key, list] of Object.entries(only)) { - error(`FAIL ${key}: .only runs this test alone and drops every other one in the file:`) - for (const line of list) error(` ${line}`) + error(`FAIL ${key}: .only runs this test alone and drops every other one in the file:`); + for (const line of list) error(` ${line}`); } - const grown = Object.entries(counts).filter(([key, n]) => n > (baseline[key] ?? 0)) + const grown = Object.entries(counts).filter(([key, n]) => n > (baseline[key] ?? 0)); for (const [key, n] of grown) { - error(`FAIL ${key}: ${n} skipped or todo test(s), baseline ${baseline[key] ?? 0}:`) - for (const line of skipped[key]) error(` ${line}`) + error(`FAIL ${key}: ${n} skipped or todo test(s), baseline ${baseline[key] ?? 0}:`); + for (const line of skipped[key]) error(` ${line}`); } if (grown.length) { - error( - 'Fix the test, delete it if its feature is gone, or use skipIf/runIf when it depends on the environment.', - ) + error('Fix the test, delete it if its feature is gone, or use skipIf/runIf when it depends on the environment.'); } - const lowerable = Object.entries(baseline).filter(([key, n]) => (counts[key] ?? 0) < n) - if (lowerable.length && !update) log(`${lowerable.length} file(s) skip fewer tests than the baseline: run with --update to lower it.`) - const sum = (map) => Object.values(map).reduce((a, b) => a + b, 0) - log(`skips: ${sum(counts)} skipped or todo test(s), baseline allows ${sum(baseline)}; ${Object.keys(only).length} file(s) with .only`) - return grown.length || Object.keys(only).length ? 1 : 0 + const lowerable = Object.entries(baseline).filter(([key, n]) => (counts[key] ?? 0) < n); + if (lowerable.length && !update) + log(`${lowerable.length} file(s) skip fewer tests than the baseline: run with --update to lower it.`); + const sum = (map) => Object.values(map).reduce((a, b) => a + b, 0); + log( + `skips: ${sum(counts)} skipped or todo test(s), baseline allows ${sum(baseline)}; ${Object.keys(only).length} file(s) with .only` + ); + return grown.length || Object.keys(only).length ? 1 : 0; } diff --git a/client/scripts/offline-lint.mjs b/client/scripts/offline-lint.mjs index 635dda592..14c7e02fa 100644 --- a/client/scripts/offline-lint.mjs +++ b/client/scripts/offline-lint.mjs @@ -15,9 +15,11 @@ * npm run lint:offline -- --update take the files that stopped off the list; * it never adds one */ -import { fileURLToPath } from 'node:url' -import { check } from './lib/offline.mjs' -import { runCli } from './lib/ratchet.mjs' +import { fileURLToPath } from 'node:url'; +import { check } from './lib/offline.mjs'; +import { runCli } from './lib/ratchet.mjs'; -const root = fileURLToPath(new URL('..', import.meta.url)) -await runCli('lint:offline', (args) => check({ root, update: args.includes('--update'), list: args.includes('--list') })) +const root = fileURLToPath(new URL('..', import.meta.url)); +await runCli('lint:offline', (args) => + check({ root, update: args.includes('--update'), list: args.includes('--list') }) +); diff --git a/client/scripts/rtl-lint.mjs b/client/scripts/rtl-lint.mjs index 28bd4046a..9613e20d8 100644 --- a/client/scripts/rtl-lint.mjs +++ b/client/scripts/rtl-lint.mjs @@ -26,9 +26,9 @@ * * The matching lives in scripts/lib/rtl.mjs. */ -import { fileURLToPath } from 'node:url' -import { runCli } from './lib/ratchet.mjs' -import { check } from './lib/rtl.mjs' +import { fileURLToPath } from 'node:url'; +import { runCli } from './lib/ratchet.mjs'; +import { check } from './lib/rtl.mjs'; -const root = fileURLToPath(new URL('..', import.meta.url)) -await runCli('lint:rtl', (args) => check({ root, update: args.includes('--update'), list: args.includes('--list') })) +const root = fileURLToPath(new URL('..', import.meta.url)); +await runCli('lint:rtl', (args) => check({ root, update: args.includes('--update'), list: args.includes('--list') })); diff --git a/client/scripts/size-lint.mjs b/client/scripts/size-lint.mjs index 26e4ee179..6656299e9 100644 --- a/client/scripts/size-lint.mjs +++ b/client/scripts/size-lint.mjs @@ -19,9 +19,9 @@ * * The measuring lives in scripts/lib/size.mjs. */ -import { fileURLToPath } from 'node:url' -import { runCli } from './lib/ratchet.mjs' -import { check } from './lib/size.mjs' +import { fileURLToPath } from 'node:url'; +import { runCli } from './lib/ratchet.mjs'; +import { check } from './lib/size.mjs'; -const root = fileURLToPath(new URL('..', import.meta.url)) -await runCli('lint:size', (args) => check({ root, update: args.includes('--update') })) +const root = fileURLToPath(new URL('..', import.meta.url)); +await runCli('lint:size', (args) => check({ root, update: args.includes('--update') })); diff --git a/client/scripts/skip-lint.mjs b/client/scripts/skip-lint.mjs index 5a8341967..7b6ec13d9 100644 --- a/client/scripts/skip-lint.mjs +++ b/client/scripts/skip-lint.mjs @@ -17,9 +17,9 @@ * npm run lint:skips -- --update lower the baseline to what the files hold now; * it never raises an entry */ -import { fileURLToPath } from 'node:url' -import { runCli } from './lib/ratchet.mjs' -import { check } from './lib/skips.mjs' +import { fileURLToPath } from 'node:url'; +import { runCli } from './lib/ratchet.mjs'; +import { check } from './lib/skips.mjs'; -const root = fileURLToPath(new URL('..', import.meta.url)) -await runCli('lint:skips', (args) => check({ root, update: args.includes('--update') })) +const root = fileURLToPath(new URL('..', import.meta.url)); +await runCli('lint:skips', (args) => check({ root, update: args.includes('--update') }));