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.
This commit is contained in:
Maurice
2026-10-08 12:24:37 +02:00
parent 17b72ff778
commit 0344a86aec
16 changed files with 473 additions and 398 deletions
+5 -5
View File
@@ -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') }));
+7 -5
View File
@@ -18,9 +18,11 @@
* To format a file: npx prettier --write <file>. 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') })
);
+7 -5
View File
@@ -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') })
);
+47 -45
View File
@@ -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<LintResult[]>}
*/
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 <file> to see each one.',
)
'Run npx eslint <file> 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;
}
+35 -26
View File
@@ -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;
}
+36 -29
View File
@@ -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) })),
}))
}));
}
+31 -28
View File
@@ -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;
}
+27 -27
View File
@@ -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;
}
+50 -49
View File
@@ -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/<dir> 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;
}
}
+85 -74
View File
@@ -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 =
/(?<![\w-])(?:margin-left|margin-right|padding-left|padding-right|border-left[\w-]*|border-right[\w-]*|border-(?:top|bottom)-(?:left|right)-radius|left|right)\s*:|(?<![\w-])(?:text-align|float)\s*:\s*(?:left|right)\b/g
/(?<![\w-])(?:margin-left|margin-right|padding-left|padding-right|border-left[\w-]*|border-right[\w-]*|border-(?:top|bottom)-(?:left|right)-radius|left|right)\s*:|(?<![\w-])(?:text-align|float)\s*:\s*(?:left|right)\b/g;
export const DISABLE = 'rtl-lint-disable'
export const DISABLE = 'rtl-lint-disable';
/**
* The source with its comments blanked out, so prose like "top right" or
@@ -35,41 +44,41 @@ export const DISABLE = 'rtl-lint-disable'
* (accept="image/*", a URL); a stylesheet only has block comments.
*/
function withoutComments(source, file) {
const chars = source.split('')
const chars = source.split('');
const blank = (pos, end) => {
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;
}
+55 -29
View File
@@ -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;
}
+66 -56
View File
@@ -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;
}
+7 -5
View File
@@ -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') })
);
+5 -5
View File
@@ -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') }));
+5 -5
View File
@@ -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') }));
+5 -5
View File
@@ -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') }));