diff --git a/packages/dev/config/eslint.directives.js b/packages/dev/config/eslint.directives.js new file mode 100644 index 0000000..1384fea --- /dev/null +++ b/packages/dev/config/eslint.directives.js @@ -0,0 +1,78 @@ +// Copyright 2017-2026 @pezkuwi/dev authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +/** @type {Map} */ +const sources = new Map(); + +/** + * Widens the fix eslint core gives an unused disable directive. + * + * Core removes only the comment and puts a single space in its place + * (lib/linter/apply-disable-directives.js), so --fix turns + * + * const a = 1; + * // eslint-disable-next-line no-console + * const b = 2; + * + * into a whitespace-only line between the two statements, which the layout + * rules then leave behind as a blank line. Measured on the consumers after the + * eslint 10 move: 23 such lines in api alone, each removed by hand. + * + * Only a fix that removes a whole directive comment is changed: alone on its + * line, the line goes; after code, the space before it goes; before code, the + * space after it goes. Removing one rule name out of several is left as core + * makes it, as is every message with a rule id. + * + * @param {import('eslint').Linter.LintMessage} message + * @param {string} text the source the message was produced from + * @returns {import('eslint').Linter.LintMessage} + */ +function widenDirectiveFix (message, text) { + if (message.ruleId !== null || message.fix?.text.trim() !== '') { + return message; + } + + let [start, end] = message.fix.range; + + if (!/^\/[/*]/.test(text.slice(start, end))) { + return message; + } + + const lineStart = text.lastIndexOf('\n', start - 1) + 1; + const newline = text.indexOf('\n', end); + const lineEnd = newline === -1 ? text.length : newline; + const aloneBefore = text.slice(lineStart, start).trim() === ''; + const aloneAfter = text.slice(end, lineEnd).trim() === ''; + + if (aloneBefore && aloneAfter) { + return { ...message, fix: { range: [lineStart, newline === -1 ? text.length : newline + 1], text: '' } }; + } else if (aloneBefore) { + while (end < lineEnd && /[ \t]/.test(text[end] ?? '')) { + end++; + } + } else { + while (start > lineStart && /[ \t]/.test(text[start - 1] ?? '')) { + start--; + } + } + + return { ...message, fix: { range: [start, end], text: '' } }; +} + +/** @type {import('eslint').Linter.Processor} */ +export const directiveLines = { + meta: { name: '@pezkuwi/dev/directive-lines' }, + postprocess (lists, filename) { + const text = sources.get(filename) ?? ''; + + sources.delete(filename); + + return lists.flat().map((m) => widenDirectiveFix(m, text)); + }, + preprocess (text, filename) { + sources.set(filename, text); + + return [text]; + }, + supportsAutofix: true +}; diff --git a/packages/dev/config/eslint.js b/packages/dev/config/eslint.js index 88a3ec6..82aea4f 100644 --- a/packages/dev/config/eslint.js +++ b/packages/dev/config/eslint.js @@ -22,6 +22,7 @@ import simpleImportSortPlugin from 'eslint-plugin-simple-import-sort'; import sortDestructureKeysPlugin from 'eslint-plugin-sort-destructure-keys'; import globals from 'globals'; +import { directiveLines } from './eslint.directives.js'; import { overrideAll, overrideJs, overrideJsx, overrideSpec } from './eslint.rules.js'; import { standardRules } from './eslint.standard.js'; import { withoutContextualAssertions } from './eslint.typeAssertion.js'; @@ -127,6 +128,8 @@ export default [ }, { files: extsToGlobs(EXT_ALL), + // see eslint.directives.js + processor: directiveLines, rules: { ...eslintJs.configs.recommended.rules, ...standardRules, diff --git a/packages/dev/src/directives.spec.ts b/packages/dev/src/directives.spec.ts new file mode 100644 index 0000000..d5faad6 --- /dev/null +++ b/packages/dev/src/directives.spec.ts @@ -0,0 +1,95 @@ +// Copyright 2017-2026 @pezkuwi/dev authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +/// + +import type { Linter } from 'eslint'; + +import { ESLint } from 'eslint'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +// Loaded by path: the config directory is plain JavaScript outside this +// package's TypeScript root. +const PROCESSOR = path.join(process.cwd(), 'packages/dev/config/eslint.directives.js'); + +const SOURCE = `const a = 1; +// eslint-disable-next-line no-console +const b = 2; + +function f () { + // eslint-disable-next-line no-console + return a + b; +} + +f(); // eslint-disable-line no-console +/* eslint-disable-next-line no-console */ export const c = 3; +// eslint-disable-next-line no-alert, no-console +console.log(c); +`; + +const FIXED = `const a = 1; +const b = 2; + +function f () { + return a + b; +} + +f(); +export const c = 3; +// eslint-disable-next-line no-console +console.log(c); +`; + +async function fix (processor?: Linter.Processor): Promise { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'directives-')); + + try { + fs.writeFileSync(path.join(dir, 'sample.js'), SOURCE); + + const eslint = new ESLint({ + cwd: dir, + fix: true, + overrideConfig: [{ + files: ['**/*.js'], + linterOptions: { reportUnusedDisableDirectives: 'error' }, + ...(processor ? { processor } : {}), + rules: { 'no-alert': 'error', 'no-console': 'error' } + }], + overrideConfigFile: true + }); + const [result] = await eslint.lintFiles(['sample.js']); + + return result.output ?? SOURCE; + } finally { + fs.rmSync(dir, { force: true, recursive: true }); + } +} + +describe('directiveLines', (): void => { + it('removes an unused directive with its line or the space around it', async (): Promise => { + const { directiveLines } = await import(PROCESSOR) as { directiveLines: Linter.Processor }; + + expect(await fix(directiveLines)).toEqual(FIXED); + }); + + it('is needed: core leaves a space where each comment was', async (): Promise => { + expect((await fix()).split('\n')).toEqual([ + 'const a = 1;', + ' ', + 'const b = 2;', + '', + 'function f () {', + ' ', + ' return a + b;', + '}', + '', + 'f(); ', + ' export const c = 3;', + '// eslint-disable-next-line no-console', + 'console.log(c);', + '' + ]); + }); +});