diff --git a/packages/dev/scripts/pezkuwi-dev-migrate-eslint10.mjs b/packages/dev/scripts/pezkuwi-dev-migrate-eslint10.mjs index 2fd6a77..ab6dbfe 100755 --- a/packages/dev/scripts/pezkuwi-dev-migrate-eslint10.mjs +++ b/packages/dev/scripts/pezkuwi-dev-migrate-eslint10.mjs @@ -14,7 +14,9 @@ // 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. +// generators emit them, and the rule keys of the repository's own +// eslint.config.* ('import/extensions': 'off'). A rule that became several +// is reported for a hand edit there, and the tool then exits 1. import stylisticPlugin from '@stylistic/eslint-plugin'; import { builtinRules } from 'eslint/use-at-your-own-risk'; @@ -96,6 +98,38 @@ const files = execFileSync('git', ['ls-files', '-z', '--', '*.ts', '*.tsx', '*.j const check = process.argv.includes('--check'); let changedFiles = 0; let changedComments = 0; +let changedKeys = 0; +/** @type {string[]} */ +const manual = []; + +// Rule keys in a repository's own eslint config ('import/extensions': 'off'), +// quoted or not. Only in eslint.config.* files, where such a key is a rule. +const CONFIG_FILE = /(^|\/)eslint\.config\.[cm]?[jt]s$/; +const CONFIG_KEY = /(^|[{,\s])(['"]?)([@a-z][\w@/-]*)\2(\s*:)/g; + +/** + * @param {string} file + * @param {string} src + * @returns {string} + */ +function renameConfigKeys (file, src) { + return src.replace(CONFIG_KEY, (/** @type {string} */ all, /** @type {string} */ pre, /** @type {string} */ _quote, /** @type {string} */ name, /** @type {string} */ colon) => { + const next = rename(name); + + if (next.length === 1 && next[0] === name) { + return all; + } else if (next.length !== 1) { + // one rule became several: the value has to be repeated for each, by hand + manual.push(`${file}: '${name}' is now ${next.join(', ')}`); + + return all; + } + + changedKeys++; + + return `${pre}'${next[0]}'${colon}`; + }); +} for (const file of files) { const src = fs.readFileSync(file, 'utf-8'); @@ -116,19 +150,27 @@ for (const file of files) { return `${kind}${gap}${next.join(', ')}${tail}`; }); - if (out !== src) { + const outKeys = CONFIG_FILE.test(file) + ? renameConfigKeys(file, out) + : out; + + if (outKeys !== src) { changedFiles++; if (check) { console.log(`would change: ${file}`); } else { - fs.writeFileSync(file, out); + fs.writeFileSync(file, outKeys); } } } -console.log(`${check ? 'would rewrite' : 'rewrote'} ${changedComments} directive(s) in ${changedFiles} file(s)`); +for (const line of manual) { + console.log(`by hand: ${line}`); +} -if (check && changedFiles) { +console.log(`${check ? 'would rewrite' : 'rewrote'} ${changedComments} directive(s) and ${changedKeys} config key(s) in ${changedFiles} file(s)`); + +if ((check && changedFiles) || manual.length) { process.exit(1); } diff --git a/packages/dev/src/migrateEslint10.spec.ts b/packages/dev/src/migrateEslint10.spec.ts index e412400..ea42ab5 100644 --- a/packages/dev/src/migrateEslint10.spec.ts +++ b/packages/dev/src/migrateEslint10.spec.ts @@ -38,14 +38,40 @@ const header = '/* eslint-disable @stylistic/quotes */'; // eslint-disable-next-line @typescript-eslint/no-explicit-any `; -function inRepo (content: string, args: string[] = []): { out: string; code: number; file: string } { +const CONFIG_BEFORE = `export default [ + { + files: ['**/*.ts'], + rules: { + 'deprecation/deprecation': 'off', + "import/extensions": 'off', + indent: 'off', + 'no-console': 'error' + } + } +]; +`; + +const CONFIG_AFTER = `export default [ + { + files: ['**/*.ts'], + rules: { + '@typescript-eslint/no-deprecated': 'off', + 'import-x/extensions': 'off', + '@stylistic/indent': 'off', + 'no-console': 'error' + } + } +]; +`; + +function inRepo (content: string, args: string[] = [], name = 'sample.ts'): { out: string; code: number; file: string } { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'migrate-eslint10-')); - const file = path.join(dir, 'sample.ts'); + const file = path.join(dir, name); try { fs.writeFileSync(file, content); execFileSync('git', ['init', '-q'], { cwd: dir }); - execFileSync('git', ['add', 'sample.ts'], { cwd: dir }); + execFileSync('git', ['add', name], { cwd: dir }); let code = 0; let out = ''; @@ -68,14 +94,31 @@ describe('pezkuwi-dev-migrate-eslint10', (): void => { const { file, out } = inRepo(BEFORE); expect(file).toEqual(AFTER); - expect(out.includes('rewrote 8 directive(s) in 1 file(s)')).toEqual(true); + expect(out.includes('rewrote 8 directive(s) and 0 config key(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); + expect(out.includes('rewrote 0 directive(s) and 0 config key(s) in 0 file(s)')).toEqual(true); + }); + + it('renames rule keys in eslint.config.*, and only rule keys', (): void => { + const { code, file, out } = inRepo(CONFIG_BEFORE, [], 'eslint.config.js'); + + expect(file).toEqual(CONFIG_AFTER); + expect(code).toEqual(0); + expect(out.includes('rewrote 0 directive(s) and 3 config key(s) in 1 file(s)')).toEqual(true); + }); + + it('leaves a rule that became several to a hand edit, and exits 1', (): void => { + const config = "export default [{ rules: { '@typescript-eslint/ban-types': 'off' } }];\n"; + const { code, file, out } = inRepo(config, [], 'eslint.config.mjs'); + + expect(file).toEqual(config); + expect(code).toEqual(1); + expect(out.includes("by hand: eslint.config.mjs: '@typescript-eslint/ban-types' is now")).toEqual(true); }); it('--check reports and changes nothing, exiting 1 when there is work', (): void => {