@oxlint/migrate
Version:
Generates a `.oxlintrc.json` from a existing eslint flat config
498 lines (497 loc) • 20.8 kB
JavaScript
import { a as nurseryRules, i as buildUnsupportedRuleExplanations, n as preFixForJsPlugins, o as rules_exports, r as isOffValue, s as normalizeRuleToCanonical, t as main } from "../src-aj7n0D2s.mjs";
import { program } from "commander";
import { existsSync, readFileSync, renameSync, writeFileSync } from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { parseSync } from "oxc-parser";
import { glob } from "tinyglobby";
import { writeFile } from "node:fs/promises";
//#region bin/config-loader.ts
const FLAT_CONFIG_FILENAMES = [
"eslint.config.js",
"eslint.config.mjs",
"eslint.config.cjs",
"eslint.config.ts",
"eslint.config.mts",
"eslint.config.cts"
];
const getAutodetectedEslintConfigName = (cwd) => {
for (const filename of FLAT_CONFIG_FILENAMES) {
const filePath = path.join(cwd, filename);
if (existsSync(filePath)) return filePath;
}
};
const loadESLintConfig = async (filePath) => {
if (filePath.endsWith("json")) throw new Error(`json format is not supported. @oxlint/migrate only supports the eslint flat configuration`);
let url = pathToFileURL(filePath).toString();
if (!existsSync(filePath)) throw new Error(`eslint config file not found: ${filePath}`);
return import(url);
};
//#endregion
//#region package.json
var version = "1.73.0";
//#endregion
//#region src/walker/comments/replaceRuleDirectiveComment.ts
const allRulesSet = new Set(Object.values(rules_exports).flat());
const nurseryRulesSet = new Set(nurseryRules);
function replaceRuleDirectiveComment(comment, type, options) {
const originalComment = comment;
comment = comment.split(" -- ")[0].trimStart();
if (!comment.startsWith("eslint-")) return originalComment;
comment = comment.substring(7);
if (comment.startsWith("enable")) comment = comment.substring(6);
else if (comment.startsWith("disable")) {
comment = comment.substring(7);
if (type === "Line") {
if (comment.startsWith("-next-line")) comment = comment.substring(10);
else if (comment.startsWith("-line")) comment = comment.substring(5);
}
} else return originalComment;
if (!comment.startsWith(" ")) return originalComment;
comment = comment.trimStart();
if (comment.length === 0) return originalComment;
while (comment.length) {
const commaIdx = comment.indexOf(",");
const candidateEnd = commaIdx === -1 ? comment.length : commaIdx;
const candidate = comment.substring(0, candidateEnd).trimEnd();
const canonical = normalizeRuleToCanonical(candidate);
if (!allRulesSet.has(canonical)) return originalComment;
if (!options.withNursery && nurseryRulesSet.has(canonical)) return originalComment;
comment = comment.substring(candidate.length).trimStart();
if (!comment.length) break;
if (!comment.startsWith(", ")) return originalComment;
comment = comment.substring(1).trimStart();
}
return originalComment.replace(/eslint-/, "oxlint-");
}
//#endregion
//#region src/walker/comments/index.ts
function replaceComments(comment, type, options) {
const originalComment = comment;
comment = comment.trim();
if (comment.startsWith("eslint-")) return replaceRuleDirectiveComment(originalComment, type, options);
else if (type === "Block") {
if (comment.startsWith("eslint ")) throw new Error("changing eslint rules with inline comment is not supported");
else if (comment.startsWith("global ")) throw new Error("changing globals with inline comment is not supported");
}
return originalComment;
}
//#endregion
//#region src/walker/partialSourceTextLoader.ts
function extractLangAttribute(source) {
const langIndex = source.indexOf("lang");
if (langIndex === -1) return void 0;
let cursor = langIndex + 4;
while (cursor < source.length && isWhitespace(source[cursor])) cursor++;
if (source[cursor] !== "=") return void 0;
cursor++;
while (cursor < source.length && isWhitespace(source[cursor])) cursor++;
const quote = source[cursor];
if (quote !== "\"" && quote !== "'") return void 0;
cursor++;
let value = "";
while (cursor < source.length && source[cursor] !== quote) value += source[cursor++];
if (value === "js" || value === "jsx" || value === "ts" || value === "tsx") return value;
}
function extractScriptBlocks(sourceText, offset, maxBlocks, parseLangAttribute) {
const results = [];
while (offset < sourceText.length) {
const idx = sourceText.indexOf("<script", offset);
if (idx === -1) break;
const nextChar = sourceText[idx + 7];
if (nextChar !== " " && nextChar !== ">" && nextChar !== "\n" && nextChar !== " ") {
offset = idx + 7;
continue;
}
let i = idx + 7;
let inQuote = null;
let genericDepth = 0;
let selfClosing = false;
while (i < sourceText.length) {
const c = sourceText[i];
if (inQuote) {
if (c === inQuote) inQuote = null;
} else if (c === "\"" || c === "'") inQuote = c;
else if (c === "<") genericDepth++;
else if (c === ">") if (genericDepth > 0) genericDepth--;
else {
if (i > idx && sourceText[i - 1] === "/") selfClosing = true;
i++;
break;
}
i++;
}
if (selfClosing) {
offset = i;
continue;
}
if (i >= sourceText.length) break;
let lang = void 0;
if (parseLangAttribute) lang = extractLangAttribute(sourceText.slice(idx, i));
const contentStart = i;
const closeIdx = sourceText.indexOf("<\/script>", contentStart);
if (closeIdx === -1) break;
const content = sourceText.slice(contentStart, closeIdx);
results.push({
sourceText: content,
offset: contentStart,
lang
});
if (results.length >= maxBlocks) break;
offset = closeIdx + 9;
}
return results;
}
function partialSourceTextLoader(absoluteFilePath, fileContent) {
if (absoluteFilePath.endsWith(".vue")) return partialVueSourceTextLoader(fileContent);
else if (absoluteFilePath.endsWith(".astro")) return partialAstroSourceTextLoader(fileContent);
else if (absoluteFilePath.endsWith(".svelte")) return partialSvelteSourceTextLoader(fileContent);
return [{
sourceText: fileContent,
offset: 0
}];
}
function isWhitespace(char) {
return char === " " || char === " " || char === "\r";
}
function findDelimiter(sourceText, startPos) {
let i = startPos;
while (i < sourceText.length) {
if (i === 0 || sourceText[i - 1] === "\n") {
let j = i;
while (j < sourceText.length && isWhitespace(sourceText[j])) j++;
if (sourceText[j] === "-" && sourceText[j + 1] === "-" && sourceText[j + 2] === "-") {
let k = j + 3;
while (k < sourceText.length && sourceText[k] !== "\n") {
if (!isWhitespace(sourceText[k])) break;
k++;
}
if (k === sourceText.length || sourceText[k] === "\n") return j;
}
}
i++;
}
return -1;
}
function partialVueSourceTextLoader(sourceText) {
return extractScriptBlocks(sourceText, 0, 2, true);
}
function partialSvelteSourceTextLoader(sourceText) {
return extractScriptBlocks(sourceText, 0, 2, true);
}
function partialAstroSourceTextLoader(sourceText) {
const results = [];
let pos = 0;
const frontmatterStartDelimiter = findDelimiter(sourceText, pos);
if (frontmatterStartDelimiter !== -1) {
const frontmatterContentStart = frontmatterStartDelimiter + 3;
const frontmatterEndDelimiter = findDelimiter(sourceText, frontmatterContentStart);
if (frontmatterEndDelimiter !== -1) {
const content = sourceText.slice(frontmatterContentStart, frontmatterEndDelimiter);
results.push({
sourceText: content,
offset: frontmatterContentStart,
lang: "ts",
sourceType: "module"
});
pos = frontmatterEndDelimiter + 3;
}
}
results.push(...extractScriptBlocks(sourceText, pos, Number.MAX_SAFE_INTEGER, false).map((sourceText) => {
return Object.assign(sourceText, {
lang: `ts`,
sourceType: `module`
});
}));
return results;
}
//#endregion
//#region src/walker/replaceCommentsInFile.ts
const getComments = (absoluteFilePath, partialSourceText, options) => {
const parserResult = parseSync(absoluteFilePath, partialSourceText.sourceText, {
lang: partialSourceText.lang,
sourceType: partialSourceText.sourceType
});
if (parserResult.errors.length > 0) options.reporter?.addWarning(`${absoluteFilePath}: failed to parse`);
return parserResult.comments;
};
function replaceCommentsInSourceText(absoluteFilePath, partialSourceText, options) {
const comments = getComments(absoluteFilePath, partialSourceText, options);
let sourceText = partialSourceText.sourceText;
for (const comment of comments) try {
const replacedStr = replaceComments(comment.value, comment.type, options);
if (replacedStr !== comment.value) {
const newComment = comment.type === "Line" ? `//${replacedStr}` : `/*${replacedStr}*/`;
sourceText = sourceText.slice(0, comment.start) + newComment + sourceText.slice(comment.end);
}
} catch (error) {
if (error instanceof Error) {
options.reporter?.addWarning(`${absoluteFilePath}, char offset ${comment.start + partialSourceText.offset}: ${error.message}`);
continue;
}
throw error;
}
return sourceText;
}
function replaceCommentsInFile(absoluteFilePath, fileContent, options) {
for (const partialSourceText of partialSourceTextLoader(absoluteFilePath, fileContent)) {
const newSourceText = replaceCommentsInSourceText(absoluteFilePath, partialSourceText, options);
if (newSourceText !== partialSourceText.sourceText) fileContent = fileContent.slice(0, partialSourceText.offset) + newSourceText + fileContent.slice(partialSourceText.offset + partialSourceText.sourceText.length);
}
return fileContent;
}
//#endregion
//#region src/walker/index.ts
const walkAndReplaceProjectFiles = (projectFiles, readFileSync, writeFile, options) => {
return Promise.all(projectFiles.map((file) => {
const sourceText = readFileSync(file);
if (!sourceText) return Promise.resolve();
const newSourceText = replaceCommentsInFile(file, sourceText, options);
if (newSourceText === sourceText) return Promise.resolve();
return writeFile(file, newSourceText);
}));
};
//#endregion
//#region bin/project-loader.ts
const getAllProjectFiles = () => {
return glob([
"**/*.{js,cjs,mjs,ts,cts,mts,jsx,tsx,vue,astro,svelte}",
"!**/node_modules/**",
"!**/dist/**"
], { absolute: true });
};
//#endregion
//#region src/reporter.ts
var DefaultReporter = class {
warnings = /* @__PURE__ */ new Set();
skippedRules = /* @__PURE__ */ new Map([
["nursery", /* @__PURE__ */ new Set()],
["type-aware", /* @__PURE__ */ new Set()],
["not-implemented", /* @__PURE__ */ new Set()],
["unsupported", /* @__PURE__ */ new Set()],
["js-plugins", /* @__PURE__ */ new Set()]
]);
addWarning(message) {
this.warnings.add(message);
}
getWarnings() {
return Array.from(this.warnings);
}
markSkipped(rule, category) {
this.skippedRules.get(category)?.add(rule);
}
removeSkipped(rule, category) {
this.skippedRules.get(category)?.delete(rule);
}
getSkippedRulesByCategory() {
const result = {
nursery: [],
"type-aware": [],
"not-implemented": [],
"js-plugins": [],
unsupported: []
};
for (const [category, rules] of this.skippedRules) result[category] = Array.from(rules);
return result;
}
};
//#endregion
//#region bin/output-formatter.ts
const unsupportedRuleExplanations = buildUnsupportedRuleExplanations();
const CATEGORY_METADATA = {
nursery: {
label: "Nursery",
description: "Experimental:"
},
"type-aware": {
label: "Type-aware",
description: "Requires TS info:"
},
"js-plugins": {
label: "JS Plugins",
description: "Requires JS plugins:"
},
"not-implemented": {
label: "Not Implemented",
description: "Not yet in oxlint:"
},
unsupported: {
label: "Unsupported",
description: "Won't be implemented:"
}
};
const MAX_LABEL_LENGTH = Math.max(...Object.values(CATEGORY_METADATA).map((meta) => meta.label.length));
/**
* Formats a category summary as either inline (with example) or vertical list
*/
function formatCategorySummary(count, category, rules, showAll) {
const meta = CATEGORY_METADATA[category];
if (!showAll) {
const maxRules = 3;
const exampleList = rules.slice(0, maxRules).join(", ");
const suffix = count > maxRules ? ", and more" : "";
const prefix = meta.description ? `${meta.description} ` : "";
return ` - ${String(count).padStart(3)} ${meta.label.padEnd(MAX_LABEL_LENGTH)} (${prefix}${exampleList}${suffix})\n`;
}
let output = ` - ${count} ${meta.label}\n`;
for (const rule of rules) if (category === "unsupported" && unsupportedRuleExplanations[rule]) output += ` - ${rule}: ${unsupportedRuleExplanations[rule]}\n`;
else output += ` - ${rule}\n`;
return output;
}
/**
* Detects which CLI flags are missing and could enable more rules
*/
function detectMissingFlags(byCategory, cliOptions) {
const missingFlags = [];
if (byCategory.nursery.length > 0 && !cliOptions.withNursery) missingFlags.push("--with-nursery");
if (byCategory["type-aware"].length > 0 && !cliOptions.typeAware) missingFlags.push("--type-aware");
if (byCategory["js-plugins"].length > 0 && !cliOptions.jsPlugins) missingFlags.push("--js-plugins=true");
return missingFlags;
}
/**
* Formats the complete migration output message
*/
function formatMigrationOutput(data) {
let output = "";
const showAll = data.cliOptions.details || false;
if (data.enabledRulesCount === 0) output += `\n⚠️ ${data.outputFileName} created with no rules enabled.\n`;
else output += `\n✨ ${data.outputFileName} created with ${data.enabledRulesCount} rules.\n`;
const byCategory = data.skippedRulesByCategory;
const nurseryCount = byCategory.nursery.length;
const typeAwareCount = byCategory["type-aware"].length;
const notImplementedCount = byCategory["not-implemented"].length;
const unsupportedCount = byCategory.unsupported.length;
const jsPluginsCount = byCategory["js-plugins"].length;
const totalSkipped = nurseryCount + typeAwareCount + notImplementedCount + unsupportedCount + jsPluginsCount;
if (totalSkipped > 0) {
output += `\n Skipped ${totalSkipped} rules:\n`;
if (nurseryCount > 0) output += formatCategorySummary(nurseryCount, "nursery", byCategory.nursery, showAll);
if (typeAwareCount > 0) output += formatCategorySummary(typeAwareCount, "type-aware", byCategory["type-aware"], showAll);
if (jsPluginsCount > 0) output += formatCategorySummary(jsPluginsCount, "js-plugins", byCategory["js-plugins"], showAll);
if (notImplementedCount > 0) output += formatCategorySummary(notImplementedCount, "not-implemented", byCategory["not-implemented"], showAll);
if (unsupportedCount > 0) output += formatCategorySummary(unsupportedCount, "unsupported", byCategory.unsupported, showAll);
if (!showAll) {
const maxExamples = 3;
if (nurseryCount > maxExamples || typeAwareCount > maxExamples || notImplementedCount > maxExamples || unsupportedCount > maxExamples || jsPluginsCount > maxExamples) output += `\n Tip: Use --details to see the full list.\n`;
}
const missingFlags = detectMissingFlags(byCategory, data.cliOptions);
if (missingFlags.length > 0) {
const eslintConfigArg = data.eslintConfigPath ? ` ${path.basename(data.eslintConfigPath)}` : "";
output += `\n👉 Re-run with flags to include more:\n`;
output += ` npx @oxlint/migrate${eslintConfigArg} ${missingFlags.join(" ")}\n`;
}
}
if (data.enabledRulesCount > 0) {
output += `\n🚀 Next:\n`;
output += ` npx oxlint .\n`;
}
return output;
}
function formatWarningsOutput(warnings) {
if (warnings.length === 0) return "";
let output = `⚠️ Warnings (${warnings.length}):\n`;
for (const warning of warnings) {
const [message, ...details] = warning.split("\n");
output += ` * ${message}\n`;
for (const detail of details.filter((line) => line.trim().length)) output += ` * ${detail}\n`;
}
return output.trimEnd();
}
function displayMigrationResult(outputMessage, warnings) {
console.log(outputMessage);
if (warnings.length > 0) console.warn(formatWarningsOutput(warnings));
}
//#endregion
//#region bin/oxlint-migrate.ts
const cwd = process.cwd();
const parseCliBoolean = (value) => value === "false" ? false : !!value;
const getFileContent = (absoluteFilePath) => {
try {
return readFileSync(absoluteFilePath, "utf-8");
} catch {
return;
}
};
/**
* Strips JSON comments (single-line `//` and multi-line) and trailing commas
* so that `tsconfig.json` (which is actually JSONC) can be parsed with `JSON.parse`.
*/
const stripJsoncFeatures = (text) => text.replace(/\/\/.*$/gm, "").replace(/\/\*[\s\S]*?\*\//g, "").replace(/,\s*([}\]])/g, "$1");
/**
* When `--type-aware` is used, check whether the project's `tsconfig.json`
* contains a `baseUrl` value in `compilerOptions`. If it does, emit a warning
* because `tsgo` does not support `baseUrl`, which may cause issues with
* type-aware linting.
*/
const warnIfTsconfigHasBaseUrl = (reporter) => {
const content = getFileContent(path.join(cwd, "tsconfig.json"));
if (content === void 0) return;
try {
const tsconfig = JSON.parse(stripJsoncFeatures(content));
if (tsconfig?.compilerOptions?.baseUrl !== void 0) reporter.addWarning(`tsconfig.json has \`baseUrl\` set to ${JSON.stringify(tsconfig.compilerOptions.baseUrl)}. \`baseUrl\` is not supported by tsgo, which may cause issues with type-aware linting. Consider removing \`baseUrl\` or using path aliases instead.`);
} catch {}
};
/**
* Count enabled rules (excluding "off" rules) from both rules and overrides
*/
const countEnabledRules = (config) => {
const enabledRules = /* @__PURE__ */ new Set();
if (config.rules) {
for (const [ruleName, ruleValue] of Object.entries(config.rules)) if (!isOffValue(ruleValue)) enabledRules.add(ruleName);
}
if (config.overrides && Array.isArray(config.overrides)) {
for (const override of config.overrides) if (override.rules) {
for (const [ruleName, ruleValue] of Object.entries(override.rules)) if (!isOffValue(ruleValue)) enabledRules.add(ruleName);
}
}
return enabledRules.size;
};
program.name("oxlint-migrate").version(version).argument("[eslint-config]", "The path to the eslint v9 config file").option("--output-file <file>", "The oxlint configuration file where to eslint v9 rules will be written to", ".oxlintrc.json").option("--merge", "Merge eslint configuration with an existing .oxlintrc.json configuration", false).option("--with-nursery", "Include oxlint rules which are currently under development", false).option("--replace-eslint-comments", "Search in the project files for eslint comments and replaces them with oxlint. Some eslint comments are not supported and will be reported.").option("--type-aware", "Includes supported type-aware rules. Needs the same flag in `oxlint` or the `typeAware` config option to enable it.").option("--js-plugins [bool]", "Tries to convert unsupported oxlint plugins with `jsPlugins`. Enabled by default; pass `--js-plugins=false` to disable.", true).option("--details", "List rules that could not be migrated to oxlint.", false).action(async (filePath) => {
const cliOptions = program.opts();
const jsPlugins = parseCliBoolean(cliOptions.jsPlugins);
const oxlintFilePath = path.join(cwd, cliOptions.outputFile);
const reporter = new DefaultReporter();
const options = {
reporter,
merge: !!cliOptions.merge,
withNursery: !!cliOptions.withNursery,
typeAware: !!cliOptions.typeAware,
jsPlugins
};
if (options.typeAware) warnIfTsconfigHasBaseUrl(reporter);
if (cliOptions.replaceEslintComments) {
await walkAndReplaceProjectFiles(await getAllProjectFiles(), (filePath) => getFileContent(filePath), (filePath, content) => writeFile(filePath, content, "utf-8"), options);
return;
}
if (filePath === void 0) filePath = getAutodetectedEslintConfigName(cwd);
else filePath = path.join(cwd, filePath);
if (filePath === void 0) program.error(`could not autodetect eslint config file`);
const resetPreFix = await preFixForJsPlugins();
const eslintConfigs = await loadESLintConfig(filePath);
resetPreFix();
let config;
if (options.merge && existsSync(oxlintFilePath)) config = JSON.parse(readFileSync(oxlintFilePath, {
encoding: "utf8",
flag: "r"
}));
const oxlintConfig = "default" in eslintConfigs ? await main(eslintConfigs.default, config, options) : await main(eslintConfigs, config, options);
if (existsSync(oxlintFilePath)) renameSync(oxlintFilePath, `${oxlintFilePath}.bak`);
writeFileSync(oxlintFilePath, JSON.stringify(oxlintConfig, null, 2));
const enabledRulesCount = countEnabledRules(oxlintConfig);
displayMigrationResult(formatMigrationOutput({
outputFileName: cliOptions.outputFile,
enabledRulesCount,
skippedRulesByCategory: reporter.getSkippedRulesByCategory(),
cliOptions: {
withNursery: !!cliOptions.withNursery,
typeAware: !!cliOptions.typeAware,
details: !!cliOptions.details,
jsPlugins
},
eslintConfigPath: filePath
}), reporter.getWarnings());
});
program.parse();
//#endregion
export {};