mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
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:
@@ -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') }));
|
||||
|
||||
@@ -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') })
|
||||
);
|
||||
|
||||
@@ -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') })
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) })),
|
||||
}))
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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') })
|
||||
);
|
||||
|
||||
@@ -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') }));
|
||||
|
||||
@@ -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') }));
|
||||
|
||||
@@ -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') }));
|
||||
|
||||
Reference in New Issue
Block a user