mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
f458278e88
lint:warnings failed with the total and the files holding the most of the rule, which are old ones the baseline allows; the file that tipped the count was never named, so finding it meant linting by hand. When a rule grows, the ratchet now lints the files that differ from the base twice, as they are and as they were there, and lists every one holding more of the rule, line and message. The base is TREK_LINT_BASE (the CI fetches the pushed-over commit on a push and the target branch on a pull request) or origin/dev; without it the report stays as it was.
183 lines
8.5 KiB
JavaScript
183 lines
8.5 KiB
JavaScript
import { ESLint } from 'eslint';
|
|
import assert from 'node:assert/strict';
|
|
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { afterEach, describe, it } from 'node:test';
|
|
import { areaCounts, check, inlineConfigsOf, lintWith, newWarningsOf, tally } from './eslint-warnings.mjs';
|
|
import { RatchetError } from './ratchet.mjs';
|
|
import { ratchetTree } from './tree-fixture.mjs';
|
|
|
|
let tree;
|
|
afterEach(() => tree?.remove());
|
|
|
|
const BASELINE = 'scripts/eslint-baseline.json';
|
|
const empty = { src: {}, tests: {}, suppressed: {} };
|
|
const warn = (ruleId, message = 'm') => ({ ruleId, severity: 1, message });
|
|
|
|
function result(root, key, messages, suppressed = []) {
|
|
return {
|
|
filePath: join(root, key),
|
|
messages: messages.map((m) => ({ message: 'm', ...m })),
|
|
suppressedMessages: suppressed.map((m) => ({ message: 'm', ...m })),
|
|
};
|
|
}
|
|
|
|
async function run(baseline, results, update = false) {
|
|
tree = ratchetTree({ [BASELINE]: JSON.stringify(baseline) });
|
|
return check({
|
|
root: tree.root,
|
|
baselinePath: tree.path(BASELINE),
|
|
update,
|
|
lint: async (root) => results(root),
|
|
...tree.out,
|
|
});
|
|
}
|
|
|
|
describe('scripts/lib/eslint-warnings', () => {
|
|
it('counts warnings per rule for app code, tests and suppressed messages', () => {
|
|
const root = join('/r');
|
|
const { counts, files, errors } = tally(
|
|
[
|
|
result(
|
|
root,
|
|
'src/a.ts',
|
|
[warn('no-empty'), warn('no-empty'), warn(null, 'Unused eslint-disable directive')],
|
|
[warn('no-console')],
|
|
),
|
|
result(root, 'src/a.test.ts', [warn('@typescript-eslint/no-explicit-any')]),
|
|
result(root, 'tests/unit/b.ts', [warn('@typescript-eslint/no-explicit-any')]),
|
|
result(root, 'src/c.ts', [warn(null, 'odd')]),
|
|
],
|
|
root,
|
|
);
|
|
assert.deepEqual(counts, {
|
|
src: { 'no-empty': 2, 'unused-disable-directive': 1, '(no rule)': 1 },
|
|
tests: { '@typescript-eslint/no-explicit-any': 2 },
|
|
suppressed: { 'no-console': 1 },
|
|
});
|
|
assert.deepEqual(files.src['no-empty'], { 'src/a.ts': 2 });
|
|
assert.deepEqual(errors, []);
|
|
});
|
|
|
|
it('passes when every count sits at its entry', async () => {
|
|
assert.equal(await run({ ...empty, src: { 'no-empty': 1 } }, (root) => [result(root, 'src/a.ts', [warn('no-empty')])]), 0);
|
|
assert.match(tree.log.join('\n'), /eslint: 0 error\(s\); warnings src 1\/1, tests 0\/0, suppressed 0\/0/);
|
|
});
|
|
|
|
it('fails a count that fell below its entry until --update lowers it', async () => {
|
|
const baseline = { ...empty, src: { 'no-empty': 2 }, suppressed: { 'no-console': 1 } };
|
|
const results = (root) => [result(root, 'src/a.ts', [warn('no-empty')])];
|
|
assert.equal(await run(baseline, results), 1);
|
|
const out = tree.error.join('\n');
|
|
assert.match(out, /src: no-empty is held at 2 in scripts\/eslint-baseline\.json, but there are 1 now/);
|
|
assert.match(out, /suppressed: no-console is held at 1 .*but there are 0 now/);
|
|
assert.match(out, /npm run lint:warnings -- --update/);
|
|
tree.remove();
|
|
assert.equal(await run(baseline, results, true), 0);
|
|
assert.deepEqual(JSON.parse(readFileSync(tree.path(BASELINE), 'utf8')), {
|
|
src: { 'no-empty': 1 },
|
|
suppressed: {},
|
|
tests: {},
|
|
});
|
|
});
|
|
|
|
it('fails a new warning, names the files behind it, and keeps app code off the test headroom', async () => {
|
|
const code = await run({ ...empty, src: { 'no-empty': 1 }, tests: { 'no-empty': 5 } }, (root) => [
|
|
result(root, 'src/a.ts', [warn('no-empty')]),
|
|
result(root, 'src/b.ts', [warn('no-empty')]),
|
|
]);
|
|
assert.equal(code, 1);
|
|
const out = tree.error.join('\n');
|
|
assert.match(out, /src: 2 no-empty warning\(s\), baseline 1\. Most in: src\/a\.ts \(1\), src\/b\.ts \(1\)/);
|
|
assert.match(out, /Fix the new warning rather than silencing it/);
|
|
});
|
|
|
|
it('names the file and line behind a grown count, measured against the base, and nothing without one', async () => {
|
|
const root = mkdtempSync(join(tmpdir(), 'lint-new-'));
|
|
try {
|
|
mkdirSync(join(root, 'src'));
|
|
writeFileSync(join(root, 'src/new.ts'), 'two');
|
|
writeFileSync(join(root, 'src/old.ts'), 'one');
|
|
// new.ts held none of the rule on the base, old.ts already held its one
|
|
const git = (_root, args) => (args[0] === 'diff' ? 'src/new.ts\nsrc/old.ts\n' : args[1].endsWith('old.ts') ? 'one' : 'none');
|
|
const lintText = async (text) => [
|
|
{ messages: Array.from({ length: { two: 2, one: 1, none: 0 }[text] }, (_, i) => ({ ...warn('no-empty', `w${i}`), line: i + 1 })) },
|
|
];
|
|
const keys = ['src/new.ts', 'src/old.ts', 'src/untouched.ts'];
|
|
const lines = await newWarningsOf({ root, rule: 'no-empty', area: 'src', keys, lintText, ref: 'base', git });
|
|
assert.deepEqual(lines, ['src/new.ts:1 w0', 'src/new.ts:2 w1']);
|
|
assert.equal(await newWarningsOf({ root, rule: 'no-empty', area: 'src', keys, lintText, git: () => null }), null);
|
|
} finally {
|
|
rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('counts a message an eslint-disable comment silenced', async () => {
|
|
assert.equal(await run(empty, (root) => [result(root, 'src/a.ts', [], [warn('no-console')])]), 1);
|
|
assert.match(tree.error.join('\n'), /suppressed: 1 no-console warning/);
|
|
});
|
|
|
|
it('fails on any error, a parse failure and an inline rule config included', async () => {
|
|
const code = await run(empty, (root) => [
|
|
result(root, 'src/a.ts', [{ ruleId: null, severity: 2, fatal: true, message: 'Parsing error', line: 3, column: 1 }]),
|
|
result(root, 'src/b.ts', [{ ruleId: 'no-undef', severity: 2, message: 'x is not defined' }]),
|
|
{ ...result(root, 'src/c.ts', []), inlineConfigs: ['1:1 /* eslint no-empty: off */'] },
|
|
]);
|
|
assert.equal(code, 1);
|
|
const out = tree.error.join('\n');
|
|
assert.match(out, /src\/a\.ts:3:1 {2}Parsing error/);
|
|
assert.match(out, /src\/b\.ts:0:0 {2}x is not defined \(no-undef\)/);
|
|
assert.match(out, /src\/c\.ts:1:1 {2}\/\* eslint no-empty: off \*\/ {2}configures a rule inline/);
|
|
});
|
|
|
|
it('stops on a baseline without all three areas, with a stray one, or without a lint function', async () => {
|
|
assert.match(areaCounts({ src: {}, tests: {} }), /"suppressed" area is missing/);
|
|
assert.match(areaCounts({ ...empty, other: {} }), /"other" is not one of/);
|
|
assert.match(areaCounts({ ...empty, src: { a: -1 } }), /in "src"/);
|
|
assert.match(areaCounts([]), /not an object/);
|
|
await assert.rejects(run({ src: {} }, () => []), RatchetError);
|
|
tree.remove();
|
|
tree = ratchetTree({ [BASELINE]: JSON.stringify(empty) });
|
|
await assert.rejects(check({ root: tree.root, ...tree.out }), /without a lint function/);
|
|
});
|
|
|
|
it('finds the block comments that configure a rule, and nothing in strings, templates or regexes', () => {
|
|
const source = [
|
|
'/* eslint @typescript-eslint/no-explicit-any: off */',
|
|
'const a = "/* eslint no-empty: off */";',
|
|
'const b = `${a} /* eslint no-empty: off */`;',
|
|
'/* eslint-disable no-empty */',
|
|
'/*eslint no-empty:0*/ const c = 1;',
|
|
'const r = /\\/\\* eslint no-empty/;',
|
|
'// eslint no-empty: off',
|
|
'/** eslint is the linter */',
|
|
].join('\n');
|
|
assert.deepEqual(inlineConfigsOf(source, 'src/a.ts'), [
|
|
'1:1 /* eslint @typescript-eslint/no-explicit-any: off */',
|
|
'5:1 /*eslint no-empty:0*/',
|
|
]);
|
|
assert.equal(inlineConfigsOf('export const a = 1 /* eslint no-empty: off */\n', 'src/a.mts').length, 1);
|
|
assert.deepEqual(inlineConfigsOf('const a = <div>{/* eslint no-empty: 1 */}</div>;\n', 'src/a.tsx').length, 1);
|
|
assert.deepEqual(inlineConfigsOf('const a = 1;\n', 'src/a.jsx'), []);
|
|
assert.deepEqual(inlineConfigsOf('export const a = 1\n', 'src/a.js'), []);
|
|
});
|
|
|
|
it('lints with the ESLint class it is handed and reads the inline rule configs of every file', async () => {
|
|
tree = ratchetTree({
|
|
'eslint.config.mjs': "export default [{ files: ['**/*.js'], rules: { 'no-empty': 'warn' } }];\n",
|
|
'src/a.js': '/* eslint no-empty: off */\nif (globalThis.x) {}\n',
|
|
'src/b.js': 'if (globalThis.x) {}\n',
|
|
});
|
|
const results = await lintWith(ESLint)(tree.root);
|
|
const byKey = Object.fromEntries(results.map((r) => [r.filePath.split('\\').join('/').split('/src/')[1], r]));
|
|
assert.deepEqual(byKey['a.js'].messages, []);
|
|
assert.deepEqual(byKey['a.js'].inlineConfigs, ['1:1 /* eslint no-empty: off */']);
|
|
assert.deepEqual(
|
|
byKey['b.js'].messages.map((m) => m.ruleId),
|
|
['no-empty'],
|
|
);
|
|
assert.deepEqual(byKey['b.js'].inlineConfigs, []);
|
|
});
|
|
});
|