diff --git a/packages/dev/package.json b/packages/dev/package.json index 14d4b01..f75131c 100644 --- a/packages/dev/package.json +++ b/packages/dev/package.json @@ -28,6 +28,7 @@ "pezkuwi-dev-copy-dir": "./scripts/pezkuwi-dev-copy-dir.mjs", "pezkuwi-dev-copy-to": "./scripts/pezkuwi-dev-copy-to.mjs", "pezkuwi-dev-deno-map": "./scripts/pezkuwi-dev-deno-map.mjs", + "pezkuwi-dev-migrate-eslint10": "./scripts/pezkuwi-dev-migrate-eslint10.mjs", "pezkuwi-dev-run-lint": "./scripts/pezkuwi-dev-run-lint.mjs", "pezkuwi-dev-run-node-ts": "./scripts/pezkuwi-dev-run-node-ts.mjs", "pezkuwi-dev-run-test": "./scripts/pezkuwi-dev-run-test.mjs", diff --git a/packages/dev/scripts/pezkuwi-dev-deno-map.mjs b/packages/dev/scripts/pezkuwi-dev-deno-map.mjs index 544d012..ba15d13 100755 --- a/packages/dev/scripts/pezkuwi-dev-deno-map.mjs +++ b/packages/dev/scripts/pezkuwi-dev-deno-map.mjs @@ -19,7 +19,7 @@ const [e, i] = fs if (!fs.existsSync('mod.ts')) { fs.writeFileSync('mod.ts', `// Copyright 2017-${new Date().getFullYear()} @pezkuwi/dev authors & contributors\n// SPDX-License-Identifier: Apache-2.0\n\n// auto-generated via pezkuwi-dev-deno-map, do not edit\n\n// This is a Deno file, so we can allow .ts imports - /* eslint-disable import/extensions */\n\n${e.join('\n')}\n`); + /* eslint-disable import-x/extensions */\n\n${e.join('\n')}\n`); } if (fs.existsSync('import_map.in.json')) { diff --git a/packages/dev/scripts/pezkuwi-dev-migrate-eslint10.mjs b/packages/dev/scripts/pezkuwi-dev-migrate-eslint10.mjs new file mode 100755 index 0000000..2fd6a77 --- /dev/null +++ b/packages/dev/scripts/pezkuwi-dev-migrate-eslint10.mjs @@ -0,0 +1,134 @@ +#!/usr/bin/env node +// Copyright 2017-2026 @pezkuwi/dev authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +// Renames rules in eslint-disable / eslint-enable comments for the 0.86 +// toolchain (eslint 10, typescript-eslint 8, @stylistic, import-x). +// +// A disable comment that names a rule which no longer exists under that name +// disables nothing: eslint reports it as unused and `--fix` deletes it, after +// which the rule it was meant to silence applies. Run this before the first +// lint on 0.86, in the repository root: +// +// pezkuwi-dev-migrate-eslint10 # rewrite tracked files +// pezkuwi-dev-migrate-eslint10 --check # report, change nothing (exit 1 if anything would change) +// +// It also rewrites such comments inside string literals, which is how code +// generators emit them. + +import stylisticPlugin from '@stylistic/eslint-plugin'; +import { builtinRules } from 'eslint/use-at-your-own-risk'; +import importPlugin from 'eslint-plugin-import-x'; +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; + +const STYLISTIC = new Set(Object.keys(stylisticPlugin.rules)); +const IMPORT_X = new Set(Object.keys(importPlugin.rules)); + +/** @type {Map} */ +const RENAME = new Map(); + +// Core rules eslint 10 deprecated, with the successor it names itself. The +// rule metadata (meta.deprecated.replacedBy) is only exposed here, so this +// reads it rather than keeping a hand-written copy that could drift. +// eslint-disable-next-line @typescript-eslint/no-deprecated +for (const [name, rule] of builtinRules) { + const replacedBy = rule.meta?.deprecated && typeof rule.meta.deprecated === 'object' + ? rule.meta.deprecated.replacedBy + : undefined; + + if (replacedBy?.length === 1) { + const [{ plugin, rule: next }] = replacedBy; + + if (next?.name) { + RENAME.set(name, [plugin?.name === '@stylistic/eslint-plugin' ? `@stylistic/${next.name}` : next.name]); + } + } +} + +// typescript-eslint 8 removed its formatting rules; they live in @stylistic. +for (const name of ['block-spacing', 'brace-style', 'comma-dangle', 'comma-spacing', 'func-call-spacing', 'indent', 'key-spacing', 'keyword-spacing', 'lines-around-comment', 'lines-between-class-members', 'member-delimiter-style', 'no-extra-parens', 'no-extra-semi', 'object-curly-spacing', 'padding-line-between-statements', 'quotes', 'semi', 'space-before-blocks', 'space-before-function-paren', 'space-infix-ops', 'type-annotation-spacing']) { + const next = name === 'func-call-spacing' ? 'function-call-spacing' : name; + + if (STYLISTIC.has(next)) { + RENAME.set(`@typescript-eslint/${name}`, [`@stylistic/${next}`]); + } +} + +// Other typescript-eslint 8 removals and renames. +RENAME.set('@typescript-eslint/ban-types', ['@typescript-eslint/no-empty-object-type', '@typescript-eslint/no-unsafe-function-type', '@typescript-eslint/no-wrapper-object-types']); +RENAME.set('@typescript-eslint/no-empty-interface', ['@typescript-eslint/no-empty-object-type']); +RENAME.set('@typescript-eslint/no-loss-of-precision', ['no-loss-of-precision']); +RENAME.set('@typescript-eslint/no-throw-literal', ['@typescript-eslint/only-throw-error']); +RENAME.set('@typescript-eslint/no-var-requires', ['@typescript-eslint/no-require-imports']); +RENAME.set('deprecation/deprecation', ['@typescript-eslint/no-deprecated']); + +// The react formatting rules config/eslint.rules.js (overrideJsx) now takes +// from @stylistic; keep this list in step with it. (The config is not +// imported here: loading it reads the repository's tsconfig.base.json.) +for (const name of ['jsx-closing-bracket-location', 'jsx-first-prop-new-line', 'jsx-max-props-per-line', 'jsx-newline', 'jsx-props-no-multi-spaces', 'jsx-sort-props', 'jsx-tag-spacing']) { + if (STYLISTIC.has(name)) { + RENAME.set(`react/${name}`, [`@stylistic/${name}`]); + } +} + +/** + * @param {string} name + * @returns {string[]} + */ +function rename (name) { + if (RENAME.has(name)) { + return /** @type {string[]} */ (RENAME.get(name)); + } else if (name.startsWith('import/') && IMPORT_X.has(name.slice(7))) { + return [`import-x/${name.slice(7)}`]; + } + + return [name]; +} + +// eslint-disable, eslint-disable-line, eslint-disable-next-line, eslint-enable +// followed by a rule list, optionally ending in `-- description`. +const DIRECTIVE = /(eslint-(?:disable(?:-next-line|-line)?|enable))([ \t]+)([^\n*'"`]*?)([ \t]*(?:--[^\n*]*)?)(?=\*\/|\n|$|['"`])/g; + +const files = execFileSync('git', ['ls-files', '-z', '--', '*.ts', '*.tsx', '*.js', '*.jsx', '*.mjs', '*.cjs', '*.cts', '*.mts'], { encoding: 'utf-8' }) + .split('\0') + .filter((f) => f && !/(^|\/)(node_modules|build|build-[^/]+)\//.test(f)); +const check = process.argv.includes('--check'); +let changedFiles = 0; +let changedComments = 0; + +for (const file of files) { + const src = fs.readFileSync(file, 'utf-8'); + const out = src.replace(DIRECTIVE, (/** @type {string} */ all, /** @type {string} */ kind, /** @type {string} */ gap, /** @type {string} */ list, /** @type {string} */ tail) => { + if (!list.trim()) { + return all; + } + + const names = list.split(',').map((n) => n.trim()).filter(Boolean); + const next = [...new Set(names.flatMap(rename))]; + + if (next.join(',') === names.join(',')) { + return all; + } + + changedComments++; + + return `${kind}${gap}${next.join(', ')}${tail}`; + }); + + if (out !== src) { + changedFiles++; + + if (check) { + console.log(`would change: ${file}`); + } else { + fs.writeFileSync(file, out); + } + } +} + +console.log(`${check ? 'would rewrite' : 'rewrote'} ${changedComments} directive(s) in ${changedFiles} file(s)`); + +if (check && changedFiles) { + process.exit(1); +} diff --git a/packages/dev/src/migrateEslint10.spec.ts b/packages/dev/src/migrateEslint10.spec.ts new file mode 100644 index 0000000..e412400 --- /dev/null +++ b/packages/dev/src/migrateEslint10.spec.ts @@ -0,0 +1,87 @@ +// Copyright 2017-2026 @pezkuwi/dev authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +/// + +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +const SCRIPT = path.join(process.cwd(), 'packages/dev/scripts/pezkuwi-dev-migrate-eslint10.mjs'); + +// Spelt so that the tool, run on this repository, does not rewrite its own +// fixture: the source never contains the directive it is testing. +const D = 'eslint-' + 'disable'; + +const BEFORE = `/* ${D} quotes */ +/* ${D} quote-props */ +// ${D}-next-line deprecation/deprecation +// ${D}-next-line import/export -- types and values share the name +// ${D}-next-line @typescript-eslint/no-empty-interface,no-use-before-define +// ${D}-next-line indent, @typescript-eslint/indent +// ${D}-next-line react/jsx-max-props-per-line +const header = '/* ${D} quotes */'; +/* ${D} */ +// ${D}-next-line @typescript-eslint/no-explicit-any +`; + +const AFTER = `/* eslint-disable @stylistic/quotes */ +/* eslint-disable @stylistic/quote-props */ +// eslint-disable-next-line @typescript-eslint/no-deprecated +// eslint-disable-next-line import-x/export -- types and values share the name +// eslint-disable-next-line @typescript-eslint/no-empty-object-type, no-use-before-define +// eslint-disable-next-line @stylistic/indent +// eslint-disable-next-line @stylistic/jsx-max-props-per-line +const header = '/* eslint-disable @stylistic/quotes */'; +/* eslint-disable */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +`; + +function inRepo (content: string, args: string[] = []): { out: string; code: number; file: string } { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'migrate-eslint10-')); + const file = path.join(dir, 'sample.ts'); + + try { + fs.writeFileSync(file, content); + execFileSync('git', ['init', '-q'], { cwd: dir }); + execFileSync('git', ['add', 'sample.ts'], { cwd: dir }); + + let code = 0; + let out = ''; + + try { + out = execFileSync(process.execPath, [SCRIPT, ...args], { cwd: dir, encoding: 'utf-8' }); + } catch (error) { + code = (error as { status: number }).status; + out = (error as { stdout: string }).stdout; + } + + return { code, file: fs.readFileSync(file, 'utf-8'), out }; + } finally { + fs.rmSync(dir, { force: true, recursive: true }); + } +} + +describe('pezkuwi-dev-migrate-eslint10', (): void => { + it('renames rules in disable comments, and in strings that emit them', (): void => { + const { file, out } = inRepo(BEFORE); + + expect(file).toEqual(AFTER); + expect(out.includes('rewrote 8 directive(s) in 1 file(s)')).toEqual(true); + }); + + it('leaves current rule names and bare disables alone', (): void => { + const { file, out } = inRepo(AFTER); + + expect(file).toEqual(AFTER); + expect(out.includes('rewrote 0 directive(s) in 0 file(s)')).toEqual(true); + }); + + it('--check reports and changes nothing, exiting 1 when there is work', (): void => { + const { code, file } = inRepo(BEFORE, ['--check']); + + expect(file).toEqual(BEFORE); + expect(code).toEqual(1); + }); +});