Last active
October 28, 2025 07:57
-
-
Save mzbac/7cadb151bd71573f96e75731e2f60c0f to your computer and use it in GitHub Desktop.
eslint to oxlint
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env node | |
| /** | |
| * Reports which ESLint 8 rules in the effective config are implemented by Oxlint. | |
| * | |
| * Options: | |
| * --config <path> ESLint config file to load | |
| * --samples <files> Comma-separated probe files used to resolve overrides | |
| * --oxlint <path> Explicit oxlint binary (defaults to local node_modules or npx) | |
| * --type-aware Pass --type-aware to oxlint | |
| * --json <path> Write the support summary (default: oxc-support-report.json) | |
| * | |
| * Exit codes: 0 (full support), 1 (missing rules), 2 (script error) | |
| */ | |
| import fs from 'node:fs'; | |
| import path from 'node:path'; | |
| import { execSync } from 'node:child_process'; | |
| import { createRequire } from 'node:module'; | |
| const require = createRequire(import.meta.url); | |
| let ESLint; | |
| try { | |
| ({ ESLint } = require('eslint')); // [email protected] | |
| } catch { | |
| console.error('[error] Cannot load `eslint`. Install a local ESLint 8: `npm i -D eslint@8`.'); | |
| process.exit(2); | |
| } | |
| const args = parseArgs(process.argv.slice(2)); | |
| const CWD = process.cwd(); | |
| const ESLINT_CONFIG = args.config || detectEslintConfig(); | |
| const TYPE_AWARE = !!args['type-aware']; | |
| const JSON_OUT = args.json || 'oxc-support-report.json'; | |
| const OXLINT_BIN = args.oxlint; | |
| const PROBE_ROOT = path.join(CWD, '.oxc-probes'); | |
| const RULE_ALIASES = [ | |
| [/^@typescript-eslint\//, 'typescript/'], | |
| [/^react-hooks\//, 'react/'], | |
| [/^n\//, 'node/'], | |
| ]; | |
| const KNOWN_OXC_PLUGINS = new Set([ | |
| 'eslint', 'typescript', 'oxc', 'unicorn', | |
| 'react', 'react-perf', 'jsx-a11y', | |
| 'import', 'node', 'promise', | |
| 'jest', 'vitest', 'jsdoc', | |
| 'nextjs', 'vue', 'regex' | |
| ]); | |
| const OX_PLUGIN_FLAG = { | |
| react: '--react-plugin', | |
| 'react-hooks': '--react-plugin', | |
| 'jsx-a11y': '--jsx-a11y-plugin', | |
| import: '--import-plugin', | |
| node: '--node-plugin', | |
| n: '--node-plugin', | |
| promise: '--promise-plugin', | |
| jest: '--jest-plugin', | |
| vitest: '--vitest-plugin', | |
| jsdoc: '--jsdoc-plugin', | |
| next: '--nextjs-plugin', | |
| nextjs: '--nextjs-plugin', | |
| regex: '--regex-plugin', | |
| vue: '--vue-plugin', | |
| // defaults (eslint, typescript, unicorn, oxc) don’t need flags | |
| }; | |
| async function main() { | |
| let createdProbeFiles = []; | |
| try { | |
| const eslint = new ESLint({ | |
| cwd: CWD, | |
| overrideConfigFile: ESLINT_CONFIG || null, | |
| useEslintrc: ESLINT_CONFIG ? false : true, | |
| }); | |
| const probes = args.samples?.length | |
| ? args.samples.split(',').map(s => s.trim()).filter(Boolean) | |
| : defaultProbes(); | |
| ensureDir(PROBE_ROOT); | |
| createdProbeFiles = ensureProbeFiles(probes); | |
| const effectiveRuleIds = new Set(); | |
| const perProbeRules = {}; | |
| for (const file of probes) { | |
| const cfg = await eslint.calculateConfigForFile(file); | |
| const ruleIds = Object.keys(cfg.rules || {}); | |
| ruleIds.forEach(r => effectiveRuleIds.add(r)); | |
| perProbeRules[file] = ruleIds.sort(); | |
| } | |
| if (effectiveRuleIds.size === 0) { | |
| console.warn('[warn] No rules found in the effective ESLint config. If your config relies on path-specific overrides, pass --samples to target them.'); | |
| } | |
| const pluginNames = new Set(); | |
| for (const id of effectiveRuleIds) pluginNames.add(id.includes('/') ? id.split('/')[0] : 'eslint'); | |
| const pluginFlags = buildOxcPluginFlags(pluginNames); | |
| const oxcRules = await listOxlintRules({ | |
| typeAware: TYPE_AWARE, | |
| pluginFlags, | |
| oxlintBin: OXLINT_BIN, | |
| }); | |
| const { supported, missing, prettier } = compareRules(effectiveRuleIds, oxcRules); | |
| const perPlugin = summarizeByPlugin(effectiveRuleIds, supported, missing); | |
| console.log('\n========== Oxc support check (ESLint 8 Node API) =========='); | |
| console.log(`Config: ${ESLINT_CONFIG || '(auto)'} Probes: ${probes.length}`); | |
| console.log(`Total ESLint rules (union): ${effectiveRuleIds.size}`); | |
| console.log(`✓ Supported by Oxc: ${supported.length}`); | |
| console.log(`✗ Missing in Oxc: ${missing.length}`); | |
| if (prettier.length) console.log(`(ignored stylistic): ${prettier.join(', ')}`); | |
| if (missing.length) { | |
| console.log('\nMissing rules:'); | |
| missing.forEach(r => console.log(' -', r)); | |
| } | |
| console.log('\nPer-plugin coverage:'); | |
| perPlugin.forEach(p => { | |
| const bar = '█'.repeat(Math.floor(p.coveragePct / 5)).padEnd(20, '░'); | |
| console.log(` ${p.plugin.padEnd(14)} → ${String(p.coveragePct).padStart(3)}% ${bar} (${p.supported}/${p.total})`); | |
| }); | |
| const summary = { | |
| configPath: ESLINT_CONFIG || null, | |
| probes, | |
| typeAware: TYPE_AWARE, | |
| totals: { | |
| unionRules: effectiveRuleIds.size, | |
| supported: supported.length, | |
| missing: missing.length, | |
| prettierIgnored: prettier.length | |
| }, | |
| missingRules: missing, | |
| supportedRules: supported, | |
| perProbeRules, | |
| perPluginCoverage: perPlugin | |
| }; | |
| fs.writeFileSync(JSON_OUT, JSON.stringify(summary, null, 2)); | |
| console.log(`\nWrote JSON: ${JSON_OUT}`); | |
| return missing.length ? 1 : 0; | |
| } finally { | |
| createdProbeFiles.forEach(safeUnlink); | |
| } | |
| } | |
| main() | |
| .then(code => process.exit(code)) | |
| .catch(err => { | |
| logError(err); | |
| process.exit(2); | |
| }); | |
| // ---------- helpers ---------- | |
| function parseArgs(argv) { | |
| const o = {}; | |
| for (let i = 0; i < argv.length; i++) { | |
| const a = argv[i]; | |
| if (a === '--config') o.config = argv[++i]; | |
| else if (a === '--samples') o.samples = argv[++i]; | |
| else if (a === '--type-aware') o['type-aware'] = true; | |
| else if (a === '--json') o.json = argv[++i]; | |
| else if (a === '--oxlint') o.oxlint = argv[++i]; | |
| } | |
| return o; | |
| } | |
| function detectEslintConfig() { | |
| const cands = [ | |
| '.eslintrc.js','.eslintrc.cjs','.eslintrc.mjs','.eslintrc.json', | |
| '.eslintrc.yaml','.eslintrc.yml', | |
| 'eslint.config.js','eslint.config.mjs','eslint.config.cjs' | |
| ]; | |
| for (const f of cands) { | |
| const p = path.join(process.cwd(), f); | |
| if (fs.existsSync(p)) return p; | |
| } | |
| return null; | |
| } | |
| function defaultProbes() { | |
| return [ | |
| path.join(PROBE_ROOT, 'probe.js'), | |
| path.join(PROBE_ROOT, 'probe.jsx'), | |
| path.join(PROBE_ROOT, 'probe.ts'), | |
| path.join(PROBE_ROOT, 'probe.tsx'), | |
| ]; | |
| } | |
| function ensureDir(d) { fs.mkdirSync(d, { recursive: true }); } | |
| function safeUnlink(p) { try { fs.unlinkSync(p); } catch {} } | |
| function ensureProbeFiles(files) { | |
| const created = []; | |
| for (const p of files) { | |
| if (!fs.existsSync(p)) { | |
| ensureDir(path.dirname(p)); | |
| const ext = path.extname(p).toLowerCase(); | |
| const code = (ext === '.tsx' || ext === '.jsx') | |
| ? `// probe\nexport default function X(){return <div/>}\n` | |
| : `// probe\nexport const x = 1;\n`; | |
| fs.writeFileSync(p, code); | |
| created.push(p); | |
| } | |
| } | |
| return created; | |
| } | |
| function normalizePluginName(name) { | |
| if (!name) return null; | |
| if (name === '@typescript-eslint') return 'typescript'; | |
| if (name === 'react-hooks') return 'react'; | |
| if (name === 'n') return 'node'; | |
| if (name === 'next') return 'nextjs'; | |
| if (name === 'prettier') return null; // stylistic; irrelevant for Oxlint | |
| return name; | |
| } | |
| function buildOxcPluginFlags(pluginNames) { | |
| const flags = new Set(); | |
| for (const p of pluginNames) { | |
| const norm = normalizePluginName(p); | |
| const f = OX_PLUGIN_FLAG[norm] || OX_PLUGIN_FLAG[p]; | |
| if (f) flags.add(f); | |
| } | |
| return Array.from(flags).sort(); | |
| } | |
| function normalizeRuleIdToOxc(id) { | |
| let r = id; | |
| for (const [re, rep] of RULE_ALIASES) { | |
| if (re.test(r)) { r = r.replace(re, rep); break; } | |
| } | |
| return r; | |
| } | |
| async function listOxlintRules({ typeAware, pluginFlags, oxlintBin }) { | |
| const resolved = resolveOxlintCommand({ preferred: oxlintBin, cwd: CWD }); | |
| const flagsSegment = pluginFlags.length ? ` ${pluginFlags.join(' ')}` : ''; | |
| const typeAwareSegment = typeAware ? ' --type-aware' : ''; | |
| const base = `${resolved.command} --rules${flagsSegment}${typeAwareSegment}`.trim(); | |
| const execOpts = { | |
| encoding: 'utf8', | |
| env: { ...process.env, NO_COLOR: '1' }, | |
| stdio: 'pipe' | |
| }; | |
| // Try JSON first (newer oxlint supports -f json for --rules) | |
| try { | |
| const json = execSync(`${base} -f json`, execOpts); | |
| const arr = JSON.parse(json); | |
| if (Array.isArray(arr) && arr.length) { | |
| const set = new Set(); | |
| const addNames = (scope, value) => { | |
| if (!value || typeof value !== 'string') return; | |
| const lowerValue = value.toLowerCase(); | |
| set.add(lowerValue); | |
| if (scope && typeof scope === 'string') { | |
| const normScope = scope.toLowerCase().replace(/_/g, '-'); | |
| if (normScope === 'eslint') set.add(`eslint/${lowerValue}`); | |
| else if (/^[a-z0-9-]+$/.test(normScope)) set.add(`${normScope}/${lowerValue}`); | |
| } | |
| if (lowerValue.includes('/')) set.add(lowerValue.split('/')[1]); | |
| }; | |
| for (const entry of arr) { | |
| if (entry && typeof entry === 'object') { | |
| if ('value' in entry) addNames(entry.scope, entry.value); | |
| else if ('rule' in entry) addNames(entry.scope, entry.rule); | |
| else addNames(entry.scope || entry.source, entry.name || entry.id || entry.ruleId); | |
| } else { | |
| addNames(null, entry); | |
| } | |
| } | |
| if (set.size) return set; | |
| } | |
| } catch { | |
| // fall through to table/text parser | |
| } | |
| // Fallback: parse the pipe-table output (pipe tables). | |
| let out; | |
| try { | |
| out = execSync(base, execOpts); | |
| } catch (err) { | |
| throw wrapExecError(err, base); | |
| } | |
| return parseOxlintRuleTable(out); | |
| } | |
| /** | |
| * Parse oxlint `--rules` pipe-tables: | |
| * | Rule name | Source | Default | Fixable? | | |
| * Adds both pluginless and plugin-prefixed variants: | |
| * `jsx-key` + `react/jsx-key`, `no-debugger` + `eslint/no-debugger`, etc. | |
| * Converts source ids: jsx_a11y → jsx-a11y, react_perf → react-perf | |
| */ | |
| function parseOxlintRuleTable(text) { | |
| const set = new Set(); | |
| for (const line of text.split(/\r?\n/)) { | |
| const s = line.trim(); | |
| if (!s.startsWith('|')) continue; | |
| const cells = s.split('|').map(x => x.trim()); | |
| // cells[1] = Rule name, cells[2] = Source | |
| if (!cells[1] || !cells[2]) continue; | |
| if (cells[1].toLowerCase() === 'rule name') continue; // header | |
| const name = cells[1].toLowerCase(); | |
| let source = cells[2].toLowerCase().replace(/_/g, '-'); // jsx_a11y → jsx-a11y | |
| if (!/^[a-z0-9-]+$/.test(name)) continue; // skip junk rows | |
| set.add(name); // pluginless | |
| if (/^[a-z0-9-]+$/.test(source)) { | |
| if (source === 'eslint') set.add(`eslint/${name}`); | |
| else set.add(`${source}/${name}`); | |
| } | |
| } | |
| return set; | |
| } | |
| function compareRules(effectiveIdsSet, oxcRuleIdsSet) { | |
| const supported = []; | |
| const missing = []; | |
| const prettier = []; | |
| for (const id of Array.from(effectiveIdsSet)) { | |
| if (id === 'prettier/prettier') { prettier.push(id); continue; } | |
| const baseName = id.includes('/') ? id.split('/')[1] : id; | |
| const ox = normalizeRuleIdToOxc(id); // @typescript-eslint/* → typescript/* | |
| const candidates = new Set([ | |
| ox, // e.g. react/jsx-key | |
| baseName.toLowerCase(), // e.g. jsx-key (pluginless) | |
| ox.startsWith('eslint/') ? ox.slice(7) : `eslint/${ox}`, // add/remove eslint/ prefix | |
| ]); | |
| // hooks special-case | |
| if (id === 'react-hooks/exhaustive-deps') { | |
| candidates.add('react/exhaustive-deps'); | |
| candidates.add('exhaustive-deps'); | |
| } | |
| const ok = Array.from(candidates).some(c => oxcRuleIdsSet.has(c)); | |
| (ok ? supported : missing).push(id); | |
| } | |
| supported.sort(); missing.sort(); prettier.sort(); | |
| return { supported, missing, prettier }; | |
| } | |
| function summarizeByPlugin(allIdsSet, supported, missing) { | |
| const by = new Map(); | |
| const incr = (ns, key) => { | |
| if (!by.has(ns)) by.set(ns, { total: 0, supported: 0, missing: 0 }); | |
| by.get(ns)[key]++; | |
| }; | |
| for (const id of Array.from(allIdsSet)) incr(id.includes('/') ? id.split('/')[0] : 'eslint', 'total'); | |
| for (const id of supported) incr(id.includes('/') ? id.split('/')[0] : 'eslint', 'supported'); | |
| for (const id of missing) incr(id.includes('/') ? id.split('/')[0] : 'eslint', 'missing'); | |
| return Array.from(by.entries()) | |
| .map(([plugin, m]) => ({ | |
| plugin, | |
| total: m.total, | |
| supported: m.supported, | |
| missing: m.missing, | |
| coveragePct: m.total ? Math.round((m.supported / m.total) * 100) : 100 | |
| })) | |
| .sort((a, b) => a.plugin.localeCompare(b.plugin)); | |
| } | |
| function resolveOxlintCommand({ preferred, cwd }) { | |
| if (preferred) { | |
| if (!fs.existsSync(preferred)) { | |
| throw new Error(`[oxlint] Provided --oxlint path not found: ${preferred}`); | |
| } | |
| return { command: shellQuote(preferred), describe: `user-specified (${preferred})` }; | |
| } | |
| const localBin = path.join(cwd, 'node_modules', '.bin', process.platform === 'win32' ? 'oxlint.cmd' : 'oxlint'); | |
| if (fs.existsSync(localBin)) { | |
| return { command: shellQuote(localBin), describe: `local node_modules (${localBin})` }; | |
| } | |
| return { command: 'npx -y oxlint@latest', describe: 'npx -y oxlint@latest' }; | |
| } | |
| function shellQuote(p) { | |
| if (!p) return ''; | |
| if (/^[A-Za-z0-9_\/\-.]+$/.test(p)) return p; | |
| return `"${p.replace(/"/g, '\\"')}"`; | |
| } | |
| function wrapExecError(err, cmd) { | |
| const stdout = err?.stdout ? String(err.stdout).trim() : ''; | |
| const stderr = err?.stderr ? String(err.stderr).trim() : ''; | |
| const details = [stdout, stderr].filter(Boolean).join('\n'); | |
| const message = details ? `[oxlint] Command failed: ${cmd}\n${details}` : `[oxlint] Command failed: ${cmd}`; | |
| const wrapped = new Error(message); | |
| wrapped.cause = err; | |
| return wrapped; | |
| } | |
| function logError(err) { | |
| if (!err) { | |
| console.error('[error] Unknown failure'); | |
| return; | |
| } | |
| const message = err.message || String(err); | |
| console.error('[error]', message); | |
| if (err.cause && err.cause !== err && err.cause.message) { | |
| console.error('[error] cause:', err.cause.message); | |
| } | |
| if (err.stack) { | |
| console.error(err.stack); | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment