@eslint/migrate-config
Version:
Configuration migration for ESLint
1,667 lines (1,449 loc) • 44.5 kB
JavaScript
/**
* @fileoverview Configuration Migration
* @author Nicholas C. Zakas
*/
//-----------------------------------------------------------------------------
// Imports
//-----------------------------------------------------------------------------
import * as recast from "recast";
// @ts-ignore: No types available
import { Legacy } from "@eslint/eslintrc";
import camelCase from "camelcase";
import pluginsNeedingCompat from "./compat-plugins.js";
import configsNeedingCompat from "./compat-configs.js";
import { convertIgnorePatternToMinimatch } from "@eslint/config-helpers";
import * as espree from "espree";
//-----------------------------------------------------------------------------
// Types
//-----------------------------------------------------------------------------
/** @typedef {import("@eslint/core").ConfigObject} FlatConfig */
/** @typedef {import("@eslint/core").LegacyConfigObject} LegacyConfig */
/** @typedef {import("@eslint/core").ConfigOverride} ConfigOverride */
/** @typedef {import("recast").types.namedTypes.ObjectExpression} ObjectExpression */
/** @typedef {import("recast").types.namedTypes.ArrayExpression} ArrayExpression */
/** @typedef {import("recast").types.namedTypes.CallExpression} CallExpression */
/** @typedef {import("recast").types.namedTypes.Property} Property */
/** @typedef {import("recast").types.namedTypes.MemberExpression} MemberExpression */
/** @typedef {import("recast").types.namedTypes.Program} Program */
/** @typedef {import("recast").types.namedTypes.Statement} Statement */
/** @typedef {import("recast").types.namedTypes.Literal} Literal */
/** @typedef {import("recast").types.namedTypes.SpreadElement} SpreadElement */
/** @typedef {import("recast").types.namedTypes.ExportDefaultDeclaration} ExportDefaultDeclaration */
/** @typedef {import("recast").types.namedTypes.AssignmentExpression} AssignmentExpression */
/** @typedef {import("./types.js").MigrationImport} MigrationImport */
//-----------------------------------------------------------------------------
// Data
//-----------------------------------------------------------------------------
const keysToCopy = ["settings", "rules", "processor"];
const linterOptionsKeysToCopy = [
"noInlineConfig",
"reportUnusedDisableDirectives",
];
//-----------------------------------------------------------------------------
// Classes
//-----------------------------------------------------------------------------
/**
* Represents a migration from one config to another.
*/
class Migration {
/**
* Any imports required for the new config.
* @type {Map<string,MigrationImport>}
*/
imports = new Map();
/**
* Any messages to display to the user.
* @type {string[]}
*/
messages = [];
/**
* Whether or not the migration needs the `__dirname` variable defined.
* @type {boolean}
*/
needsDirname = false;
/**
* Any initialization needed in the file.
* @type {Array<Statement>}
*/
inits = [];
/**
* For `env`, we need the `globals` package if there are any environments
* that aren't ECMAScript environments and also aren't from plugins
* (the name contains a slash).
* @param {Object} [env] The environment object from the config.
* @returns {void}
*/
importGlobalsIfNeeded(env) {
const needsGlobals =
env &&
Object.keys(env).some(
envName => !envName.startsWith("es") && !envName.includes("/"),
);
if (needsGlobals && !this.imports.has("globals")) {
this.imports.set("globals", {
name: "globals",
added: true,
});
}
}
/**
* Adds the necessary imports for the migration to use FlatCompat.
* @param {boolean} isModule Whether or not the migration is for an ES module.
* @returns {void}
*/
addFlatCompat(isModule) {
if (isModule) {
this.imports.set("node:path", {
name: "path",
added: true,
});
this.imports.set("node:url", {
bindings: ["fileURLToPath"],
added: true,
});
}
this.imports.set("@eslint/js", {
name: "js",
added: true,
});
this.imports.set("@eslint/eslintrc", {
bindings: ["FlatCompat"],
added: true,
});
this.needsDirname ||= isModule;
/* eslint-disable no-use-before-define -- too hard to rearrange */
this.inits.push(...getFlatCompatInit());
/* eslint-enable no-use-before-define -- too hard to rearrange */
}
}
//-----------------------------------------------------------------------------
// Helpers
//-----------------------------------------------------------------------------
const { builders: b } = recast.types;
const { naming } = Legacy;
/**
* Determines if a string is a valid identifier.
* @param {string} name The name to check.
* @returns {boolean} `true` if the name is a valid identifier.
*/
function isValidIdentifier(name) {
return /^[a-z_$][\w$]*$/iu.test(name);
}
/**
* Gets the name of the variable to use for the parser.
* @param {string|undefined} parser The name of the parser.
* @returns {string|undefined} The variable name to use or undefined if none.
*/
function getParserVariableName(parser) {
if (!parser) {
return undefined;
}
if (parser.includes("typescript-eslint")) {
return "tsParser";
}
if (parser.includes("babel")) {
return "babelParser";
}
if (parser === "espree") {
return "espree";
}
return "parser";
}
// cache for plugins needing compat
const pluginsNeedingCompatCache = new Set(pluginsNeedingCompat);
/**
* Converts an array expression to an array.
* @param {ArrayExpression} arrayExpression The array expression to convert.
* @returns {Array<string>} The array.
* @throws {TypeError} If an element in the array expression is not a literal.
*/
function convertArrayExpressionToArray(arrayExpression) {
return arrayExpression.elements.map(element => {
if (element.type === "Literal" && typeof element.value === "string") {
return element.value;
}
throw new TypeError(`Cannot convert ${element.type} to array.`);
});
}
/**
* Converts an object expression to an object.
* @param {ObjectExpression} objectExpression The object expression to convert.
* @returns {Object} The object.
* @throws {TypeError} If a property value is not a literal or identifier.
*/
function convertObjectExpressionToObject(objectExpression) {
if (objectExpression.type !== "ObjectExpression") {
throw new TypeError(
`Cannot convert ${objectExpression.type} to object.`,
);
}
return objectExpression.properties.reduce((object, property) => {
if (property.type === "Property") {
const { key, value } = property;
if (value.type !== "Literal") {
return object;
}
if (key.type === "Literal") {
object[String(key.value)] = value.value;
} else if (key.type === "Identifier") {
object[key.name] = value.value;
}
}
return object;
}, {});
}
/**
* Finds the `module.exports` or `exports` assignment in a CommonJS module.
* @param {Program} ast The AST to search.
* @returns {AssignmentExpression|null} The node representing the exports or null if not found.
*/
function findCommonJSExports(ast) {
let exports = null;
recast.visit(ast, {
visitAssignmentExpression(path) {
if (
path.node.left.type === "MemberExpression" &&
path.node.left.object.type === "Identifier" &&
path.node.left.object.name === "module" &&
path.node.left.property.type === "Identifier" &&
path.node.left.property.name === "exports"
) {
exports = path.node;
return false;
}
this.traverse(path);
return true;
},
});
return exports;
}
/**
* Finds the default export in an ES module.
* @param {Program} ast The AST to search.
* @returns {ExportDefaultDeclaration|null} The node representing the default export or null if not found.
*/
function findDefaultExport(ast) {
let defaultExport = null;
recast.visit(ast, {
visitExportDefaultDeclaration(path) {
defaultExport = path.node;
return false;
},
});
return defaultExport;
}
/**
* Determines if a plugin needs the compat utility.
* @param {string} pluginName The name of the plugin.
* @returns {boolean} `true` if the plugin needs the compat utility.
*/
function pluginNeedsCompat(pluginName) {
const pluginNameToTest = pluginName.includes("/")
? pluginName.slice(0, pluginName.indexOf("/"))
: pluginName;
return pluginsNeedingCompatCache.has(
naming.normalizePackageName(pluginNameToTest, "eslint-plugin"),
);
}
/**
* Determines if a shareable config needs the compat utility.
* @param {string} configName The name of the config.
* @returns {boolean} `true` if the config needs the compat utility.
*/
function configNeedsCompat(configName) {
const configNameToTest = configName.includes("/")
? configName.slice(0, configName.indexOf("/"))
: configName;
const fullConfigName = naming.normalizePackageName(
configNameToTest,
"eslint-config",
);
return configsNeedingCompat.includes(fullConfigName);
}
/**
* Gets the name of the variable to use for the plugin. If the plugin name
* contains slashes or an @ symbol, it will be normalized to a camelcase name.
* If the name is "import" or "export", it will be prefixed with an underscore.
* @param {string} pluginName The name of the plugin.
* @returns {string} The variable name to use.
*/
function getPluginVariableName(pluginName) {
let name = pluginName.replace(/^eslint-plugin-/u, "");
if (name === "import" || name === "export") {
return `_${name}`;
}
if (name.startsWith("@")) {
name = name.slice(1);
}
// replace slash with uppercase of following letter
name = name.replace(/\/(.)/gu, (_, letter) => letter.toUpperCase());
return camelCase(name);
}
/**
* Get the initialization code for `__dirname`.
* @returns {Array<Statement>} The AST for the initialization block.
*/
function getDirnameInit() {
/*
* Recast doesn't support `import.meta.url`, so using an uppercase "I" to
* allow for parsing. We then need to replace it with the lowercase "i".
*/
const init = `\n
const __filename = fileURLToPath(Import.meta.url);
const __dirname = path.dirname(__filename);`;
const result = recast.parse(init).program.body;
// Replace uppercase "I" with lowercase "i" in "Import.meta.url"
result[0].declarations[0].init.arguments[0].object.object.name = "import";
return result;
}
/**
* Creates an initialization block for the FlatCompat utility.
* @returns {Array<Statement>} The AST for the initialization block.
*/
function getFlatCompatInit() {
const init = `
const compat = new FlatCompat({
baseDirectory: __dirname,
recommendedConfig: js.configs.recommended,
allConfig: js.configs.all
});
`;
return recast.parse(init).program.body;
}
/**
* Creates an initialization block for the gitignore file.
* @returns {Statement} The AST for the initialization block.
*/
function getGitignoreInit() {
const init = `
const gitignorePath = path.resolve(__dirname, ".gitignore");
`;
return recast.parse(init).program.body[0];
}
/**
* Converts a glob pattern to a format that can be used in a flat config.
* @param {string} pattern The glob pattern to convert.
* @returns {string} The converted glob pattern.
*/
function convertGlobPattern(pattern) {
const isNegated = pattern.startsWith("!");
const patternToTest = isNegated ? pattern.slice(1) : pattern;
// if the pattern is already in the correct format, return it
if (patternToTest === "**" || patternToTest.includes("/")) {
return pattern;
}
return `${isNegated ? "!" : ""}**/${patternToTest}`;
}
/**
* Creates the entry for the gitignore inclusion.
* @param {Migration} migration The migration object.
* @returns {CallExpression} The AST for the gitignore entry.
*/
function createGitignoreEntry(migration) {
migration.inits.push(getGitignoreInit());
if (!migration.imports.has("@eslint/compat")) {
migration.imports.set("@eslint/compat", {
bindings: ["includeIgnoreFile"],
added: true,
});
} else {
migration.imports
.get("@eslint/compat")
.bindings.push("includeIgnoreFile");
}
if (!migration.imports.has("node:path")) {
migration.imports.set("node:path", {
name: "path",
added: true,
});
}
const code = `includeIgnoreFile(gitignorePath)`;
return recast.parse(code).program.body[0].expression;
}
/**
* Creates the globals object from the config.
* @param {LegacyConfig} config The config to create globals from.
* @returns {ObjectExpression|undefined} The globals object or undefined if none.
*/
function createGlobals(config) {
const { globals, env } = config;
if (!globals && !env) {
return undefined;
}
const properties = [];
if (env) {
properties.push(
...Object.keys(env)
.filter(name => !name.startsWith("es"))
.map(name => {
let envName = name;
const memberExpression = b.memberExpression(
b.identifier("globals"),
b.identifier(name),
);
// plugins environments in the form plugin/env
if (name.includes("/")) {
const [pluginName, pluginEnvName] = name.split("/");
const pluginVariableName =
getPluginVariableName(pluginName);
// looks like plugin.environments.envName.globals
memberExpression.object = b.memberExpression(
b.memberExpression(
b.identifier(pluginVariableName),
b.identifier("environments"),
),
isValidIdentifier(pluginEnvName)
? b.identifier(pluginEnvName)
: b.literal(pluginEnvName),
!isValidIdentifier(pluginEnvName),
);
memberExpression.property = b.identifier("globals");
envName = pluginEnvName;
}
// if the name is not a valid identifier, use computed syntax
if (!isValidIdentifier(envName)) {
memberExpression.computed = true;
memberExpression.property = b.literal(envName);
}
const envValue = env[name];
// environment is enabled
if (envValue) {
return b.spreadProperty(memberExpression);
}
// environment is disabled
return b.spreadProperty(
b.callExpression(
b.memberExpression(
b.identifier("Object"),
b.identifier("fromEntries"),
),
[
b.callExpression(
b.memberExpression(
b.callExpression(
b.memberExpression(
b.identifier("Object"),
b.identifier("entries"),
),
[memberExpression],
),
b.identifier("map"),
),
[
b.arrowFunctionExpression(
[
b.arrayPattern([
b.identifier("key"),
]),
],
b.arrayExpression([
b.identifier("key"),
b.literal("off"),
]),
),
],
),
],
),
);
}),
);
}
if (globals) {
properties.push(
...Object.keys(globals).map(name =>
b.property(
"init",
b.identifier(name),
b.literal(globals[name]),
),
),
);
}
return b.objectExpression(properties);
}
/**
* Creates the linter options object from the config.
* @param {LegacyConfig} config The config to create linter options from.
* @returns {ObjectExpression|undefined} The linter options object or undefined if none.
*/
function createLinterOptions(config) {
if (!config.noInlineConfig && !config.reportUnusedDisableDirectives) {
return undefined;
}
const properties = [];
linterOptionsKeysToCopy.forEach(key => {
if (config[key]) {
properties.push(
b.property("init", b.identifier(key), b.literal(config[key])),
);
}
});
return b.objectExpression(properties);
}
/**
* Creates an array of function arguments from an array of extended configs.
* @param {string|string[]} extendedConfigs The extended configs to convert.
* @returns {Array<Literal>} The AST for the array expression.
*/
function createExtendsArguments(extendedConfigs) {
// create an array of strings
if (typeof extendedConfigs === "string") {
return [b.literal(extendedConfigs)];
}
return extendedConfigs.map(config => b.literal(config));
}
/**
* Creates a an object expression, array expression, or literal that duplicates an existing value.
* @param {Object} value The object to create the AST for.
* @returns {ObjectExpression|ArrayExpression|Literal} The AST for the value.
*/
function createAST(value) {
if (Array.isArray(value)) {
return b.arrayExpression(value.map(item => createAST(item)));
}
if (value && typeof value === "object") {
const properties = Object.keys(value).map(key => {
const propertyValue = value[key];
const identifier = isValidIdentifier(key)
? b.identifier(key)
: b.literal(key);
return b.property("init", identifier, createAST(propertyValue));
});
return b.objectExpression(properties);
}
return b.literal(value);
}
/**
* Creates an array expression from an array of glob patterns.
* @param {string[]} patterns The glob patterns to convert.
* @returns {ArrayExpression} The AST for the array expression.
*/
function createFilesArray(patterns) {
return b.arrayExpression(
patterns.map(pattern => b.literal(convertGlobPattern(pattern))),
);
}
/**
* Creates an array expression from a node representing files.
* @param {ArrayExpression|Literal} files The node to convert.
* @returns {ArrayExpression} The AST for the array expression.
*/
function createFilesArrayFromNode(files) {
if (files.type === "ArrayExpression") {
return b.arrayExpression(
files.elements.map(element => {
if (
element.type === "Literal" &&
typeof element.value === "string"
) {
return b.literal(convertGlobPattern(element.value));
}
return element;
}),
);
}
if (files.type === "Literal" && typeof files.value === "string") {
return b.arrayExpression([b.literal(convertGlobPattern(files.value))]);
}
throw new TypeError(`Cannot convert ${files.type} to array.`);
}
/**
* Creates an object expression for the language options.
* @param {Migration} migration The migration object.
* @param {LegacyConfig} config The config to create language options from.
* @returns {ObjectExpression|undefined} The AST for the object expression or undefined if none.
*/
function createLanguageOptions(migration, config) {
const properties = [];
const { imports, messages } = migration;
// Both `env` and `globals` end up as globals in flat config
const globals = createGlobals(config);
if (globals) {
properties.push(b.property("init", b.identifier("globals"), globals));
}
migration.importGlobalsIfNeeded(config.env);
// Copy over `parser`
const parserName = getParserVariableName(config.parser);
if (parserName) {
properties.push(
b.property(
"init",
b.identifier("parser"),
b.identifier(parserName),
),
);
imports.set(config.parser, {
name: parserName,
});
}
// Copy over `parserOptions`
if (config.parserOptions) {
const {
ecmaVersion = 5,
sourceType = "script",
...otherParserOptions
} = config.parserOptions;
// move `ecmaVersion` to `languageOptions`
properties.push(
b.property(
"init",
b.identifier("ecmaVersion"),
b.literal(ecmaVersion),
),
);
// move `sourceType` to `languageOptions` -- be sure to check for Node.js environment
/** @type {"module"|"script"|"commonjs"} */
let finalSourceType = sourceType;
if (config?.env?.node) {
if (sourceType === "module") {
messages.push(
"The 'node' environment is used, but the sourceType is 'module'. Using sourceType 'module'. If you want to use CommonJS modules, set the sourceType to 'commonjs'.",
);
} else {
finalSourceType = "commonjs";
messages.push(
"The 'node' environment is used, so switching sourceType to 'commonjs'.",
);
}
}
properties.push(
b.property(
"init",
b.identifier("sourceType"),
b.literal(finalSourceType),
),
);
if (Object.keys(otherParserOptions).length > 0) {
properties.push(
b.property(
"init",
b.identifier("parserOptions"),
createAST(otherParserOptions),
),
);
}
}
return properties.length ? b.objectExpression(properties) : undefined;
}
/**
* Creates an object expression for the plugins array. Also adds the necessary imports
* to the migration imports map.
* @param {string[]} plugins The plugins to create the object expression for.
* @param {Migration} migration The migration object.
* @returns {ObjectExpression} The AST for the object expression.
*/
function createPlugins(plugins, migration) {
const { imports } = migration;
const properties = [];
const compatNeeded = plugins.reduce((previous, pluginName) => {
const pluginVariableName = getPluginVariableName(pluginName);
const shortPluginName = naming.getShorthandName(
pluginName,
"eslint-plugin",
);
const needsCompat = pluginNeedsCompat(pluginName);
const pluginValue = needsCompat
? b.callExpression(b.identifier("fixupPluginRules"), [
b.identifier(pluginVariableName),
])
: b.identifier(pluginVariableName);
const pluginsProperty = b.property(
"init",
isValidIdentifier(shortPluginName)
? b.identifier(shortPluginName)
: b.literal(shortPluginName),
pluginValue,
);
if (pluginVariableName === shortPluginName && !needsCompat) {
pluginsProperty.shorthand = true;
}
properties.push(pluginsProperty);
imports.set(naming.normalizePackageName(pluginName, "eslint-plugin"), {
name: pluginVariableName,
});
return needsCompat || previous;
}, false);
if (compatNeeded) {
if (!migration.imports.has("@eslint/compat")) {
migration.imports.set("@eslint/compat", {
bindings: ["fixupPluginRules"],
added: true,
});
} else if (
!migration.imports
.get("@eslint/compat")
.bindings.includes("fixupPluginRules")
) {
migration.imports
.get("@eslint/compat")
.bindings.push("fixupPluginRules");
}
}
return b.objectExpression(properties);
}
/**
* Creates an object expression for the `ignorePatterns` property.
* @param {string|string[]} ignorePatterns The config to create the object expression for.
* @returns {CallExpression} The AST for the object expression.
*/
function createGlobalIgnores(ignorePatterns) {
const ignorePatternsArray = Array.isArray(ignorePatterns)
? ignorePatterns
: [ignorePatterns];
const ignorePatternsArrayExpression = b.arrayExpression(
ignorePatternsArray.map(pattern =>
b.literal(convertIgnorePatternToMinimatch(pattern)),
),
);
return b.callExpression(b.identifier("globalIgnores"), [
ignorePatternsArrayExpression,
]);
}
/**
* Creates a call expression for the `globalIgnores` property when
* passed a node.
* @param {ArrayExpression|Literal} ignoresPatterns The node to create the call expression from.
* @returns {CallExpression} The AST for the call expression.
*/
function createGlobalIgnoresFromNode(ignoresPatterns) {
const arrayExpression =
ignoresPatterns.type === "ArrayExpression"
? ignoresPatterns
: b.arrayExpression([ignoresPatterns]);
const ignorePatternsArrayExpression = b.arrayExpression(
arrayExpression.elements.map(element =>
element.type === "Literal"
? b.literal(
typeof element.value === "string"
? convertIgnorePatternToMinimatch(element.value)
: element.value,
)
: element,
),
);
return b.callExpression(b.identifier("globalIgnores"), [
ignorePatternsArrayExpression,
]);
}
/**
* Creates a call expression for the `extends` property.
* @param {string|string[]} configExtends The array of extends to create the call expression for.
* @param {Migration} migration The migration object.
* @returns {CallExpression} The AST for the call expression.
*/
function createExtendsCallExpression(configExtends, migration) {
let extendsCallExpression = b.callExpression(
b.memberExpression(b.identifier("compat"), b.identifier("extends")),
createExtendsArguments(configExtends),
);
const extendsArray = Array.isArray(configExtends)
? configExtends
: [configExtends];
// Check if any of the extends are plugins that need the compat utility
const needsCompat = extendsArray.some(extend => {
if (
extend.startsWith("eslint:") ||
extend.startsWith(".") ||
extend.startsWith("/")
) {
return false;
}
if (extend.startsWith("plugin:")) {
return pluginNeedsCompat(extend.slice(7));
}
return configNeedsCompat(extend);
});
if (needsCompat) {
/*
* When even one `extends` item needs compat, we need to mark every
* plugin as needing compat. This is because the `fixupConfigRules`
* function will be called on the entire object, and if any of those
* plugins is also referenced in `plugins`, the user will get a
* "can't redefine plugin" error.
*/
extendsArray.forEach(extend => {
if (extend.startsWith("plugin:")) {
const pluginName = extend.slice(7, extend.indexOf("/"));
const normalizedPluginName = naming.normalizePackageName(
pluginName,
"eslint-plugin",
);
pluginsNeedingCompatCache.add(normalizedPluginName);
}
});
if (!migration.imports.has("@eslint/compat")) {
migration.imports.set("@eslint/compat", {
bindings: ["fixupConfigRules"],
added: true,
});
} else {
migration.imports
.get("@eslint/compat")
.bindings.push("fixupConfigRules");
}
extendsCallExpression = b.callExpression(
b.identifier("fixupConfigRules"),
[extendsCallExpression],
);
}
return extendsCallExpression;
}
/**
* Migrates a config object to the flat config format.
* @param {Migration} migration The migration object.
* @param {ConfigOverride} config The config object to migrate.
* @returns {Array<ObjectExpression|SpreadElement>} The AST for the object expression.
*/
function migrateConfigObject(migration, config) {
const configArrayElements = [];
const properties = [];
let files, ignores;
// Copy over `files` -- should end up first by convention
if (config.files) {
files = createFilesArray(
Array.isArray(config.files) ? config.files : [config.files],
);
properties.push(b.property("init", b.identifier("files"), files));
}
// Copy over `excludedFiles` -- should end up first if no `files` or second if `files` is present
if (config.excludedFiles) {
ignores = createFilesArray(
Array.isArray(config.excludedFiles)
? config.excludedFiles
: [config.excludedFiles],
);
properties.push(b.property("init", b.identifier("ignores"), ignores));
}
// Handle `extends`
if (config.extends) {
properties.push(
b.property(
"init",
b.identifier("extends"),
createExtendsCallExpression(config.extends, migration),
),
);
}
/*
* Copy over plugins. This must happen after processing `extends` in order to
* properly account for plugins that need the compat utility.
*/
if (config.plugins) {
properties.push(
b.property(
"init",
b.identifier("plugins"),
createPlugins(config.plugins, migration),
),
);
}
// Copy over `noInlineConfig` and `reportUnusedDisableDirectives`
const linterOptions = createLinterOptions(config);
if (linterOptions) {
properties.push(
b.property("init", b.identifier("linterOptions"), linterOptions),
);
}
// Create `languageOptions` from `env`, `globals`, `parser`, and `parserOptions`
const languageOptions = createLanguageOptions(migration, config);
if (languageOptions) {
properties.push(
b.property(
"init",
b.identifier("languageOptions"),
languageOptions,
),
);
}
// Copy over everything that stays the same - `settings`, `rules`, `processor`
keysToCopy.forEach(key => {
if (config[key]) {
const propertyValue =
typeof config[key] === "object"
? createAST(config[key])
: b.literal(config[key]);
properties.push(
b.property("init", b.identifier(key), propertyValue),
);
}
});
/*
* If there is an `extends` with a `files` and/or `ignores`, then it's possible this object
* will contain only `files` (and/or `ignores`), in which case we don't need it because there
* is already a config object with the same properties.
*/
const objectIsNeeded =
!config.extends ||
properties.some(property => {
if (property.key.type === "Identifier") {
return (
property.key.name !== "files" &&
property.key.name !== "ignores"
);
}
return true;
});
if (objectIsNeeded) {
configArrayElements.push(b.objectExpression(properties));
}
return configArrayElements;
}
/**
* Creates import/require statements from the migration imports map
* @param {Migration} migration The migration object
* @param {boolean} isModule Whether the output is a module or not
* @returns {Array<Statement>} Array of import/require statements
*/
function addImports(migration, isModule) {
const imports = [];
if (!isModule) {
migration.imports.forEach(({ name, bindings }, path) => {
const bindingProperties = bindings?.map(binding => {
const bindingProperty = b.property(
"init",
b.identifier(binding),
b.identifier(binding),
);
bindingProperty.shorthand = true;
return bindingProperty;
});
imports.push(
name
? b.variableDeclaration("const", [
b.variableDeclarator(
b.identifier(name),
b.callExpression(b.identifier("require"), [
b.literal(path),
]),
),
])
: b.variableDeclaration("const", [
b.variableDeclarator(
b.objectPattern(bindingProperties),
b.callExpression(b.identifier("require"), [
b.literal(path),
]),
),
]),
);
});
} else {
migration.imports.forEach(({ name, bindings }, path) => {
imports.push(
name
? b.importDeclaration(
[b.importDefaultSpecifier(b.identifier(name))],
b.literal(path),
)
: b.importDeclaration(
bindings.map(binding =>
b.importSpecifier(b.identifier(binding)),
),
b.literal(path),
),
);
});
}
return imports;
}
/**
* Converts an ObjectExpression eslintrc config to a flat config.
* @param {ObjectExpression} config The config object to convert.
* @param {Migration} migration The migration object.
* @returns {Array<any>} The converted config.
*/
function convertLegacyConfigExpression(config, migration) {
const newProperties = [];
/** @type {Array<any>} */
const configArray = [b.objectExpression(newProperties)];
const linterOptionsProperties = [];
const languageOptionsProperties = [];
/** @type {ObjectExpression} */
let globals;
function createLanguageOptionsNode() {
if (languageOptionsProperties.length === 0) {
newProperties.push(
b.property(
"init",
b.identifier("languageOptions"),
b.objectExpression(languageOptionsProperties),
),
);
}
}
function createGlobalsNode() {
if (globals) {
languageOptionsProperties.push(
b.property("init", b.identifier("globals"), globals),
);
}
}
for (const property of config.properties) {
// just copy over non-property nodes and pray it works
if (property.type !== "Property") {
newProperties.push(property);
continue;
}
const { key, value } = property;
// just copy over non-string keys
if (key.type !== "Identifier" && key.type !== "Literal") {
newProperties.push(property);
continue;
}
const name = key.type === "Identifier" ? key.name : key.value;
switch (name) {
// remove root
case "root":
// do not add root to the new properties
break;
// convert parser
case "parser": {
if (
value.type === "Literal" &&
typeof value.value === "string"
) {
const parserName = getParserVariableName(value.value);
if (parserName) {
if (languageOptionsProperties.length === 0) {
createLanguageOptionsNode();
}
languageOptionsProperties.push(
b.property(
"init",
b.identifier("parser"),
b.identifier(parserName),
),
);
migration.imports.set(value.value, {
name: parserName,
});
}
}
break;
}
case "parserOptions": {
if (languageOptionsProperties.length === 0) {
createLanguageOptionsNode();
}
// if the value is not an object expression, then just copy it over
if (value.type !== "ObjectExpression") {
languageOptionsProperties.push(property);
break;
}
// move ecmaVersion and sourceType properties up one level
if (value.properties) {
const indicesToRemove = [];
value.properties.forEach((prop, i) => {
if (prop.type !== "Property") {
return;
}
let parserOptionsKey;
if (prop.key.type === "Identifier") {
parserOptionsKey = prop.key.name;
} else if (prop.key.type === "Literal") {
parserOptionsKey = prop.key.value;
} else {
return;
}
if (
parserOptionsKey === "ecmaVersion" ||
parserOptionsKey === "sourceType"
) {
languageOptionsProperties.push(prop);
indicesToRemove.push(i);
}
});
// remove the properties we just moved
for (let i = indicesToRemove.length - 1; i >= 0; i--) {
value.properties.splice(indicesToRemove[i], 1);
}
}
languageOptionsProperties.push(
b.property("init", b.identifier("parserOptions"), value),
);
break;
}
// convert env
case "env": {
if (languageOptionsProperties.length === 0) {
createLanguageOptionsNode();
}
// if the value is not an object expression, then just copy it over
if (value.type !== "ObjectExpression") {
newProperties.push(property);
break;
}
const env = convertObjectExpressionToObject(value);
const envGlobals = createGlobals({ env });
migration.importGlobalsIfNeeded(env);
// if globals already exists, then prepend envGlobals to the same object
if (globals) {
globals.properties.unshift(...envGlobals.properties);
} else {
globals = envGlobals;
createGlobalsNode();
}
break;
}
// convert globals
case "globals": {
if (languageOptionsProperties.length === 0) {
createLanguageOptionsNode();
}
// if it's not an object expression then just copy it over
if (value.type !== "ObjectExpression") {
languageOptionsProperties.push(property);
break;
}
// if globals already exists then append properties to it
if (globals) {
globals.properties.push(...value.properties);
} else {
globals = value;
createGlobalsNode();
}
break;
}
// convert plugins
case "plugins": {
if (value.type === "ArrayExpression") {
const plugins = convertArrayExpressionToArray(value);
newProperties.push(
b.property(
"init",
b.identifier("plugins"),
createPlugins(plugins, migration),
),
);
}
break;
}
// convert extends
case "extends": {
// if it's not an array expression then just copy it over
if (value.type !== "ArrayExpression") {
newProperties.push(property);
break;
}
newProperties.push(
b.property(
"init",
b.identifier("extends"),
createExtendsCallExpression(
convertArrayExpressionToArray(value),
migration,
),
),
);
break;
}
// copy over linter options
case "noInlineConfig":
case "reportUnusedDisableDirectives": {
// if linterOptions doesn't exist yet, then create it
if (linterOptionsProperties.length === 0) {
newProperties.push(
b.property(
"init",
b.identifier("linterOptions"),
b.objectExpression(linterOptionsProperties),
),
);
}
linterOptionsProperties.push(property);
break;
}
case "ignorePatterns":
// if the value is not an array expression then just ignore it
if (value.type !== "ArrayExpression") {
migration.messages.push(
`Unexpected type for ${name}: ${value.type}. Ignoring...`,
);
break;
}
if (
!migration.imports
.get("eslint/config")
.bindings.includes("globalIgnores")
) {
migration.imports
.get("eslint/config")
.bindings.push("globalIgnores");
}
configArray.push(createGlobalIgnoresFromNode(value));
break;
case "overrides":
// if the value is not an array expression then just ignore it
if (value.type !== "ArrayExpression") {
migration.messages.push(
`Unexpected type for ${name}: ${value.type}. Ignoring...`,
);
break;
}
configArray.push(
...value.elements.flatMap(element =>
element.type === "ObjectExpression"
? convertLegacyConfigExpression(element, migration)
: element,
),
);
break;
case "files":
case "excludedFiles": {
// if the value is not an array expression or literal, then just ignore it
if (
value.type !== "ArrayExpression" &&
value.type !== "Literal"
) {
migration.messages.push(
`Unexpected type for ${name}: ${value.type}. Ignoring...`,
);
break;
}
const filesExpression = createFilesArrayFromNode(value);
newProperties.push(
b.property(
"init",
b.identifier(
name === "excludedFiles" ? "ignores" : "files",
),
filesExpression,
),
);
break;
}
// rules, settings, and processor are copied over as is
default:
newProperties.push(property);
}
}
return configArray;
}
//-----------------------------------------------------------------------------
// Migration Methods
//-----------------------------------------------------------------------------
/**
* Migrates an eslintrc config to flat config format.
* @param {LegacyConfig} config The eslintrc config to migrate.
* @param {Object} [options] Options for the migration.
* @param {"module"|"commonjs"} [options.sourceType] The module type to output.
* @param {string[]} [options.ignorePatterns] An array of glob patterns to ignore.
* @param {boolean} [options.gitignore] `true` to include contents of a .gitignore file.
* @returns {{code:string,messages:Array<string>,imports:Map<string,MigrationImport>}} The migrated config and
* any messages to display to the user.
*/
export function migrateConfig(
config,
{ sourceType = "module", ignorePatterns, gitignore = false } = {},
) {
const migration = new Migration();
const body = [];
// add ignore patterns from .eslintignore
if (ignorePatterns) {
if (!config.ignorePatterns) {
config.ignorePatterns = [];
}
// put the .eslintignore patterns last so they can override config ignores
config.ignorePatterns = [...config.ignorePatterns, ...ignorePatterns];
}
// always use defineConfig
migration.imports.set("eslint/config", {
bindings: ["defineConfig"],
});
/** @type {Array<CallExpression|ObjectExpression|SpreadElement>} */
const configArrayElements = [
...migrateConfigObject(
migration,
/** @type {ConfigOverride} */ (config),
),
];
const isModule = sourceType === "module";
// if the base config has no properties, then remove the empty object
if (
configArrayElements[0].type === "ObjectExpression" &&
configArrayElements[0].properties.length === 0
) {
configArrayElements.shift();
}
// add any overrides
if (config.overrides) {
config.overrides.forEach(override => {
configArrayElements.push(
...migrateConfigObject(migration, override),
);
});
}
// if any config has extends then we need to add imports
if (
config.extends ||
config.overrides?.some(override => override.extends)
) {
migration.addFlatCompat(isModule);
}
// add .gitignore if necessary
if (gitignore) {
migration.needsDirname ||= isModule;
configArrayElements.unshift(createGitignoreEntry(migration));
if (migration.needsDirname && !migration.imports.has("node:url")) {
migration.imports.set("node:url", {
bindings: ["fileURLToPath"],
added: true,
});
}
}
if (config.ignorePatterns) {
migration.imports.get("eslint/config").bindings.push("globalIgnores");
configArrayElements.unshift(createGlobalIgnores(config.ignorePatterns));
}
// add imports to the top of the file
// Add imports in either CJS or ESM format
const imports = addImports(migration, isModule);
body.push(...imports);
// add calculation of `__dirname` if needed
if (migration.needsDirname) {
body.push(...getDirnameInit());
}
// output any inits
body.push(...migration.inits);
// the defineConfig() call
const defineConfigNode = b.callExpression(b.identifier("defineConfig"), [
b.arrayExpression(configArrayElements),
]);
// output the actual config array to the program
if (!isModule) {
body.push(
b.expressionStatement(
b.assignmentExpression(
"=",
b.memberExpression(
b.identifier("module"),
b.identifier("exports"),
),
defineConfigNode,
),
),
);
} else {
body.push(b.exportDefaultDeclaration(defineConfigNode));
}
return {
// Recast doesn't export the `StatementKind` type so we need to cast the body to `Array<any>`
code: recast.print(b.program(/** @type {Array<any>}*/ (body)), {
tabWidth: 4,
trailingComma: true,
lineTerminator: "\n",
}).code,
messages: migration.messages,
imports: migration.imports,
};
}
/**
* Migrates a JS config file to the flat config format.
* @param {string} code The JS config file to migrate.
* @param {Object} [options] Options for the migration.
* @param {string[]} [options.ignorePatterns] An array of glob patterns to ignore.
* @param {boolean} [options.gitignore] `true` to include contents of a .gitignore file.
* @returns {{code:string,messages:Array<string>,imports:Map<string,MigrationImport>}} The migrated config and
* any messages to display to the user.
*/
export function migrateJSConfig(
code,
{ ignorePatterns, gitignore = false } = {},
) {
// first parse the code
const ast = recast.parse(code, {
parser: {
parse(source) {
return espree.parse(source, {
sourceType: "module",
ecmaVersion: 2024,
});
},
},
});
const cjsExports = findCommonJSExports(ast);
const isModule = !cjsExports;
const esmExport = isModule ? findDefaultExport(ast) : null;
const oldConfig = isModule ? esmExport.declaration : cjsExports.right;
const migration = new Migration();
const body = ast.program.body;
if (!oldConfig || oldConfig.type !== "ObjectExpression") {
throw new TypeError(
"Config object isn't an object expression. Aborting.",
);
}
/** @type {Array<any>} */
const configArrayElements = [];
// always use defineConfig
migration.imports.set("eslint/config", {
bindings: ["defineConfig"],
});
// add .gitignore if necessary
if (gitignore) {
migration.needsDirname ||= isModule;
configArrayElements.unshift(createGitignoreEntry(migration));
if (migration.needsDirname && !migration.imports.has("node:url")) {
migration.imports.set("node:url", {
bindings: ["fileURLToPath"],
added: true,
});
}
}
configArrayElements.push(
...convertLegacyConfigExpression(oldConfig, migration),
);
// add ignore patterns from .eslintignore
if (ignorePatterns) {
if (
!migration.imports
.get("eslint/config")
.bindings.includes("globalIgnores")
) {
migration.imports
.get("eslint/config")
.bindings.push("globalIgnores");
}
configArrayElements.push(createGlobalIgnores(ignorePatterns));
}
// if any config has extends then we need to add imports
if (
configArrayElements.some(
element =>
element.type === "ObjectExpression" &&
element.properties.some(
property => property.key.name === "extends",
),
)
) {
migration.addFlatCompat(isModule);
}
const defineConfigNode = b.callExpression(b.identifier("defineConfig"), [
b.arrayExpression(configArrayElements),
]);
if (isModule) {
esmExport.declaration = defineConfigNode;
} else {
cjsExports.right = defineConfigNode;
}
const bodyAdditions = [...addImports(migration, isModule)];
// add calculation of `__dirname` if needed
if (migration.needsDirname) {
bodyAdditions.push(...getDirnameInit());
}
// output any inits
bodyAdditions.push(...migration.inits);
// outside of ESM we need to be careful of "use strict" directives
const startIndex = !isModule && body[0].directive === "use strict" ? 1 : 0;
body.splice(startIndex, 0, ...bodyAdditions);
return {
// Recast doesn't export the `StatementKind` type so we need to cast the body to `Array<any>`
code: recast.print(ast, {
tabWidth: 4,
trailingComma: true,
lineTerminator: "\n",
}).code,
messages: migration.messages,
imports: migration.imports,
};
}