UNPKG

@oxlint/migrate

Version:

Generates a `.oxlintrc.json` from a existing eslint flat config

498 lines (497 loc) 20.8 kB
#!/usr/bin/env node 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 {};