Add pezkuwi-dev-migrate-eslint10, and fix the Deno map's disable comment

Moving a repository to 0.86 renames rules (formatting rules to @stylistic,
import/* to import-x/*, deprecation/deprecation to
@typescript-eslint/no-deprecated, and typescript-eslint 8's removals). A
disable comment that still names the old rule disables nothing: eslint
reports it unused, --fix deletes it, and the rule it held back applies.
Measured on pezkuwi-apps: the generated typesBundle.ts carries
/* eslint-disable quotes */, and without a rename --fix rewrote all 188k
lines of it.

pezkuwi-dev-migrate-eslint10 renames the rules in eslint-disable /
eslint-enable comments across the tracked files, before the first lint on
0.86, including such comments inside string literals, which is how
generators emit them. Core renames come from eslint's own rule metadata
(meta.deprecated.replacedBy); the rest are the mappings of #9. --check
reports without writing and exits 1 when there is work.

Run on this repository it found one: pezkuwi-dev-deno-map writes
/* eslint-disable import/extensions */ into the Deno mod.ts it generates.

The spec runs the tool in a scratch git repository: every kind of rename,
descriptions kept, duplicates merged, a directive inside a string, current
names and bare disables left alone, and --check. Its fixture is spelt so
the tool, run here, cannot rewrite it.
This commit is contained in:
Satoshi Qazi Muhammed committed 2026-10-09 09:45:03 +04:00
1 parent 568f735610
commit 1ff7cd1f6f
4 files changed
+223 -1

No files matched your search

+1
View File
@@ -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",
@@ -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')) {
+134
View File
@@ -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<string, string[]>} */
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);
}
+87
View File
@@ -0,0 +1,87 @@
// Copyright 2017-2026 @pezkuwi/dev authors & contributors
// SPDX-License-Identifier: Apache-2.0
/// <reference types="@pezkuwi/dev-test/globals.d.ts" />
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);
});
});