Skip to content

Instantly share code, notes, and snippets.

@mzbac
Last active October 28, 2025 07:57
Show Gist options
  • Save mzbac/7cadb151bd71573f96e75731e2f60c0f to your computer and use it in GitHub Desktop.
Save mzbac/7cadb151bd71573f96e75731e2f60c0f to your computer and use it in GitHub Desktop.
eslint to oxlint
#!/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